SettingsView.tsx 132 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193
  1. import React, { useState, useEffect } from 'react';
  2. import { createPortal } from 'react-dom';
  3. import { ModelConfig, ModelType, AppSettings, KnowledgeGroup, Tenant, TenantMember, DEFAULT_SETTINGS } from '../../types';
  4. import { useLanguage } from '../../contexts/LanguageContext';
  5. import {
  6. ChevronLeft,
  7. ChevronRight,
  8. Plus,
  9. Search,
  10. KeyRound,
  11. Trash2,
  12. Edit,
  13. UserPlus,
  14. Globe,
  15. PlusCircle,
  16. Clock,
  17. ExternalLink,
  18. Download,
  19. Upload,
  20. Building,
  21. Settings as SettingsIcon,
  22. Shield,
  23. User,
  24. MoreVertical,
  25. Check,
  26. ChevronDown,
  27. ChevronUp,
  28. Filter,
  29. RefreshCcw,
  30. LayoutDashboard,
  31. Users,
  32. Database,
  33. UserCircle,
  34. HardDrive,
  35. LayoutGrid,
  36. X,
  37. Key,
  38. Loader2,
  39. Edit2,
  40. Save,
  41. Cpu,
  42. BookOpen,
  43. Sparkles,
  44. ToggleRight,
  45. ToggleLeft,
  46. FileText,
  47. } from "lucide-react";
  48. import { motion, AnimatePresence } from 'framer-motion';
  49. import { userService } from '../../services/userService';
  50. import { settingsService } from '../../services/settingsService';
  51. import { userSettingService } from '../../services/userSettingService';
  52. import { knowledgeGroupService } from '../../services/knowledgeGroupService';
  53. import { apiClient } from '../../services/apiClient';
  54. import { AssessmentTemplateManager } from './AssessmentTemplateManager';
  55. import { useConfirm } from '../../contexts/ConfirmContext';
  56. import { useToast } from '../../contexts/ToastContext';
  57. interface SettingsViewProps {
  58. // Model Props
  59. models: ModelConfig[];
  60. authToken: string | null;
  61. onUpdateModels: (action: 'create' | 'update' | 'delete', model: ModelConfig) => Promise<void>;
  62. isAdmin?: boolean; // Added isAdmin prop
  63. currentUser?: any; // Added current user prop
  64. initialTab?: TabType;
  65. }
  66. type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks' | 'assessment_templates';
  67. const buildTenantTree = (tenants: Tenant[]): Tenant[] => {
  68. const map = new Map<string, Tenant>();
  69. const roots: Tenant[] = [];
  70. tenants.forEach(t => {
  71. map.set(t.id, { ...t, children: [] });
  72. });
  73. tenants.forEach(t => {
  74. const node = map.get(t.id)!;
  75. if (t.parentId && map.has(t.parentId)) {
  76. const parent = map.get(t.parentId)!;
  77. parent.children = parent.children || [];
  78. parent.children.push(node);
  79. } else {
  80. roots.push(node);
  81. }
  82. });
  83. return roots;
  84. };
  85. // Moved outside to prevent re-mounting
  86. const Pagination: React.FC<{
  87. current: number;
  88. total: number;
  89. pageSize: number;
  90. onChange: (page: number) => void;
  91. }> = ({ current, total, pageSize, onChange }) => {
  92. const totalPages = Math.ceil(total / pageSize);
  93. if (totalPages <= 1) return null;
  94. return (
  95. <div className="flex items-center justify-center gap-2 mt-6">
  96. <button
  97. disabled={current === 1}
  98. onClick={() => onChange(current - 1)}
  99. className="p-2 rounded-xl bg-white border border-slate-200 text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-slate-50 transition-colors"
  100. >
  101. <ChevronDown className="w-4 h-4 rotate-90" />
  102. </button>
  103. <div className="flex items-center gap-1">
  104. {[...Array(totalPages)].map((_, i) => {
  105. const p = i + 1;
  106. if (totalPages > 7) {
  107. if (p !== 1 && p !== totalPages && Math.abs(p - current) > 1) {
  108. if (p === 2 || p === totalPages - 1) return <span key={p} className="px-1 font-bold text-slate-300">...</span>;
  109. return null;
  110. }
  111. }
  112. return (
  113. <button
  114. key={p}
  115. onClick={() => onChange(p)}
  116. className={`w-9 h-9 flex items-center justify-center rounded-xl text-xs font-black transition-all ${current === p ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100' : 'bg-white border border-slate-200 text-slate-500 hover:border-indigo-500 hover:text-indigo-600'}`}
  117. >
  118. {p}
  119. </button>
  120. );
  121. })}
  122. </div>
  123. <button
  124. disabled={current === totalPages}
  125. onClick={() => onChange(current + 1)}
  126. className="p-2 rounded-xl bg-white border border-slate-200 text-slate-600 disabled:opacity-30 disabled:cursor-not-allowed hover:bg-slate-50 transition-colors"
  127. >
  128. <ChevronDown className="w-4 h-4 -rotate-90" />
  129. </button>
  130. </div>
  131. );
  132. };
  133. export const SettingsView: React.FC<SettingsViewProps> = ({
  134. models,
  135. authToken,
  136. onUpdateModels,
  137. isAdmin = false,
  138. currentUser,
  139. initialTab = 'general',
  140. }) => {
  141. const { t, language, setLanguage } = useLanguage();
  142. const { confirm } = useConfirm();
  143. const { showError, showSuccess } = useToast();
  144. const [activeTab, setActiveTab] = useState<TabType>('general');
  145. const [isLoading, setIsLoading] = useState(false);
  146. const [error, setError] = useState<string | null>(null);
  147. // --- Model Manager State ---
  148. const [editingId, setEditingId] = useState<string | null>(null);
  149. const [modelFormData, setModelFormData] = useState<Partial<ModelConfig>>({
  150. type: ModelType.LLM,
  151. baseUrl: 'http://localhost:11434/v1',
  152. modelId: 'llama3',
  153. name: '',
  154. dimensions: 1536,
  155. apiKey: '',
  156. maxInputTokens: 8191,
  157. maxBatchSize: 2048
  158. });
  159. const [users, setUsers] = useState<any[]>([]);
  160. const [isUserLoading, setIsUserLoading] = useState(false);
  161. const [userPage, setUserPage] = useState(1);
  162. const USER_PAGE_SIZE = 20;
  163. const [showAddUser, setShowAddUser] = useState(false);
  164. const [newUser, setNewUser] = useState({ username: '', password: '', displayName: '' });
  165. const [userSuccess, setUserSuccess] = useState('');
  166. // --- Change Password State ---
  167. const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' });
  168. const [passwordSuccess, setPasswordSuccess] = useState('');
  169. // --- App Settings State ---
  170. const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
  171. const [knowledgeGroups, setKnowledgeGroups] = useState<KnowledgeGroup[]>([]);
  172. const [isSettingsLoading, setIsSettingsLoading] = useState(false);
  173. const [enabledModelIds, setEnabledModelIds] = useState<string[]>([]);
  174. // --- Tenant Admin Binding Search State ---
  175. const [bindingTenantId, setBindingTenantId] = useState<string | null>(null);
  176. const [userSearchQuery, setUserSearchQuery] = useState('');
  177. // --- Manage Members Modal State ---
  178. const [managingMembersTenantId, setManagingMembersTenantId] = useState<string | null>(null);
  179. const [tenantMembers, setTenantMembers] = useState<any[]>([]);
  180. const [allMemberIds, setAllMemberIds] = useState<Set<string>>(new Set());
  181. const [memberUserSearch, setMemberUserSearch] = useState('');
  182. const [bindingRole, setBindingRole] = useState('USER');
  183. const [currentMemberSearch, setCurrentMemberSearch] = useState('');
  184. const [isMembersLoading, setIsMembersLoading] = useState(false);
  185. const [activeTenantManagementId, setActiveTenantManagementId] = useState<string | null>(null);
  186. const [memberPage, setMemberPage] = useState(1);
  187. const [memberTotal, setMemberTotal] = useState(0);
  188. const MEMBER_PAGE_SIZE = 20;
  189. const [userTotal, setUserTotal] = useState(0);
  190. // --- Tenant Tree & Global Management State ---
  191. const [tenants, setTenants] = useState<Tenant[]>([]);
  192. const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
  193. const [stats, setStats] = useState({ users: 0, tenants: 0 });
  194. const [showCreateTenant, setShowCreateTenant] = useState(false);
  195. const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
  196. const [newTenant, setNewTenant] = useState<{ name: string; domain: string; parentId: string | null }>({
  197. name: '',
  198. domain: '',
  199. parentId: null
  200. });
  201. useEffect(() => {
  202. if (initialTab) {
  203. setActiveTab(initialTab);
  204. }
  205. }, [initialTab]);
  206. useEffect(() => {
  207. if (activeTab === 'user' || activeTab === 'tenants') {
  208. fetchUsers(userPage);
  209. }
  210. }, [userPage]);
  211. useEffect(() => {
  212. if (selectedTenantId) {
  213. fetchTenantMembers(selectedTenantId, memberPage);
  214. fetchAllMemberIds(selectedTenantId);
  215. } else {
  216. setAllMemberIds(new Set());
  217. }
  218. }, [selectedTenantId, memberPage]);
  219. // Data fetching on tab change
  220. useEffect(() => {
  221. // Reset pages when switching tabs to avoid bleed-over
  222. if (activeTab === 'user' || activeTab === 'tenants') {
  223. setUserPage(1);
  224. }
  225. if (activeTab === 'user') {
  226. fetchUsers(1);
  227. } else if (activeTab === 'general') {
  228. fetchSettingsAndGroups();
  229. } else if (activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN') {
  230. fetchTenantsData();
  231. fetchUsers(1); // Ensure users are loaded for admin binding
  232. }
  233. // Independent check for KB/Model settings to avoid being blocked by the branches above
  234. if ((activeTab === 'knowledge_base' || activeTab === 'model') &&
  235. (currentUser?.role === 'TENANT_ADMIN' || currentUser?.role === 'SUPER_ADMIN' || isAdmin)) {
  236. fetchKnowledgeBaseSettings();
  237. }
  238. }, [activeTab, currentUser, authToken, isAdmin]);
  239. const [kbSettings, setKbSettings] = useState<any>(null);
  240. const [localKbSettings, setLocalKbSettings] = useState<any>(null);
  241. const [isSavingKbSettings, setIsSavingKbSettings] = useState(false);
  242. const fetchKnowledgeBaseSettings = async () => {
  243. if (!authToken) return;
  244. setIsLoading(true);
  245. try {
  246. const data = await userSettingService.get(authToken);
  247. // If data is null, undefined, or empty object, use DEFAULT_SETTINGS
  248. const finalSettings = (data && Object.keys(data).length > 0) ? { ...DEFAULT_SETTINGS, ...data } : DEFAULT_SETTINGS;
  249. setKbSettings(finalSettings);
  250. setLocalKbSettings(finalSettings);
  251. } catch (error) {
  252. console.error(error);
  253. // Fallback to defaults on error to prevent blank page
  254. setKbSettings(DEFAULT_SETTINGS);
  255. setLocalKbSettings(DEFAULT_SETTINGS);
  256. } finally {
  257. setIsLoading(false);
  258. }
  259. };
  260. const handleUpdateKbSettings = (key: string, value: any) => {
  261. setLocalKbSettings((prev: any) => ({ ...prev, [key]: value }));
  262. };
  263. const handleSaveKbSettings = async () => {
  264. if (!authToken || !localKbSettings) return;
  265. setIsSavingKbSettings(true);
  266. try {
  267. await userSettingService.update(authToken, localKbSettings);
  268. setKbSettings(localKbSettings);
  269. showSuccess(t('kbSettingsSaved'));
  270. } catch (error) {
  271. console.error(error);
  272. showError(t('actionFailed'));
  273. } finally {
  274. setIsSavingKbSettings(false);
  275. }
  276. };
  277. const handleCancelKbSettings = () => {
  278. setLocalKbSettings(kbSettings);
  279. };
  280. const fetchSettingsAndGroups = async () => {
  281. if (!authToken) return;
  282. setIsSettingsLoading(true);
  283. try {
  284. const [settings, groups, personal] = await Promise.all([
  285. userSettingService.get(authToken),
  286. knowledgeGroupService.getGroups(),
  287. userSettingService.getPersonal(authToken)
  288. ]);
  289. setAppSettings(settings);
  290. setKnowledgeGroups(groups);
  291. // Sync local language with user settings if they differ
  292. if (personal?.language && personal.language !== language) {
  293. setLanguage(personal.language as any);
  294. }
  295. // Also update KB settings with the same data if not already set
  296. if (settings && Object.keys(settings).length > 0) {
  297. setKbSettings(settings);
  298. setLocalKbSettings(settings);
  299. }
  300. } catch (error) {
  301. console.error('Failed to fetch settings or groups:', error);
  302. } finally {
  303. setIsSettingsLoading(false);
  304. }
  305. };
  306. // --- General tab handlers ---
  307. const handleChangePassword = async (e: React.FormEvent) => {
  308. e.preventDefault();
  309. setError(null);
  310. setPasswordSuccess('');
  311. if (passwordForm.new !== passwordForm.confirm) {
  312. setError(t('passwordMismatch'));
  313. return;
  314. }
  315. if (passwordForm.new.length < 6) {
  316. setError(t('newPasswordMinLength'));
  317. return;
  318. }
  319. setIsLoading(true);
  320. try {
  321. await userService.changePassword(passwordForm.current, passwordForm.new);
  322. setPasswordSuccess(t('passwordChangeSuccess'));
  323. setPasswordForm({ current: '', new: '', confirm: '' });
  324. } catch (err: any) {
  325. setError(err.message || t('passwordChangeFailed'));
  326. } finally {
  327. setIsLoading(false);
  328. }
  329. };
  330. // --- ユーザータブのハンドラー ---
  331. const fetchUsers = async (page?: number) => {
  332. setIsUserLoading(true);
  333. const p = page || userPage;
  334. try {
  335. const result = await userService.getUsers(p, USER_PAGE_SIZE);
  336. if (result && result.data) {
  337. setUsers(result.data);
  338. setUserTotal(result.total);
  339. } else if (Array.isArray(result)) {
  340. setUsers(result);
  341. setUserTotal(result.length);
  342. }
  343. } catch (error: any) {
  344. setError(error.message || t('getUserListFailed'));
  345. } finally {
  346. setIsUserLoading(false);
  347. }
  348. };
  349. const handleCreateUser = async (e: React.FormEvent) => {
  350. e.preventDefault();
  351. setError('');
  352. setUserSuccess('');
  353. if (newUser.username && newUser.password && newUser.displayName) {
  354. setIsUserLoading(true);
  355. try {
  356. await userService.createUser(
  357. newUser.username,
  358. newUser.password,
  359. false,
  360. undefined,
  361. newUser.displayName
  362. );
  363. showSuccess(t('userCreatedSuccess'));
  364. setNewUser({ username: '', password: '', displayName: '' });
  365. setShowAddUser(false);
  366. fetchUsers();
  367. } catch (error: any) {
  368. setError(error.message || t('createUserFailed'));
  369. } finally {
  370. setIsUserLoading(false);
  371. }
  372. }
  373. };
  374. const [passwordChangeUserData, setPasswordChangeUserData] = useState<{ userId: string, newPassword: string } | null>(null);
  375. // --- Edit User State ---
  376. const [editUserData, setEditUserData] = useState<{ userId: string, username: string, displayName: string } | null>(null);
  377. const handleToggleUserAdmin = async (userId: string, newAdminStatus: boolean) => {
  378. try {
  379. await userService.updateUser(userId, newAdminStatus);
  380. // Re-fetch user list
  381. fetchUsers();
  382. setUserSuccess(newAdminStatus ? t('userPromotedToAdmin') : t('userDemotedFromAdmin'));
  383. } catch (error: any) {
  384. setError(error.message || t('updateUserFailed'));
  385. }
  386. };
  387. const handleUserPasswordChange = async () => {
  388. if (!passwordChangeUserData || !passwordChangeUserData.newPassword) return;
  389. try {
  390. // Update user password
  391. await userService.updateUserInfo(passwordChangeUserData.userId, { password: passwordChangeUserData.newPassword });
  392. setUserSuccess(t('passwordChangeSuccess'));
  393. setPasswordChangeUserData(null);
  394. fetchUsers(); // Refresh the user list
  395. } catch (error: any) {
  396. setError(error.message || t('passwordChangeFailed'));
  397. }
  398. };
  399. const fetchAllMemberIds = async (tenantId: string) => {
  400. try {
  401. const { data } = await apiClient.get<string[]>(`/v1/tenants/${tenantId}/members/ids`);
  402. if (Array.isArray(data)) {
  403. setAllMemberIds(new Set(data));
  404. }
  405. } catch (e) {
  406. console.error('Failed to fetch all member IDs:', e);
  407. }
  408. };
  409. const fetchTenantMembers = async (tenantId: string, page?: number) => {
  410. setIsMembersLoading(true);
  411. const p = page || memberPage;
  412. try {
  413. const { data } = await apiClient.get(`/v1/tenants/${tenantId}/members?page=${p}&limit=${MEMBER_PAGE_SIZE}`);
  414. if (data && data.data) {
  415. setTenantMembers(data.data);
  416. setMemberTotal(data.total);
  417. } else if (Array.isArray(data)) {
  418. setTenantMembers(data);
  419. setMemberTotal(data.length);
  420. }
  421. } catch (e) {
  422. console.error(e);
  423. } finally {
  424. setIsMembersLoading(false);
  425. }
  426. };
  427. const handleAddMember = async (tenantId: string, userId: string, role: string = 'USER') => {
  428. try {
  429. await apiClient.post(`/v1/tenants/${tenantId}/members`, { userId, role });
  430. setAllMemberIds(prev => {
  431. const next = new Set(prev);
  432. next.add(userId);
  433. return next;
  434. });
  435. showSuccess(t('confirm'));
  436. fetchTenantMembers(tenantId);
  437. fetchTenantsData();
  438. } catch (e: any) {
  439. showError(e.message || 'Error adding member');
  440. }
  441. };
  442. const handleRemoveMember = async (tenantId: string, userId: string) => {
  443. try {
  444. await apiClient.delete(`/v1/tenants/${tenantId}/members/${userId}`);
  445. setAllMemberIds(prev => {
  446. const next = new Set(prev);
  447. next.delete(userId);
  448. return next;
  449. });
  450. showSuccess('User removed from organization');
  451. fetchTenantMembers(tenantId);
  452. fetchTenantsData();
  453. } catch (e: any) {
  454. showError(e.message || 'Error removing member');
  455. }
  456. };
  457. const handleUpdateMemberRole = async (tenantId: string, userId: string, role: string) => {
  458. try {
  459. await apiClient.patch(`/v1/tenants/${tenantId}/members/${userId}`, { role });
  460. showSuccess(t('featureUpdated'));
  461. fetchTenantMembers(tenantId);
  462. } catch (e: any) {
  463. showError(e.message || 'Error updating role');
  464. }
  465. };
  466. const fetchTenantsData = async () => {
  467. if (!authToken) return;
  468. setIsLoading(true);
  469. try {
  470. const [tenRes, admRes] = await Promise.all([
  471. apiClient.get('/v1/tenants'),
  472. apiClient.get('/users?page=1&limit=1')
  473. ]);
  474. const data: Tenant[] = tenRes.data;
  475. const filteredData = data.filter(t => t.name !== 'Default');
  476. setTenants(filteredData);
  477. setStats(s => ({ ...s, tenants: filteredData.length }));
  478. const result = admRes.data;
  479. setStats(s => ({ ...s, users: result.total ?? result.length ?? 0 }));
  480. } catch (e) {
  481. console.error(e);
  482. } finally {
  483. setIsLoading(false);
  484. }
  485. };
  486. const handleCreateTenant = async (e: React.FormEvent) => {
  487. e.preventDefault();
  488. try {
  489. const path = editingTenant ? `/v1/tenants/${editingTenant.id}` : '/v1/tenants';
  490. const body = {
  491. name: newTenant.name,
  492. domain: newTenant.domain,
  493. parentId: newTenant.parentId
  494. };
  495. if (editingTenant) {
  496. await apiClient.put(path, body);
  497. } else {
  498. await apiClient.post(path, body);
  499. }
  500. setShowCreateTenant(false);
  501. setEditingTenant(null);
  502. setNewTenant({ name: '', domain: '', parentId: null });
  503. fetchTenantsData();
  504. showSuccess(editingTenant ? 'Tenant updated' : 'Tenant created');
  505. } catch (e: any) {
  506. showError(e.message || 'Action failed');
  507. }
  508. };
  509. const handleRemoveTenant = async (tenantId: string) => {
  510. if (!(await confirm(t('confirmDeleteTenant')))) return;
  511. try {
  512. await apiClient.delete(`/v1/tenants/${tenantId}`);
  513. setSelectedTenantId(null);
  514. fetchTenantsData();
  515. showSuccess('Tenant deleted');
  516. } catch (e: any) {
  517. showError(e.message || 'Delete failed');
  518. }
  519. };
  520. const handleUpdateUser = async () => {
  521. if (!editUserData) return;
  522. try {
  523. await userService.updateUserInfo(editUserData.userId, {
  524. username: editUserData.username,
  525. displayName: editUserData.displayName
  526. });
  527. showSuccess(t('featureUpdated'));
  528. setEditUserData(null);
  529. fetchUsers();
  530. } catch (error: any) {
  531. showError('Failed to update user');
  532. }
  533. };
  534. const handleDeleteUser = async (userId: string) => {
  535. if (!confirm(t('confirmDeleteUser') || "Are you sure?")) return;
  536. try {
  537. await userService.deleteUser(userId);
  538. showSuccess(t('userDeletedSuccessfully'));
  539. fetchUsers();
  540. } catch (error: any) {
  541. showError(error.message || t('deleteUserFailed'));
  542. }
  543. };
  544. const handleExportUsers = async () => {
  545. try {
  546. const blob = await userService.exportUsers();
  547. const url = window.URL.createObjectURL(blob);
  548. const a = document.createElement('a');
  549. a.href = url;
  550. a.download = `users_${new Date().toISOString().split('T')[0]}.xlsx`;
  551. document.body.appendChild(a);
  552. a.click();
  553. window.URL.revokeObjectURL(url);
  554. document.body.removeChild(a);
  555. } catch (error) {
  556. console.error('Export users failed', error);
  557. showError(t('exportFailed'));
  558. }
  559. };
  560. const handleImportUsers = async (e: React.ChangeEvent<HTMLInputElement>) => {
  561. const file = e.target.files?.[0];
  562. if (!file) return;
  563. try {
  564. const result = await userService.importUsers(file);
  565. showSuccess(t('importSuccess').replace('$1', (result.success || 0).toString()).replace('$2', (result.failed || 0).toString()));
  566. fetchUsers();
  567. if (result.errors.length > 0) {
  568. console.warn('Import had errors:', result.errors);
  569. }
  570. } catch (error: any) {
  571. console.error('Import users failed', error);
  572. showError(t('importFailed') + (error.response?.data?.message ? `: ${error.response.data.message}` : ''));
  573. } finally {
  574. // Reset input
  575. e.target.value = '';
  576. }
  577. };
  578. const handleSaveModel = async () => {
  579. if (!authToken) return;
  580. setIsLoading(true);
  581. try {
  582. await onUpdateModels(editingId === 'new' ? 'create' : 'update', modelFormData as ModelConfig);
  583. setEditingId(null);
  584. } catch (err) {
  585. setError('Update failed');
  586. } finally {
  587. setIsLoading(false);
  588. }
  589. };
  590. const handleToggleModel = async (model: ModelConfig) => {
  591. if (currentUser?.role === 'TENANT_ADMIN') {
  592. const newEnabledIds = enabledModelIds.includes(model.id)
  593. ? enabledModelIds.filter(id => id !== model.id)
  594. : [...enabledModelIds, model.id];
  595. try {
  596. await apiClient.put('/v1/admin/settings', { enabledModelIds: newEnabledIds });
  597. setEnabledModelIds(newEnabledIds);
  598. setLocalKbSettings((prev: any) => ({ ...prev, enabledModelIds: newEnabledIds }));
  599. showSuccess('Updated');
  600. } catch (e: any) {
  601. showError(e.message || 'Update failed');
  602. }
  603. return;
  604. }
  605. await onUpdateModels('update', { ...model, isEnabled: !model.isEnabled });
  606. };
  607. const handleDeleteModel = async (id: string) => {
  608. if (await confirm(t('confirmDeleteModel'))) {
  609. await onUpdateModels('delete', { id } as ModelConfig);
  610. }
  611. };
  612. const TenantTreeNode: React.FC<{
  613. tenant: Tenant;
  614. selectedTenantId: string | null;
  615. onSelect: (id: string) => void;
  616. onCreateSubtenant: (parentId: string) => void;
  617. depth?: number;
  618. }> = ({ tenant, selectedTenantId, onSelect, onCreateSubtenant, depth = 0 }) => {
  619. const [collapsed, setCollapsed] = useState(false);
  620. const hasChildren = tenant.children && tenant.children.length > 0;
  621. const isSelected = selectedTenantId === tenant.id;
  622. return (
  623. <div className="select-none">
  624. <div
  625. className={`group flex items-center gap-2 px-3 py-2 rounded-xl cursor-pointer transition-all ${isSelected ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100' : 'hover:bg-slate-50 text-slate-600 hover:text-slate-900'}`}
  626. style={{ paddingLeft: `${depth * 1.5 + 0.75}rem` }}
  627. onClick={() => onSelect(tenant.id)}
  628. >
  629. <div className="flex items-center gap-2 flex-1 min-w-0">
  630. {hasChildren ? (
  631. <button
  632. onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
  633. className={`p-0.5 rounded-md hover:bg-black/10 transition-colors ${isSelected ? 'text-white/70' : 'text-slate-400'}`}
  634. >
  635. {collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
  636. </button>
  637. ) : (
  638. <div className="w-5" />
  639. )}
  640. <Building size={16} className={isSelected ? 'text-white' : 'text-slate-400'} />
  641. <span className="text-sm font-bold truncate">{tenant.name}</span>
  642. </div>
  643. <button
  644. onClick={(e) => {
  645. e.stopPropagation();
  646. onCreateSubtenant(tenant.id);
  647. }}
  648. className={`p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-all ${isSelected ? 'hover:bg-white/20 text-white' : 'hover:bg-slate-200 text-slate-400 hover:text-indigo-600'}`}
  649. title={t('createSubOrg')}
  650. >
  651. <Plus size={14} />
  652. </button>
  653. </div>
  654. {hasChildren && !collapsed && (
  655. <div className="mt-1">
  656. {tenant.children?.map(child => (
  657. <TenantTreeNode
  658. key={child.id}
  659. tenant={child}
  660. selectedTenantId={selectedTenantId}
  661. onSelect={onSelect}
  662. onCreateSubtenant={onCreateSubtenant}
  663. depth={depth + 1}
  664. />
  665. ))}
  666. </div>
  667. )}
  668. </div>
  669. );
  670. };
  671. const getTypeLabel = (type: ModelType) => {
  672. switch (type) {
  673. case ModelType.LLM: return t('typeLLM');
  674. case ModelType.EMBEDDING: return t('typeEmbedding');
  675. case ModelType.RERANK: return t('typeRerank');
  676. case ModelType.VISION: return t('typeVision');
  677. default: return type;
  678. }
  679. };
  680. // --- Rendering functions ---
  681. const renderGeneralTab = () => (
  682. <div className="space-y-8 animate-in slide-in-from-right duration-300 max-w-2xl">
  683. {/* Change password section */}
  684. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  685. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  686. <Key className="w-4 h-4 text-blue-500" />
  687. {t('changePassword')}
  688. </h3>
  689. <form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
  690. <input type="hidden" name="username" value="current-user" autoComplete="username" />
  691. <div>
  692. <input
  693. type="password"
  694. name="currentPassword"
  695. placeholder={t('currentPassword')}
  696. value={passwordForm.current}
  697. onChange={e => setPasswordForm({ ...passwordForm, current: e.target.value })}
  698. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  699. required
  700. autoComplete="current-password"
  701. />
  702. </div>
  703. <div>
  704. <input
  705. type="password"
  706. name="newPassword"
  707. placeholder={t('newPassword')}
  708. value={passwordForm.new}
  709. onChange={e => setPasswordForm({ ...passwordForm, new: e.target.value })}
  710. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  711. required
  712. autoComplete="new-password"
  713. />
  714. </div>
  715. <div>
  716. <input
  717. type="password"
  718. name="confirmPassword"
  719. placeholder={t('confirmPassword')}
  720. value={passwordForm.confirm}
  721. onChange={e => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
  722. className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
  723. required
  724. autoComplete="new-password"
  725. />
  726. </div>
  727. {passwordSuccess && <p className="text-xs text-green-600">{passwordSuccess}</p>}
  728. <button
  729. type="submit"
  730. disabled={isLoading}
  731. className="w-full py-2 bg-slate-800 text-white rounded-md text-sm hover:bg-slate-900 transition-colors disabled:opacity-50"
  732. >
  733. {isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : t('confirmChange')}
  734. </button>
  735. </form>
  736. </section>
  737. {/* 语言设置セクション */}
  738. <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
  739. <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
  740. <Globe className="w-4 h-4 text-blue-500" />
  741. {t('languageSettings')}
  742. </h3>
  743. <div className="space-y-4 max-w-sm">
  744. <div className="space-y-2">
  745. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">
  746. {t('switchLanguage')}
  747. </label>
  748. <select
  749. value={language}
  750. onChange={async (e) => {
  751. const newLang = e.target.value as any;
  752. setLanguage(newLang);
  753. try {
  754. await settingsService.updateLanguage(newLang);
  755. showSuccess(t('confirm'));
  756. } catch (err) {
  757. console.error('Failed to update backend language preference:', err);
  758. }
  759. }}
  760. 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"
  761. >
  762. <option value="en">English</option>
  763. <option value="zh">中文 (Chinese)</option>
  764. <option value="ja">日本語 (Japanese)</option>
  765. </select>
  766. </div>
  767. </div>
  768. </section>
  769. </div >
  770. );
  771. const renderUserTab = () => (
  772. <div className="space-y-6 w-full">
  773. <div className="flex justify-between items-center mb-6">
  774. <div>
  775. <p className="text-xs text-slate-400 font-medium">{''}</p>
  776. </div>
  777. {currentUser?.role === 'SUPER_ADMIN' && (
  778. <div className="flex gap-2">
  779. <button
  780. onClick={handleExportUsers}
  781. className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-600 text-sm font-bold rounded-2xl hover:bg-slate-50 shadow-sm transition-all active:scale-95"
  782. title={t('exportUsers')}
  783. >
  784. <Download className="w-4 h-4" />
  785. <span className="hidden sm:inline">{t('exportUsers')}</span>
  786. </button>
  787. <div className="relative">
  788. <input
  789. type="file"
  790. accept=".xlsx,.xls,.csv"
  791. onChange={handleImportUsers}
  792. className="absolute inset-0 opacity-0 cursor-pointer"
  793. title={t('importUsers')}
  794. />
  795. <button
  796. className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 text-slate-600 text-sm font-bold rounded-2xl hover:bg-slate-50 shadow-sm transition-all active:scale-95"
  797. >
  798. <Upload className="w-4 h-4" />
  799. <span className="hidden sm:inline">{t('importUsers')}</span>
  800. </button>
  801. </div>
  802. <button
  803. onClick={() => setShowAddUser(!showAddUser)}
  804. 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"
  805. >
  806. <Plus className="w-4 h-4" />
  807. {t('addUser')}
  808. </button>
  809. </div>
  810. )}
  811. </div>
  812. {showAddUser && (
  813. <motion.form
  814. initial={{ opacity: 0, y: -10 }}
  815. animate={{ opacity: 1, y: 0 }}
  816. onSubmit={handleCreateUser}
  817. className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-xl mb-8 space-y-5"
  818. >
  819. <div className="grid grid-cols-1 md:grid-cols-3 gap-5">
  820. <input
  821. type="text"
  822. placeholder={t('usernamePlaceholder')}
  823. value={newUser.username}
  824. onChange={e => setNewUser({ ...newUser, username: e.target.value })}
  825. 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"
  826. required
  827. autoComplete="username"
  828. />
  829. <input
  830. type="text"
  831. placeholder={t('displayNamePlaceholder') || t('name')}
  832. value={newUser.displayName}
  833. onChange={e => setNewUser({ ...newUser, displayName: e.target.value })}
  834. 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"
  835. required
  836. autoComplete="name"
  837. />
  838. <input
  839. type="password"
  840. placeholder={t('passwordPlaceholder')}
  841. value={newUser.password}
  842. onChange={e => setNewUser({ ...newUser, password: e.target.value })}
  843. 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"
  844. required
  845. autoComplete="new-password"
  846. />
  847. </div>
  848. <div className="flex items-center justify-between">
  849. <div></div>
  850. <div className="flex gap-3">
  851. <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>
  852. <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>
  853. </div>
  854. </div>
  855. </motion.form>
  856. )}
  857. {createPortal(
  858. <AnimatePresence>
  859. {passwordChangeUserData && (
  860. <div key="password-change-modal" className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center z-[1000] p-6">
  861. <motion.div
  862. initial={{ scale: 0.95, opacity: 0 }}
  863. animate={{ scale: 1, opacity: 1 }}
  864. exit={{ scale: 0.95, opacity: 0 }}
  865. className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
  866. >
  867. <div className="flex items-center justify-between mb-8">
  868. <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('changeUserPassword')}</h3>
  869. <button onClick={() => setPasswordChangeUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
  870. <X size={20} className="text-slate-400" />
  871. </button>
  872. </div>
  873. <form onSubmit={(e) => { e.preventDefault(); handleUserPasswordChange(); }} className="space-y-6">
  874. <div>
  875. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  876. {t('newPassword')}
  877. </label>
  878. <input
  879. type="password"
  880. value={passwordChangeUserData.newPassword}
  881. onChange={(e) => setPasswordChangeUserData({ ...passwordChangeUserData, newPassword: e.target.value })}
  882. 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"
  883. placeholder={t('enterNewPassword')}
  884. required
  885. />
  886. </div>
  887. <div className="flex gap-4 pt-4">
  888. <button type="button" onClick={() => setPasswordChangeUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  889. <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>
  890. </div>
  891. </form>
  892. </motion.div>
  893. </div>
  894. )}
  895. </AnimatePresence>,
  896. document.body
  897. )}
  898. {/* Edit User Modal */}
  899. {createPortal(
  900. <AnimatePresence>
  901. {editUserData && (
  902. <div key="edit-user-modal" className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4">
  903. <motion.div
  904. initial={{ scale: 0.95, opacity: 0 }}
  905. animate={{ scale: 1, opacity: 1 }}
  906. exit={{ scale: 0.95, opacity: 0 }}
  907. className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
  908. >
  909. <div className="flex items-center justify-between mb-8">
  910. <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('editUser')}</h3>
  911. <button onClick={() => setEditUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
  912. <X size={20} className="text-slate-400" />
  913. </button>
  914. </div>
  915. <form onSubmit={(e) => { e.preventDefault(); handleUpdateUser(); }} className="space-y-6">
  916. <div>
  917. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  918. {t('username')}
  919. </label>
  920. <input
  921. type="text"
  922. value={editUserData.username}
  923. onChange={(e) => setEditUserData({ ...editUserData, username: e.target.value })}
  924. 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"
  925. placeholder={t('usernamePlaceholder')}
  926. required
  927. />
  928. </div>
  929. <div>
  930. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  931. {t('displayName') || t('name')}
  932. </label>
  933. <input
  934. type="text"
  935. value={editUserData.displayName}
  936. onChange={(e) => setEditUserData({ ...editUserData, displayName: e.target.value })}
  937. 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"
  938. placeholder={t('displayNamePlaceholder') || t('namePlaceholder')}
  939. required
  940. />
  941. </div>
  942. <div className="py-2.5 px-4 bg-indigo-50 rounded-2xl border border-indigo-100/50">
  943. <p className="text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-1">{t('globalUserNote') || "Note"}</p>
  944. <p className="text-[11px] text-indigo-700/70 leading-relaxed font-medium">
  945. {t('roleManagedInOrg') || "Roles are managed within organizations."}
  946. </p>
  947. </div>
  948. <div className="flex gap-4 pt-4">
  949. <button type="button" onClick={() => setEditUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  950. <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('saveChanges')}</button>
  951. </div>
  952. </form>
  953. </motion.div>
  954. </div>
  955. )}
  956. </AnimatePresence>,
  957. document.body
  958. )}
  959. <div className="w-full bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-2xl overflow-hidden shadow-sm">
  960. <table className="w-full border-collapse text-left">
  961. <thead>
  962. <tr className="bg-slate-50/50 border-b border-slate-200/50">
  963. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('username')}</th>
  964. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('displayName') || t('name')}</th>
  965. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('organizations')}</th>
  966. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('createdAt')}</th>
  967. <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest text-right">{t('actions')}</th>
  968. </tr>
  969. </thead>
  970. <tbody className="divide-y divide-slate-100">
  971. <AnimatePresence>
  972. {users.filter((u: any) => u.username !== 'admin').map((user: any, index: number) => {
  973. let IconComponent = User;
  974. let iconColors = 'bg-slate-50 text-slate-400';
  975. if (user.isAdmin) {
  976. IconComponent = Shield;
  977. iconColors = 'bg-red-50 text-red-600';
  978. }
  979. return (
  980. <motion.tr
  981. key={user.id}
  982. initial={{ opacity: 0, y: 10 }}
  983. animate={{ opacity: 1, y: 0 }}
  984. transition={{ delay: index * 0.03 }}
  985. className="group hover:bg-slate-50/50 transition-all"
  986. >
  987. <td className="px-6 py-4">
  988. <div className="flex items-center gap-3">
  989. <div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${iconColors}`}>
  990. <IconComponent size={18} />
  991. </div>
  992. <div className="min-w-0">
  993. <p className="font-bold text-slate-900 truncate">{user.username}</p>
  994. </div>
  995. </div>
  996. </td>
  997. <td className="px-6 py-4">
  998. <p className="text-sm text-slate-600 truncate">{user.displayName || '-'}</p>
  999. </td>
  1000. <td className="px-6 py-4">
  1001. {user.tenantMembers && user.tenantMembers.length > 0 ? (
  1002. <div className="flex flex-wrap gap-1">
  1003. {user.tenantMembers
  1004. .filter((m: any) => m.tenant?.name !== 'Default')
  1005. .map((m: any) => (
  1006. <span
  1007. key={m.tenantId}
  1008. className="inline-flex items-center gap-1 px-2 py-0.5 bg-emerald-50 text-emerald-700 text-[9px] font-black rounded-md uppercase tracking-wider border border-emerald-100"
  1009. >
  1010. <Building size={8} />
  1011. {m.tenant?.name || m.tenantId}
  1012. </span>
  1013. ))}
  1014. </div>
  1015. ) : (
  1016. <span className="text-[10px] text-slate-400 italic">{t('noOrganization')}</span>
  1017. )}
  1018. </td>
  1019. <td className="px-6 py-4">
  1020. <p className="text-[11px] font-medium text-slate-600">
  1021. {new Date(user.createdAt).toLocaleDateString()}
  1022. </p>
  1023. </td>
  1024. <td className="px-6 py-4 text-right">
  1025. <div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-all">
  1026. {user.username !== 'admin' && (
  1027. <>
  1028. <button
  1029. onClick={(e) => {
  1030. e.preventDefault();
  1031. e.stopPropagation();
  1032. setEditUserData({
  1033. userId: user.id,
  1034. username: user.username,
  1035. displayName: user.displayName || ''
  1036. });
  1037. }}
  1038. className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
  1039. title={t('edit')}
  1040. >
  1041. <Edit2 className="w-4 h-4" />
  1042. </button>
  1043. <button
  1044. onClick={() => setPasswordChangeUserData({ userId: user.id, newPassword: '' })}
  1045. className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
  1046. title={t('changeUserPassword')}
  1047. >
  1048. <Key className="w-4 h-4" />
  1049. </button>
  1050. {user.id !== currentUser?.id && (
  1051. <button
  1052. onClick={() => handleDeleteUser(user.id)}
  1053. className="p-2 rounded-lg text-slate-400 hover:text-red-500 hover:bg-white shadow-sm transition-all"
  1054. title={t('deleteUser')}
  1055. >
  1056. <Trash2 className="w-4 h-4" />
  1057. </button>
  1058. )}
  1059. </>
  1060. )}
  1061. </div>
  1062. </td>
  1063. </motion.tr>
  1064. );
  1065. })}
  1066. </AnimatePresence>
  1067. </tbody>
  1068. </table>
  1069. </div>
  1070. <Pagination
  1071. current={userPage}
  1072. total={userTotal}
  1073. pageSize={USER_PAGE_SIZE}
  1074. onChange={setUserPage}
  1075. />
  1076. </div>
  1077. );
  1078. const renderTenantsTab = () => {
  1079. const tenantTree = buildTenantTree(tenants);
  1080. const activeTenant = selectedTenantId ? tenants.find(t => t.id === selectedTenantId) : null;
  1081. return (
  1082. <div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-12rem)] animate-in fade-in duration-500">
  1083. {/* Left: Organization Tree */}
  1084. <div className="w-full lg:w-80 flex flex-col bg-white border border-slate-200 rounded-3xl shadow-sm overflow-hidden min-h-[400px] lg:min-h-0">
  1085. <div className="p-6 border-b border-slate-100 flex items-center justify-between shrink-0">
  1086. <div>
  1087. <h3 className="font-black text-slate-900 text-lg tracking-tight">{t('orgManagement')}</h3>
  1088. <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{t('globalTenantControl')}</p>
  1089. </div>
  1090. <button
  1091. onClick={() => {
  1092. setNewTenant({ name: '', domain: '', parentId: null });
  1093. setEditingTenant(null);
  1094. setShowCreateTenant(true);
  1095. }}
  1096. className="p-2 bg-slate-900 text-white rounded-xl hover:bg-slate-700 transition-all"
  1097. >
  1098. <Plus size={18} />
  1099. </button>
  1100. </div>
  1101. <div className="flex-1 overflow-y-auto p-4 space-y-1 custom-scrollbar">
  1102. {tenantTree.length > 0 ? (
  1103. tenantTree.map(t => (
  1104. <TenantTreeNode
  1105. key={t.id}
  1106. tenant={t}
  1107. selectedTenantId={selectedTenantId}
  1108. onSelect={(id) => {
  1109. if (id !== selectedTenantId) {
  1110. setSelectedTenantId(id);
  1111. setMemberPage(1);
  1112. setUserPage(1);
  1113. }
  1114. }}
  1115. onCreateSubtenant={(parentId) => {
  1116. setNewTenant({ name: '', domain: '', parentId });
  1117. setEditingTenant(null);
  1118. setShowCreateTenant(true);
  1119. }}
  1120. />
  1121. ))
  1122. ) : (
  1123. <div className="py-20 text-center">
  1124. <Building size={32} className="mx-auto text-slate-200 mb-3" />
  1125. <p className="text-xs font-black text-slate-300 uppercase tracking-widest">{t('noOrganizations')}</p>
  1126. </div>
  1127. )}
  1128. </div>
  1129. <div className="p-4 bg-slate-50 border-t border-slate-100 shrink-0">
  1130. <div className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-2xl shadow-sm">
  1131. <div className="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center text-blue-600">
  1132. <Building size={20} />
  1133. </div>
  1134. <div>
  1135. <p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('totalTenants')}</p>
  1136. <p className="text-xl font-black text-slate-900">{stats.tenants}</p>
  1137. </div>
  1138. </div>
  1139. </div>
  1140. </div>
  1141. {/* Right: User List & Management */}
  1142. <div className="flex-1 flex flex-col bg-white border border-slate-200 rounded-3xl shadow-xl overflow-hidden min-h-[600px] lg:min-h-0">
  1143. {activeTenant ? (
  1144. <div className="flex flex-col h-full">
  1145. {/* Organization Header */}
  1146. <div className="p-8 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between shrink-0">
  1147. <div className="flex items-center gap-4">
  1148. <div className="w-14 h-14 rounded-2xl bg-white border border-slate-200 shadow-sm flex items-center justify-center text-indigo-600">
  1149. <Building size={28} />
  1150. </div>
  1151. <div>
  1152. <div className="flex items-center gap-2">
  1153. <h2 className="text-2xl font-black text-slate-900 tracking-tight">{activeTenant.name}</h2>
  1154. <span className="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[9px] font-black uppercase tracking-widest border border-indigo-100">{t('activeOrg')}</span>
  1155. </div>
  1156. <p className="text-xs font-medium text-slate-400">{activeTenant.domain || t('noCustomDomain')}</p>
  1157. </div>
  1158. </div>
  1159. <div className="flex items-center gap-2">
  1160. <button
  1161. onClick={() => {
  1162. setEditingTenant(activeTenant);
  1163. setNewTenant({
  1164. name: activeTenant.name,
  1165. domain: activeTenant.domain || '',
  1166. parentId: activeTenant.parentId || null
  1167. });
  1168. setShowCreateTenant(true);
  1169. }}
  1170. className="p-2.5 bg-white border border-slate-200 text-slate-600 rounded-xl hover:border-indigo-500 hover:text-indigo-600 transition-all shadow-sm"
  1171. title={t('orgSettings')}
  1172. >
  1173. <SettingsIcon size={18} />
  1174. </button>
  1175. <button
  1176. onClick={() => handleRemoveTenant(activeTenant.id)}
  1177. className="p-2.5 bg-white border border-slate-200 text-slate-400 hover:text-red-500 hover:border-red-500 transition-all shadow-sm"
  1178. title={t('deleteOrg')}
  1179. >
  1180. <Trash2 size={18} />
  1181. </button>
  1182. </div>
  1183. </div>
  1184. {/* Main Content Split: Members vs All Users */}
  1185. <div className="flex-1 flex overflow-hidden">
  1186. {/* Current Members */}
  1187. <div className="flex-1 flex flex-col border-r border-slate-100 overflow-hidden">
  1188. <div className="p-6 border-b border-slate-50 flex items-center justify-between shrink-0">
  1189. <h4 className="text-xs font-black text-slate-400 uppercase tracking-widest">{t('orgMembers')}</h4>
  1190. <span className="text-[10px] font-black px-2 py-0.5 bg-slate-100 text-slate-500 rounded-full">
  1191. {t('membersCount').replace('$1', (memberTotal || 0).toString())}
  1192. </span>
  1193. </div>
  1194. <div className="flex-1 overflow-y-auto p-6 scrollbar-hide">
  1195. <div className="grid grid-cols-1 gap-3">
  1196. {tenantMembers?.map((m: any) => (
  1197. <div key={m.id} className="p-4 bg-slate-50/50 border border-slate-100 rounded-2xl flex items-center justify-between group hover:bg-white hover:shadow-sm transition-all hover:border-slate-200">
  1198. <div className="flex items-center gap-3 min-w-0">
  1199. <div className="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center text-slate-400 shadow-sm">
  1200. <User size={18} />
  1201. </div>
  1202. <div className="min-w-0">
  1203. <p className="text-sm font-black text-slate-900 truncate">
  1204. {m.user?.displayName || m.user?.username || m.userId}
  1205. </p>
  1206. <select
  1207. value={m.role}
  1208. onChange={(e) => handleUpdateMemberRole(activeTenant.id, m.userId, e.target.value)}
  1209. className={`text-[9px] font-black uppercase tracking-widest bg-transparent border-none outline-none cursor-pointer hover:bg-slate-100 rounded px-1 transition-colors ${m.role === 'TENANT_ADMIN' ? 'text-indigo-500' : 'text-slate-400'}`}
  1210. >
  1211. <option value="USER">{t('roleRegularUser')}</option>
  1212. <option value="TENANT_ADMIN">{t('roleTenantAdmin')}</option>
  1213. </select>
  1214. </div>
  1215. </div>
  1216. <button
  1217. onClick={() => handleRemoveMember(activeTenant.id, m.userId)}
  1218. className="p-2 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
  1219. >
  1220. <Trash2 size={14} />
  1221. </button>
  1222. </div>
  1223. ))}
  1224. {(!tenantMembers || tenantMembers.length === 0) && (
  1225. <div className="py-20 text-center">
  1226. <Users size={24} className="mx-auto text-slate-200 mb-2" />
  1227. <p className="text-[10px] font-bold text-slate-300 uppercase tracking-wider">{t('noMembersAssigned')}</p>
  1228. </div>
  1229. )}
  1230. </div>
  1231. <Pagination
  1232. current={memberPage}
  1233. total={memberTotal}
  1234. pageSize={MEMBER_PAGE_SIZE}
  1235. onChange={setMemberPage}
  1236. />
  1237. </div>
  1238. </div>
  1239. {/* Add New Users (Right side of specific tenant view) */}
  1240. <div className="w-72 flex flex-col bg-slate-50/30 overflow-hidden shrink-0">
  1241. <div className="p-6 border-b border-slate-100 shrink-0">
  1242. <h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3">{t('addMembers')}</h4>
  1243. <div className="relative">
  1244. <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
  1245. <input
  1246. className="w-full pl-9 pr-3 py-2 bg-white border border-slate-200 rounded-xl text-xs outline-none focus:ring-4 focus:ring-indigo-500/10 transition-all"
  1247. placeholder={t('searchSystemUsers')}
  1248. value={userSearchQuery}
  1249. onChange={e => setUserSearchQuery(e.target.value)}
  1250. />
  1251. </div>
  1252. <div className="mt-3 flex gap-1 p-1 bg-white border border-slate-200 rounded-xl">
  1253. <button
  1254. onClick={() => setBindingRole('USER')}
  1255. className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${bindingRole === 'USER' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
  1256. >
  1257. {t('roleRegularUser')}
  1258. </button>
  1259. <button
  1260. onClick={() => setBindingRole('TENANT_ADMIN')}
  1261. className={`flex-1 py-1.5 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all ${bindingRole === 'TENANT_ADMIN' ? 'bg-indigo-600 text-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
  1262. >
  1263. {t('roleTenantAdmin')}
  1264. </button>
  1265. </div>
  1266. </div>
  1267. <div className="flex-1 overflow-y-auto p-4 space-y-2">
  1268. {users
  1269. .filter(u =>
  1270. u.username !== 'admin' &&
  1271. u.username.toLowerCase().includes(userSearchQuery.toLowerCase())
  1272. )
  1273. .map(u => {
  1274. const isAlreadyMember = allMemberIds.has(u.id);
  1275. return (
  1276. <button
  1277. key={u.id}
  1278. onClick={() => !isAlreadyMember && handleAddMember(activeTenant.id, u.id, bindingRole)}
  1279. disabled={isAlreadyMember}
  1280. className={`w-full p-3 border rounded-xl flex items-center justify-between group transition-all ${isAlreadyMember
  1281. ? 'bg-slate-50 border-slate-100 cursor-not-allowed opacity-60'
  1282. : 'bg-white border-slate-100 hover:border-indigo-500 hover:shadow-sm'
  1283. }`}
  1284. >
  1285. <div className="flex items-center gap-2 min-w-0">
  1286. <div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${isAlreadyMember
  1287. ? 'bg-slate-100 text-slate-300'
  1288. : 'bg-slate-50 text-slate-400 group-hover:bg-indigo-50 group-hover:text-indigo-600'
  1289. }`}>
  1290. <User size={14} />
  1291. </div>
  1292. <span className={`text-[13px] font-bold truncate ${isAlreadyMember ? 'text-slate-400' : 'text-slate-700'}`}>
  1293. {u.displayName || u.username}
  1294. </span>
  1295. </div>
  1296. {isAlreadyMember ? (
  1297. <Check size={14} className="text-emerald-500" />
  1298. ) : (
  1299. <Plus size={14} className="text-slate-300 group-hover:text-indigo-600" />
  1300. )}
  1301. </button>
  1302. );
  1303. })
  1304. }
  1305. <Pagination
  1306. current={userPage}
  1307. total={userTotal}
  1308. pageSize={USER_PAGE_SIZE}
  1309. onChange={setUserPage}
  1310. />
  1311. </div>
  1312. </div>
  1313. </div>
  1314. {/* Create Tenant Modal (Nested in Tab Content for scope) */}
  1315. {showCreateTenant && (
  1316. <div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
  1317. <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">
  1318. <h3 className="text-xl font-black text-slate-900 mb-6">{editingTenant ? t('editOrg') : t('newTenant')}</h3>
  1319. <form onSubmit={handleCreateTenant} className="space-y-5">
  1320. <div>
  1321. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
  1322. <input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('tenantName')} value={newTenant.name} onChange={e => setNewTenant({ ...newTenant, name: e.target.value })} required />
  1323. </div>
  1324. <div>
  1325. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
  1326. <input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('domainOptional')} value={newTenant.domain} onChange={e => setNewTenant({ ...newTenant, domain: e.target.value })} />
  1327. </div>
  1328. <div>
  1329. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
  1330. {!editingTenant ? (
  1331. <div className="w-full mt-1 px-4 py-3 bg-slate-100 border border-slate-200 rounded-2xl text-sm text-slate-500 font-bold">
  1332. {newTenant.parentId ? tenants.find(t => t.id === newTenant.parentId)?.name : t('noneRoot')}
  1333. </div>
  1334. ) : (
  1335. <select
  1336. className="w-full mt-1 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"
  1337. value={newTenant.parentId || ''}
  1338. onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
  1339. >
  1340. <option value="">{t('noneRoot')}</option>
  1341. {tenants.filter(t => t.id !== editingTenant?.id).map(t => (
  1342. <option key={t.id} value={t.id}>{t.name}</option>
  1343. ))}
  1344. </select>
  1345. )}
  1346. </div>
  1347. <div className="flex gap-4 pt-2">
  1348. <button type="button" onClick={() => { setShowCreateTenant(false); setEditingTenant(null); }} className="flex-1 py-3 text-slate-500 font-bold text-sm">{t('cancel')}</button>
  1349. <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 ? t('update') : t('create')}</button>
  1350. </div>
  1351. </form>
  1352. </motion.div>
  1353. </div>
  1354. )}
  1355. </div>
  1356. ) : (
  1357. <div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-slate-50/30">
  1358. <div className="w-24 h-24 rounded-[2rem] bg-indigo-50 flex items-center justify-center text-indigo-400 mb-6 shadow-xl shadow-indigo-100/50">
  1359. <Building size={48} />
  1360. </div>
  1361. <h2 className="text-2xl font-black text-slate-900 tracking-tight mb-2">{t('selectOrg')}</h2>
  1362. <p className="text-sm text-slate-400 max-w-xs font-medium leading-relaxed">{t('selectOrgDesc')}</p>
  1363. <div className="mt-12 grid grid-cols-2 gap-4 w-full max-w-lg">
  1364. <div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
  1365. <div className="w-10 h-10 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600 mb-4">
  1366. <Users size={20} />
  1367. </div>
  1368. <p className="text-xl font-black text-slate-900">{stats.users}</p>
  1369. <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{t('totalSystemUsers')}</p>
  1370. </div>
  1371. <div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
  1372. <div className="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600 mb-4">
  1373. <Shield size={20} />
  1374. </div>
  1375. <p className="text-xl font-black text-slate-900">{tenants.filter(t => t.parentId === null).length}</p>
  1376. <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{t('rootOrgs')}</p>
  1377. </div>
  1378. </div>
  1379. {/* Scope Modal for Create even when no selection */}
  1380. {showCreateTenant && (
  1381. <div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
  1382. <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">
  1383. <h3 className="text-xl font-black text-slate-900 mb-6">{t('newTenant')}</h3>
  1384. <form onSubmit={handleCreateTenant} className="space-y-5">
  1385. <div>
  1386. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
  1387. <input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('tenantName')} value={newTenant.name} onChange={e => setNewTenant({ ...newTenant, name: e.target.value })} required />
  1388. </div>
  1389. <div>
  1390. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
  1391. <input className="w-full mt-1 px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm" placeholder={t('domainOptional')} value={newTenant.domain} onChange={e => setNewTenant({ ...newTenant, domain: e.target.value })} />
  1392. </div>
  1393. <div>
  1394. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
  1395. <select
  1396. className="w-full mt-1 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"
  1397. value={newTenant.parentId || ''}
  1398. onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
  1399. >
  1400. <option value="">{t('noneRoot')}</option>
  1401. {tenants.map(t => (
  1402. <option key={t.id} value={t.id}>{t.name}</option>
  1403. ))}
  1404. </select>
  1405. </div>
  1406. <div className="flex gap-4 pt-2">
  1407. <button
  1408. type="button"
  1409. onClick={() => {
  1410. setShowCreateTenant(false);
  1411. setNewTenant({ name: '', domain: '', parentId: null });
  1412. }}
  1413. className="flex-1 py-3 text-slate-500 font-bold text-sm"
  1414. >
  1415. {t('cancel')}
  1416. </button>
  1417. <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">{t('create')}</button>
  1418. </div>
  1419. </form>
  1420. </motion.div>
  1421. </div>
  1422. )}
  1423. </div>
  1424. )}
  1425. </div>
  1426. </div>
  1427. );
  1428. };
  1429. const renderKnowledgeBaseTab = () => (
  1430. <div className="space-y-8 animate-in slide-in-from-right duration-300 w-full max-w-5xl pb-10">
  1431. {localKbSettings ? (
  1432. <>
  1433. {/* Save/Cancel Bar */}
  1434. <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">
  1435. <button
  1436. onClick={handleCancelKbSettings}
  1437. disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
  1438. className="px-6 py-2 text-sm font-bold text-slate-500 hover:text-slate-700 disabled:opacity-30"
  1439. >
  1440. {t('cancel')}
  1441. </button>
  1442. <button
  1443. onClick={handleSaveKbSettings}
  1444. disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
  1445. 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"
  1446. >
  1447. {isSavingKbSettings ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
  1448. {t('saveChanges')}
  1449. </button>
  1450. </div>
  1451. {/* Model Configuration */}
  1452. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1453. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1454. <div className="w-8 h-8 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600">
  1455. <Cpu size={16} />
  1456. </div>
  1457. {t('modelConfiguration')}
  1458. </div>
  1459. <div className="grid grid-cols-1 gap-6">
  1460. <div>
  1461. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('defaultLLMModel')}</label>
  1462. <select
  1463. value={localKbSettings.selectedLLMId || ''}
  1464. onChange={(e) => handleUpdateKbSettings('selectedLLMId', e.target.value)}
  1465. 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"
  1466. >
  1467. <option value="">--- {t('selectLLMModel')} ---</option>
  1468. {models.filter(m => m.type === ModelType.LLM).map(m => (
  1469. <option key={m.id} value={m.id}>{m.name} ({m.modelId})</option>
  1470. ))}
  1471. </select>
  1472. </div>
  1473. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  1474. <div>
  1475. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('embeddingModel')}</label>
  1476. <select
  1477. value={localKbSettings.selectedEmbeddingId || ''}
  1478. onChange={(e) => handleUpdateKbSettings('selectedEmbeddingId', e.target.value)}
  1479. 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"
  1480. >
  1481. <option value="">--- {t('selectEmbeddingModel')} ---</option>
  1482. {models.filter(m => m.type === ModelType.EMBEDDING).map(m => (
  1483. <option key={m.id} value={m.id}>{m.name}</option>
  1484. ))}
  1485. </select>
  1486. </div>
  1487. <div>
  1488. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('rerankModel')}</label>
  1489. <select
  1490. value={localKbSettings.selectedRerankId || ''}
  1491. onChange={(e) => handleUpdateKbSettings('selectedRerankId', e.target.value)}
  1492. 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"
  1493. >
  1494. <option value="">--- {t('selectRerankModel')} ---</option>
  1495. {models.filter(m => m.type === ModelType.RERANK).map(m => (
  1496. <option key={m.id} value={m.id}>{m.name}</option>
  1497. ))}
  1498. </select>
  1499. </div>
  1500. <div>
  1501. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
  1502. {t('defaultVisionModel')}
  1503. <span className="ml-1 text-[8px] opacity-60">({t('typeVision')})</span>
  1504. </label>
  1505. <select
  1506. value={localKbSettings.selectedVisionId || ''}
  1507. onChange={(e) => handleUpdateKbSettings('selectedVisionId', e.target.value)}
  1508. 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"
  1509. >
  1510. <option value="">--- {t('selectVisionModel')} ---</option>
  1511. {models.filter(m => m.type === ModelType.VISION || m.supportsVision).map(m => (
  1512. <option key={m.id} value={m.id}>{m.name}</option>
  1513. ))}
  1514. </select>
  1515. </div>
  1516. </div>
  1517. </div>
  1518. </section>
  1519. {/* Indexing & Chunking Configuration */}
  1520. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1521. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1522. <div className="w-8 h-8 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600">
  1523. <BookOpen size={16} />
  1524. </div>
  1525. {t('indexingChunkingConfig')}
  1526. </div>
  1527. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  1528. <div>
  1529. <div className="flex justify-between mb-3 px-1">
  1530. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkSize')}</label>
  1531. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkSize || 1000}</span>
  1532. </div>
  1533. <input
  1534. type="range"
  1535. min="100"
  1536. max="8192"
  1537. step="100"
  1538. value={localKbSettings.chunkSize || 1000}
  1539. onChange={(e) => handleUpdateKbSettings('chunkSize', parseInt(e.target.value))}
  1540. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1541. />
  1542. </div>
  1543. <div>
  1544. <div className="flex justify-between mb-3 px-1">
  1545. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkOverlap')}</label>
  1546. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkOverlap || 100}</span>
  1547. </div>
  1548. <input
  1549. type="range"
  1550. min="0"
  1551. max="2048"
  1552. step="10"
  1553. value={localKbSettings.chunkOverlap || 100}
  1554. onChange={(e) => handleUpdateKbSettings('chunkOverlap', parseInt(e.target.value))}
  1555. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1556. />
  1557. </div>
  1558. </div>
  1559. </section>
  1560. {/* Chat Hyperparameters */}
  1561. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1562. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1563. <div className="w-8 h-8 rounded-xl bg-pink-50 flex items-center justify-center text-pink-600">
  1564. <Sparkles size={16} />
  1565. </div>
  1566. {t('chatHyperparameters')}
  1567. </div>
  1568. <div className="space-y-8">
  1569. <div>
  1570. <div className="flex justify-between mb-3 px-1">
  1571. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('temperature')}</label>
  1572. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.temperature}</span>
  1573. </div>
  1574. <input
  1575. type="range"
  1576. min="0"
  1577. max="1"
  1578. step="0.1"
  1579. value={localKbSettings.temperature || 0.7}
  1580. onChange={(e) => handleUpdateKbSettings('temperature', parseFloat(e.target.value))}
  1581. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1582. />
  1583. <div className="flex justify-between mt-2 px-1 text-[9px] font-bold text-slate-300 uppercase tracking-tighter">
  1584. <span>{t('precise')}</span>
  1585. <span>{t('creative')}</span>
  1586. </div>
  1587. </div>
  1588. <div>
  1589. <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('maxResponseTokens')}</label>
  1590. <input
  1591. type="number"
  1592. value={localKbSettings.maxTokens || 2000}
  1593. onChange={(e) => handleUpdateKbSettings('maxTokens', parseInt(e.target.value))}
  1594. 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"
  1595. />
  1596. </div>
  1597. </div>
  1598. </section>
  1599. {/* Retrieval & Search Settings */}
  1600. <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
  1601. <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
  1602. <div className="w-8 h-8 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600">
  1603. <Database size={16} />
  1604. </div>
  1605. {t('retrievalSearchSettings')}
  1606. </div>
  1607. <div className="space-y-8">
  1608. <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
  1609. <div>
  1610. <div className="flex justify-between mb-3 px-1">
  1611. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('topK')}</label>
  1612. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.topK}</span>
  1613. </div>
  1614. <input
  1615. type="range"
  1616. min="1"
  1617. max="50"
  1618. step="1"
  1619. value={localKbSettings.topK || 10}
  1620. onChange={(e) => handleUpdateKbSettings('topK', parseInt(e.target.value))}
  1621. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1622. />
  1623. </div>
  1624. <div>
  1625. <div className="flex justify-between mb-3 px-1">
  1626. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('similarityThreshold')}</label>
  1627. <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.similarityThreshold}</span>
  1628. </div>
  1629. <input
  1630. type="range"
  1631. min="0"
  1632. max="1"
  1633. step="0.05"
  1634. value={localKbSettings.similarityThreshold || 0.5}
  1635. onChange={(e) => handleUpdateKbSettings('similarityThreshold', parseFloat(e.target.value))}
  1636. className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1637. />
  1638. </div>
  1639. </div>
  1640. <div className="space-y-4 pt-4 border-t border-slate-100">
  1641. <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">
  1642. <div>
  1643. <div className="text-sm font-bold text-slate-800">{t('enableHybridSearch')}</div>
  1644. <div className="text-[10px] text-slate-400 font-medium">{t('hybridSearchDesc')}</div>
  1645. </div>
  1646. <button
  1647. onClick={() => handleUpdateKbSettings('enableFullTextSearch', !localKbSettings.enableFullTextSearch)}
  1648. 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'}`}
  1649. >
  1650. <span className={`${localKbSettings.enableFullTextSearch ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1651. </button>
  1652. </div>
  1653. {localKbSettings.enableFullTextSearch && (
  1654. <motion.div
  1655. initial={{ opacity: 0, y: -10 }}
  1656. animate={{ opacity: 1, y: 0 }}
  1657. className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
  1658. >
  1659. <div className="flex justify-between mb-2 px-1">
  1660. <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('hybridWeight')}</label>
  1661. <span className="text-sm font-black text-indigo-600">{localKbSettings.hybridVectorWeight || 0.5}</span>
  1662. </div>
  1663. <input
  1664. type="range"
  1665. min="0"
  1666. max="1"
  1667. step="0.05"
  1668. value={localKbSettings.hybridVectorWeight || 0.5}
  1669. onChange={(e) => handleUpdateKbSettings('hybridVectorWeight', parseFloat(e.target.value))}
  1670. className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1671. />
  1672. <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
  1673. <span>{t('pureText')}</span>
  1674. <span>{t('pureVector')}</span>
  1675. </div>
  1676. </motion.div>
  1677. )}
  1678. <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
  1679. <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">
  1680. <div>
  1681. <div className="text-sm font-bold text-slate-800">{t('enableQueryExpansion')}</div>
  1682. <div className="text-[10px] text-slate-400 font-medium">{t('queryExpansionDesc')}</div>
  1683. </div>
  1684. <button
  1685. onClick={() => handleUpdateKbSettings('enableQueryExpansion', !localKbSettings.enableQueryExpansion)}
  1686. 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'}`}
  1687. >
  1688. <span className={`${localKbSettings.enableQueryExpansion ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1689. </button>
  1690. </div>
  1691. <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">
  1692. <div>
  1693. <div className="text-sm font-bold text-slate-800">{t('enableHyDE')}</div>
  1694. <div className="text-[10px] text-slate-400 font-medium">{t('hydeDesc')}</div>
  1695. </div>
  1696. <button
  1697. onClick={() => handleUpdateKbSettings('enableHyDE', !localKbSettings.enableHyDE)}
  1698. 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'}`}
  1699. >
  1700. <span className={`${localKbSettings.enableHyDE ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1701. </button>
  1702. </div>
  1703. </div>
  1704. <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">
  1705. <div>
  1706. <div className="text-sm font-bold text-slate-800">{t('enableReranking')}</div>
  1707. <div className="text-[10px] text-slate-400 font-medium">{t('rerankingDesc')}</div>
  1708. </div>
  1709. <button
  1710. onClick={() => handleUpdateKbSettings('enableRerank', !localKbSettings.enableRerank)}
  1711. 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'}`}
  1712. >
  1713. <span className={`${localKbSettings.enableRerank ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
  1714. </button>
  1715. </div>
  1716. {localKbSettings.enableRerank && (
  1717. <motion.div
  1718. initial={{ opacity: 0, y: -10 }}
  1719. animate={{ opacity: 1, y: 0 }}
  1720. className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
  1721. >
  1722. <div className="flex justify-between mb-2 px-1">
  1723. <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('rerankSimilarityThreshold')}</label>
  1724. <span className="text-sm font-black text-indigo-600">{localKbSettings.rerankSimilarityThreshold || 0.5}</span>
  1725. </div>
  1726. <input
  1727. type="range"
  1728. min="0"
  1729. max="1"
  1730. step="0.05"
  1731. value={localKbSettings.rerankSimilarityThreshold || 0.5}
  1732. onChange={(e) => handleUpdateKbSettings('rerankSimilarityThreshold', parseFloat(e.target.value))}
  1733. className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
  1734. />
  1735. <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
  1736. <span>{t('broad')}</span>
  1737. <span>{t('strict')}</span>
  1738. </div>
  1739. </motion.div>
  1740. )}
  1741. </div>
  1742. </div>
  1743. </section>
  1744. </>
  1745. ) : (
  1746. <div className="flex flex-col items-center justify-center py-20 space-y-4">
  1747. <Loader2 size={40} className="animate-spin text-indigo-600 opacity-20" />
  1748. <p className="text-sm font-medium text-slate-400 animate-pulse">{t('loading')}</p>
  1749. </div>
  1750. )}
  1751. </div>
  1752. );
  1753. const renderModelTab = () => (
  1754. <div className="w-full space-y-6">
  1755. <div className="flex justify-between items-center mb-6">
  1756. <div>
  1757. </div>
  1758. {!editingId && currentUser?.role === 'SUPER_ADMIN' && (
  1759. <button
  1760. onClick={() => { setEditingId('new'); setModelFormData({ type: ModelType.LLM, baseUrl: 'http://localhost:11434/v1', modelId: 'llama3', name: '', dimensions: 1536 }); }}
  1761. 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"
  1762. >
  1763. <Plus className="w-4 h-4" />
  1764. {t('mmAddBtn')}
  1765. </button>
  1766. )}
  1767. </div>
  1768. {editingId ? (
  1769. <motion.div
  1770. initial={{ opacity: 0, y: 10 }}
  1771. animate={{ opacity: 1, y: 0 }}
  1772. className="bg-white/90 backdrop-blur-md p-10 rounded-3xl border border-slate-200/50 shadow-xl space-y-8"
  1773. >
  1774. <div className="flex items-center gap-4 mb-2">
  1775. <div className="w-12 h-12 rounded-2xl bg-indigo-50 flex items-center justify-center text-indigo-600">
  1776. <Cpu className="w-6 h-6" />
  1777. </div>
  1778. <h3 className="text-xl font-black text-slate-900 tracking-tight">{editingId === 'new' ? t('mmAddBtn') : t('mmEdit')}</h3>
  1779. </div>
  1780. <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
  1781. <div className="space-y-2">
  1782. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormName')} *</label>
  1783. <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} />
  1784. </div>
  1785. <div className="space-y-2">
  1786. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormModelId')} *</label>
  1787. <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} />
  1788. </div>
  1789. </div>
  1790. <div className="space-y-2">
  1791. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormType')} *</label>
  1792. <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}>
  1793. <option value={ModelType.LLM}>{t('typeLLM')}</option>
  1794. <option value={ModelType.EMBEDDING}>{t('typeEmbedding')}</option>
  1795. <option value={ModelType.RERANK}>{t('typeRerank')}</option>
  1796. <option value={ModelType.VISION}>{t('typeVision')}</option>
  1797. </select>
  1798. </div>
  1799. <div className="space-y-2">
  1800. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormBaseUrl')} *</label>
  1801. <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} />
  1802. </div>
  1803. <div className="space-y-2">
  1804. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormApiKey')}</label>
  1805. <input
  1806. type="password"
  1807. 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"
  1808. value={modelFormData.apiKey || ''}
  1809. onChange={e => setModelFormData({ ...modelFormData, apiKey: e.target.value })}
  1810. disabled={isLoading}
  1811. placeholder={t('mmFormApiKeyPlaceholder')}
  1812. />
  1813. </div>
  1814. {modelFormData.type === ModelType.EMBEDDING && (
  1815. <div className="grid grid-cols-2 gap-6 p-6 bg-slate-50 rounded-3xl border border-slate-200/50">
  1816. <div className="space-y-2">
  1817. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('maxInput')}</label>
  1818. <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) })} />
  1819. </div>
  1820. <div className="space-y-2">
  1821. <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('dimensions')}</label>
  1822. <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) })} />
  1823. </div>
  1824. </div>
  1825. )}
  1826. <div className="flex justify-end gap-3 pt-4">
  1827. <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>
  1828. <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}>
  1829. {isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
  1830. {t('mmSave')}
  1831. </button>
  1832. </div>
  1833. </motion.div>
  1834. ) : (
  1835. <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">
  1836. <AnimatePresence>
  1837. {models.map((model, index) => (
  1838. <motion.div
  1839. key={model.id}
  1840. initial={{ opacity: 0, scale: 0.95 }}
  1841. animate={{ opacity: 1, scale: 1 }}
  1842. transition={{ delay: index * 0.05 }}
  1843. 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"
  1844. >
  1845. {/* Subtle background pattern/glow */}
  1846. <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" />
  1847. <div className="relative z-10">
  1848. <div className="flex items-start justify-between mb-5">
  1849. <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'}`}>
  1850. <Cpu size={26} strokeWidth={2.5} />
  1851. </div>
  1852. <div className="flex gap-1 items-center bg-slate-50 p-1 rounded-xl border border-slate-100/50">
  1853. <button
  1854. onClick={() => handleToggleModel(model)}
  1855. className={`p-1.5 rounded-lg transition-all ${((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? 'text-indigo-600 bg-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
  1856. title={((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? t('modelEnabled') : t('modelDisabled')}
  1857. >
  1858. {((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? <ToggleRight size={24} /> : <ToggleLeft size={24} />}
  1859. </button>
  1860. </div>
  1861. </div>
  1862. <div className="space-y-1 mb-6">
  1863. <div className="flex items-center gap-2.5">
  1864. <h4 className="font-black text-slate-900 text-lg tracking-tight truncate">{model.name}</h4>
  1865. </div>
  1866. <div className="flex items-center gap-2">
  1867. <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">
  1868. {getTypeLabel(model.type)}
  1869. </span>
  1870. {model.isDefault && (
  1871. <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">
  1872. <Sparkles size={8} /> {t('defaultBadge')}
  1873. </span>
  1874. )}
  1875. </div>
  1876. <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">
  1877. {model.modelId}
  1878. </p>
  1879. </div>
  1880. {/* Additional info grid */}
  1881. <div className="grid grid-cols-2 gap-3 mb-6">
  1882. {model.type === ModelType.EMBEDDING && (
  1883. <>
  1884. <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
  1885. <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('dims')}</span>
  1886. <span className="text-xs font-bold text-slate-700">{model.dimensions || '-'}</span>
  1887. </div>
  1888. <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
  1889. <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('ctx')}</span>
  1890. <span className="text-xs font-bold text-slate-700">{model.maxInputTokens || '-'}</span>
  1891. </div>
  1892. </>
  1893. )}
  1894. {model.type === ModelType.LLM && (
  1895. <div className="col-span-2 bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50 flex items-center justify-between">
  1896. <span className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('baseApi')}</span>
  1897. <span className="text-[9px] font-mono font-medium text-slate-600 max-w-[140px] truncate">{model.baseUrl}</span>
  1898. </div>
  1899. )}
  1900. </div>
  1901. </div>
  1902. <div className="flex items-center justify-between pt-4 border-t border-slate-100/50 relative z-10">
  1903. <div className="flex items-center gap-1 text-[10px] font-bold text-slate-400">
  1904. <SettingsIcon size={12} />
  1905. {t('configured')}
  1906. </div>
  1907. <div className="flex gap-2">
  1908. {currentUser?.role === 'SUPER_ADMIN' && (
  1909. <>
  1910. <button
  1911. onClick={() => { setEditingId(model.id); setModelFormData({ ...model }); }}
  1912. 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"
  1913. >
  1914. <Edit2 size={15} />
  1915. </button>
  1916. <button
  1917. onClick={() => handleDeleteModel(model.id)}
  1918. 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"
  1919. >
  1920. <Trash2 size={15} />
  1921. </button>
  1922. </>
  1923. )}
  1924. </div>
  1925. </div>
  1926. </motion.div>
  1927. ))}
  1928. </AnimatePresence>
  1929. {models.length === 0 && (
  1930. <div className="bg-white/50 border-2 border-dashed border-slate-200 rounded-3xl p-16 text-center">
  1931. <Cpu className="w-12 h-12 text-slate-200 mx-auto mb-4" />
  1932. <p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t('mmEmpty')}</p>
  1933. </div>
  1934. )}
  1935. </div>
  1936. )}
  1937. </div>
  1938. );
  1939. return (
  1940. <div className="flex h-full bg-[#FCFDFF] overflow-hidden relative">
  1941. {/* Settings Sidebar */}
  1942. <div className="w-64 bg-slate-50/50 border-r border-slate-200/60 flex flex-col shrink-0 z-20">
  1943. <div className="p-6 pb-2">
  1944. <h2 className="text-lg font-bold text-slate-900 tracking-tight">{t('tabSettings')}</h2>
  1945. </div>
  1946. <div className="flex-1 overflow-y-auto p-3 space-y-1">
  1947. <button
  1948. onClick={() => setActiveTab('general')}
  1949. className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'general' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
  1950. }`}
  1951. >
  1952. <SettingsIcon size={18} />
  1953. {t('generalSettings')}
  1954. </button>
  1955. {currentUser?.role === 'SUPER_ADMIN' && (
  1956. <button
  1957. onClick={() => setActiveTab('user')}
  1958. className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'user' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
  1959. }`}
  1960. >
  1961. <UserCircle size={18} />
  1962. {t('userManagement')}
  1963. </button>
  1964. )}
  1965. {isAdmin && (
  1966. <>
  1967. <button
  1968. onClick={() => setActiveTab('model')}
  1969. className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'model' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
  1970. }`}
  1971. >
  1972. <HardDrive size={18} />
  1973. {t('modelManagement')}
  1974. </button>
  1975. <button
  1976. onClick={() => setActiveTab('knowledge_base')}
  1977. className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'knowledge_base' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
  1978. }`}
  1979. >
  1980. <Database size={18} />
  1981. {t('sidebarTitle')}
  1982. </button>
  1983. </>
  1984. )}
  1985. {currentUser?.role === 'SUPER_ADMIN' && (
  1986. <button
  1987. onClick={() => setActiveTab('tenants')}
  1988. className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'tenants' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
  1989. }`}
  1990. >
  1991. <LayoutGrid size={18} />
  1992. {t('navTenants')}
  1993. </button>
  1994. )}
  1995. {isAdmin && (
  1996. <button
  1997. onClick={() => setActiveTab('assessment_templates')}
  1998. className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all ${activeTab === 'assessment_templates' ? 'bg-white text-indigo-600 shadow-sm border border-slate-200/60' : 'text-slate-600 hover:bg-slate-100'
  1999. }`}
  2000. >
  2001. <FileText size={18} />
  2002. {t('assessmentTemplates')}
  2003. </button>
  2004. )}
  2005. </div>
  2006. </div>
  2007. {/* Content Area */}
  2008. <div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative bg-white z-10">
  2009. <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
  2010. <div>
  2011. <h1 className="text-2xl font-bold text-slate-900 leading-tight">
  2012. {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : activeTab === 'tenants' ? t('navTenants') : t('assessmentTemplates')}
  2013. </h1>
  2014. <p className="text-[15px] text-slate-500 mt-1">
  2015. {activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : activeTab === 'tenants' ? t('tenantsSubtitle') : t('assessmentTemplatesSubtitle')}
  2016. </p>
  2017. </div>
  2018. </div>
  2019. <div className="flex-1 overflow-y-auto px-10 pb-10 scrollbar-hide">
  2020. <div className="w-full">
  2021. {error && (
  2022. <motion.div
  2023. initial={{ opacity: 0, y: -10 }}
  2024. animate={{ opacity: 1, y: 0 }}
  2025. 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"
  2026. >
  2027. <div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0">
  2028. <X className="w-4 h-4 text-red-600" />
  2029. </div>
  2030. <div>
  2031. <span className="font-black uppercase tracking-widest text-[10px] block mb-0.5">{t('errorLabel')}</span>
  2032. {error}
  2033. </div>
  2034. </motion.div>
  2035. )}
  2036. <AnimatePresence mode="wait">
  2037. <motion.div
  2038. key={activeTab}
  2039. initial={{ opacity: 0, x: 20 }}
  2040. animate={{ opacity: 1, x: 0 }}
  2041. exit={{ opacity: 0, x: -20 }}
  2042. transition={{ duration: 0.3 }}
  2043. >
  2044. {activeTab === 'general' && renderGeneralTab()}
  2045. {activeTab === 'user' && currentUser?.role === 'SUPER_ADMIN' && renderUserTab()}
  2046. {activeTab === 'model' && isAdmin && renderModelTab()}
  2047. {activeTab === 'knowledge_base' && isAdmin && renderKnowledgeBaseTab()}
  2048. {activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN' && renderTenantsTab()}
  2049. {activeTab === 'assessment_templates' && isAdmin && (
  2050. <div className="bg-white rounded-3xl border border-slate-200/60 p-8 shadow-sm">
  2051. <AssessmentTemplateManager />
  2052. </div>
  2053. )}
  2054. </motion.div>
  2055. </AnimatePresence>
  2056. </div>
  2057. </div>
  2058. </div>
  2059. </div>
  2060. );
  2061. };