| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127 |
- import React, { useState, useEffect } from 'react';
- import { ModelConfig, ModelType, AppSettings, KnowledgeGroup, Tenant, TenantMember } from '../../types';
- import { useLanguage } from '../../contexts/LanguageContext';
- import {
- ChevronLeft,
- ChevronRight,
- Plus,
- Search,
- KeyRound,
- Trash2,
- Edit,
- UserPlus,
- Globe,
- PlusCircle,
- Clock,
- ExternalLink,
- Download,
- Upload,
- Building,
- Settings as SettingsIcon,
- Shield,
- User,
- MoreVertical,
- Check,
- ChevronDown,
- ChevronUp,
- Filter,
- RefreshCcw,
- LayoutDashboard,
- Users,
- Database,
- UserCircle,
- HardDrive,
- LayoutGrid,
- X,
- Key,
- Loader2,
- Edit2,
- Save,
- Cpu,
- BookOpen,
- Sparkles,
- ToggleRight,
- ToggleLeft,
- } from "lucide-react";
- import { motion, AnimatePresence } from 'framer-motion';
- import { userService } from '../../services/userService';
- import { settingsService } from '../../services/settingsService';
- import { userSettingService } from '../../services/userSettingService';
- import { knowledgeGroupService } from '../../services/knowledgeGroupService';
- import { apiClient } from '../../services/apiClient';
- import { useConfirm } from '../../contexts/ConfirmContext';
- import { useToast } from '../../contexts/ToastContext';
- interface SettingsViewProps {
- // Model Props
- models: ModelConfig[];
- authToken: string | null;
- onUpdateModels: (action: 'create' | 'update' | 'delete', model: ModelConfig) => Promise<void>;
- isAdmin?: boolean; // Added isAdmin prop
- currentUser?: any; // Added current user prop
- initialTab?: TabType;
- }
- type TabType = 'general' | 'user' | 'model' | 'tenants' | 'knowledge_base' | 'import_tasks';
- const buildTenantTree = (tenants: Tenant[]): Tenant[] => {
- const map = new Map<string, Tenant>();
- const roots: Tenant[] = [];
- tenants.forEach(t => {
- map.set(t.id, { ...t, children: [] });
- });
- tenants.forEach(t => {
- const node = map.get(t.id)!;
- if (t.parentId && map.has(t.parentId)) {
- const parent = map.get(t.parentId)!;
- parent.children = parent.children || [];
- parent.children.push(node);
- } else {
- roots.push(node);
- }
- });
- return roots;
- };
- // Moved outside to prevent re-mounting
- const Pagination: React.FC<{
- current: number;
- total: number;
- pageSize: number;
- onChange: (page: number) => void;
- }> = ({ current, total, pageSize, onChange }) => {
- const totalPages = Math.ceil(total / pageSize);
- if (totalPages <= 1) return null;
- return (
- <div className="flex items-center justify-center gap-2 mt-6">
- <button
- disabled={current === 1}
- onClick={() => onChange(current - 1)}
- 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"
- >
- <ChevronDown className="w-4 h-4 rotate-90" />
- </button>
- <div className="flex items-center gap-1">
- {[...Array(totalPages)].map((_, i) => {
- const p = i + 1;
- if (totalPages > 7) {
- if (p !== 1 && p !== totalPages && Math.abs(p - current) > 1) {
- if (p === 2 || p === totalPages - 1) return <span key={p} className="px-1 font-bold text-slate-300">...</span>;
- return null;
- }
- }
- return (
- <button
- key={p}
- onClick={() => onChange(p)}
- 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'}`}
- >
- {p}
- </button>
- );
- })}
- </div>
- <button
- disabled={current === totalPages}
- onClick={() => onChange(current + 1)}
- 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"
- >
- <ChevronDown className="w-4 h-4 -rotate-90" />
- </button>
- </div>
- );
- };
- export const SettingsView: React.FC<SettingsViewProps> = ({
- models,
- authToken,
- onUpdateModels,
- isAdmin = false,
- currentUser,
- initialTab = 'general',
- }) => {
- const { t, language, setLanguage } = useLanguage();
- const { confirm } = useConfirm();
- const { showError, showSuccess } = useToast();
- const [activeTab, setActiveTab] = useState<TabType>('general');
- const [isLoading, setIsLoading] = useState(false);
- const [error, setError] = useState<string | null>(null);
- // --- Model Manager State ---
- const [editingId, setEditingId] = useState<string | null>(null);
- const [modelFormData, setModelFormData] = useState<Partial<ModelConfig>>({
- type: ModelType.LLM,
- baseUrl: 'http://localhost:11434/v1',
- modelId: 'llama3',
- name: '',
- dimensions: 1536,
- apiKey: '',
- maxInputTokens: 8191,
- maxBatchSize: 2048
- });
- const [users, setUsers] = useState<any[]>([]);
- const [isUserLoading, setIsUserLoading] = useState(false);
- const [userPage, setUserPage] = useState(1);
- const USER_PAGE_SIZE = 20;
- const [showAddUser, setShowAddUser] = useState(false);
- const [newUser, setNewUser] = useState({ username: '', password: '', displayName: '' });
- const [userSuccess, setUserSuccess] = useState('');
- // --- Change Password State ---
- const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' });
- const [passwordSuccess, setPasswordSuccess] = useState('');
- // --- App Settings State ---
- const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
- const [knowledgeGroups, setKnowledgeGroups] = useState<KnowledgeGroup[]>([]);
- const [isSettingsLoading, setIsSettingsLoading] = useState(false);
- const [enabledModelIds, setEnabledModelIds] = useState<string[]>([]);
- // --- Tenant Admin Binding Search State ---
- const [bindingTenantId, setBindingTenantId] = useState<string | null>(null);
- const [userSearchQuery, setUserSearchQuery] = useState('');
- // --- Manage Members Modal State ---
- const [managingMembersTenantId, setManagingMembersTenantId] = useState<string | null>(null);
- const [tenantMembers, setTenantMembers] = useState<any[]>([]);
- const [allMemberIds, setAllMemberIds] = useState<Set<string>>(new Set());
- const [memberUserSearch, setMemberUserSearch] = useState('');
- const [bindingRole, setBindingRole] = useState('USER');
- const [currentMemberSearch, setCurrentMemberSearch] = useState('');
- const [isMembersLoading, setIsMembersLoading] = useState(false);
- const [activeTenantManagementId, setActiveTenantManagementId] = useState<string | null>(null);
- const [memberPage, setMemberPage] = useState(1);
- const [memberTotal, setMemberTotal] = useState(0);
- const MEMBER_PAGE_SIZE = 20;
- const [userTotal, setUserTotal] = useState(0);
- // --- Tenant Tree & Global Management State ---
- const [tenants, setTenants] = useState<Tenant[]>([]);
- const [selectedTenantId, setSelectedTenantId] = useState<string | null>(null);
- const [stats, setStats] = useState({ users: 0, tenants: 0 });
- const [showCreateTenant, setShowCreateTenant] = useState(false);
- const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
- const [newTenant, setNewTenant] = useState<{ name: string; domain: string; parentId: string | null }>({
- name: '',
- domain: '',
- parentId: null
- });
- useEffect(() => {
- if (initialTab) {
- setActiveTab(initialTab);
- }
- }, [initialTab]);
- useEffect(() => {
- if (activeTab === 'user' || activeTab === 'tenants') {
- fetchUsers(userPage);
- }
- }, [userPage]);
- useEffect(() => {
- if (selectedTenantId) {
- fetchTenantMembers(selectedTenantId, memberPage);
- fetchAllMemberIds(selectedTenantId);
- } else {
- setAllMemberIds(new Set());
- }
- }, [selectedTenantId, memberPage]);
- // Data fetching on tab change
- useEffect(() => {
- // Reset pages when switching tabs to avoid bleed-over
- if (activeTab === 'user' || activeTab === 'tenants') {
- setUserPage(1);
- }
- if (activeTab === 'user') {
- fetchUsers(1);
- } else if (activeTab === 'general') {
- fetchSettingsAndGroups();
- } else if (activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN') {
- fetchTenantsData();
- fetchUsers(1); // Ensure users are loaded for admin binding
- }
- // Independent check for KB/Model settings to avoid being blocked by the branches above
- if ((activeTab === 'knowledge_base' || activeTab === 'model') &&
- (currentUser?.role === 'TENANT_ADMIN' || currentUser?.role === 'SUPER_ADMIN' || isAdmin)) {
- fetchKnowledgeBaseSettings();
- }
- }, [activeTab, currentUser, authToken, isAdmin]);
- const [kbSettings, setKbSettings] = useState<any>(null);
- const [localKbSettings, setLocalKbSettings] = useState<any>(null);
- const [isSavingKbSettings, setIsSavingKbSettings] = useState(false);
- const fetchKnowledgeBaseSettings = async () => {
- if (!authToken) return;
- setIsLoading(true);
- try {
- const data = await userSettingService.get(authToken);
- if (data && Object.keys(data).length > 0) {
- setKbSettings(data);
- setLocalKbSettings(data);
- if (data.selectedVisionId) {
- // Vision model ID is part of settings now
- }
- }
- } catch (error) {
- console.error(error);
- } finally {
- setIsLoading(false);
- }
- };
- const handleUpdateKbSettings = (key: string, value: any) => {
- setLocalKbSettings((prev: any) => ({ ...prev, [key]: value }));
- };
- const handleSaveKbSettings = async () => {
- if (!authToken || !localKbSettings) return;
- setIsSavingKbSettings(true);
- try {
- await userSettingService.update(authToken, localKbSettings);
- setKbSettings(localKbSettings);
- showSuccess(t('kbSettingsSaved'));
- } catch (error) {
- console.error(error);
- showError(t('actionFailed'));
- } finally {
- setIsSavingKbSettings(false);
- }
- };
- const handleCancelKbSettings = () => {
- setLocalKbSettings(kbSettings);
- };
- const fetchSettingsAndGroups = async () => {
- if (!authToken) return;
- setIsSettingsLoading(true);
- try {
- const [settings, groups, personal] = await Promise.all([
- userSettingService.get(authToken),
- knowledgeGroupService.getGroups(),
- userSettingService.getPersonal(authToken)
- ]);
- setAppSettings(settings);
- setKnowledgeGroups(groups);
- // Sync local language with user settings if they differ
- if (personal?.language && personal.language !== language) {
- setLanguage(personal.language as any);
- }
- // Also update KB settings with the same data if not already set
- if (settings && Object.keys(settings).length > 0) {
- setKbSettings(settings);
- setLocalKbSettings(settings);
- }
- } catch (error) {
- console.error('Failed to fetch settings or groups:', error);
- } finally {
- setIsSettingsLoading(false);
- }
- };
- // --- 一般タブのハンドラー ---
- const handleChangePassword = async (e: React.FormEvent) => {
- e.preventDefault();
- setError(null);
- setPasswordSuccess('');
- if (passwordForm.new !== passwordForm.confirm) {
- setError(t('passwordMismatch'));
- return;
- }
- if (passwordForm.new.length < 6) {
- setError(t('newPasswordMinLength'));
- return;
- }
- setIsLoading(true);
- try {
- await userService.changePassword(passwordForm.current, passwordForm.new);
- setPasswordSuccess(t('passwordChangeSuccess'));
- setPasswordForm({ current: '', new: '', confirm: '' });
- } catch (err: any) {
- setError(err.message || t('passwordChangeFailed'));
- } finally {
- setIsLoading(false);
- }
- };
- // --- ユーザータブのハンドラー ---
- const fetchUsers = async (page?: number) => {
- setIsUserLoading(true);
- const p = page || userPage;
- try {
- const result = await userService.getUsers(p, USER_PAGE_SIZE);
- if (result && result.data) {
- setUsers(result.data);
- setUserTotal(result.total);
- } else if (Array.isArray(result)) {
- setUsers(result);
- setUserTotal(result.length);
- }
- } catch (error: any) {
- setError(error.message || t('getUserListFailed'));
- } finally {
- setIsUserLoading(false);
- }
- };
- const handleCreateUser = async (e: React.FormEvent) => {
- e.preventDefault();
- setError('');
- setUserSuccess('');
- if (newUser.username && newUser.password && newUser.displayName) {
- setIsUserLoading(true);
- try {
- await userService.createUser(
- newUser.username,
- newUser.password,
- false,
- undefined,
- newUser.displayName
- );
- showSuccess(t('userCreatedSuccess'));
- setNewUser({ username: '', password: '', displayName: '' });
- setShowAddUser(false);
- fetchUsers();
- } catch (error: any) {
- setError(error.message || t('createUserFailed'));
- } finally {
- setIsUserLoading(false);
- }
- }
- };
- const [passwordChangeUserData, setPasswordChangeUserData] = useState<{ userId: string, newPassword: string } | null>(null);
- // --- Edit User State ---
- const [editUserData, setEditUserData] = useState<{ userId: string, username: string, displayName: string } | null>(null);
- const handleToggleUserAdmin = async (userId: string, newAdminStatus: boolean) => {
- try {
- await userService.updateUser(userId, newAdminStatus);
- // ユーザーリストを再取得
- fetchUsers();
- setUserSuccess(newAdminStatus ? t('userPromotedToAdmin') : t('userDemotedFromAdmin'));
- } catch (error: any) {
- setError(error.message || t('updateUserFailed'));
- }
- };
- const handleUserPasswordChange = async () => {
- if (!passwordChangeUserData || !passwordChangeUserData.newPassword) return;
- try {
- // Update user password
- await userService.updateUserInfo(passwordChangeUserData.userId, { password: passwordChangeUserData.newPassword });
- setUserSuccess(t('passwordChangeSuccess'));
- setPasswordChangeUserData(null);
- fetchUsers(); // Refresh the user list
- } catch (error: any) {
- setError(error.message || t('passwordChangeFailed'));
- }
- };
- const fetchAllMemberIds = async (tenantId: string) => {
- try {
- const { data } = await apiClient.get<string[]>(`/v1/tenants/${tenantId}/members/ids`);
- if (Array.isArray(data)) {
- setAllMemberIds(new Set(data));
- }
- } catch (e) {
- console.error('Failed to fetch all member IDs:', e);
- }
- };
- const fetchTenantMembers = async (tenantId: string, page?: number) => {
- setIsMembersLoading(true);
- const p = page || memberPage;
- try {
- const { data } = await apiClient.get(`/v1/tenants/${tenantId}/members?page=${p}&limit=${MEMBER_PAGE_SIZE}`);
- if (data && data.data) {
- setTenantMembers(data.data);
- setMemberTotal(data.total);
- } else if (Array.isArray(data)) {
- setTenantMembers(data);
- setMemberTotal(data.length);
- }
- } catch (e) {
- console.error(e);
- } finally {
- setIsMembersLoading(false);
- }
- };
- const handleAddMember = async (tenantId: string, userId: string, role: string = 'USER') => {
- try {
- await apiClient.post(`/v1/tenants/${tenantId}/members`, { userId, role });
- setAllMemberIds(prev => {
- const next = new Set(prev);
- next.add(userId);
- return next;
- });
- showSuccess(t('confirm'));
- fetchTenantMembers(tenantId);
- fetchTenantsData();
- } catch (e: any) {
- showError(e.message || 'Error adding member');
- }
- };
- const handleRemoveMember = async (tenantId: string, userId: string) => {
- try {
- await apiClient.delete(`/v1/tenants/${tenantId}/members/${userId}`);
- setAllMemberIds(prev => {
- const next = new Set(prev);
- next.delete(userId);
- return next;
- });
- showSuccess('User removed from organization');
- fetchTenantMembers(tenantId);
- fetchTenantsData();
- } catch (e: any) {
- showError(e.message || 'Error removing member');
- }
- };
- const handleUpdateMemberRole = async (tenantId: string, userId: string, role: string) => {
- try {
- await apiClient.patch(`/v1/tenants/${tenantId}/members/${userId}`, { role });
- showSuccess(t('featureUpdated'));
- fetchTenantMembers(tenantId);
- } catch (e: any) {
- showError(e.message || 'Error updating role');
- }
- };
- const fetchTenantsData = async () => {
- if (!authToken) return;
- setIsLoading(true);
- try {
- const [tenRes, admRes] = await Promise.all([
- apiClient.get('/v1/tenants'),
- apiClient.get('/users?page=1&limit=1')
- ]);
- const data: Tenant[] = tenRes.data;
- const filteredData = data.filter(t => t.name !== 'Default');
- setTenants(filteredData);
- setStats(s => ({ ...s, tenants: filteredData.length }));
- const result = admRes.data;
- setStats(s => ({ ...s, users: result.total ?? result.length ?? 0 }));
- } catch (e) {
- console.error(e);
- } finally {
- setIsLoading(false);
- }
- };
- const handleCreateTenant = async (e: React.FormEvent) => {
- e.preventDefault();
- try {
- const path = editingTenant ? `/v1/tenants/${editingTenant.id}` : '/v1/tenants';
- const body = {
- name: newTenant.name,
- domain: newTenant.domain,
- parentId: newTenant.parentId
- };
- if (editingTenant) {
- await apiClient.put(path, body);
- } else {
- await apiClient.post(path, body);
- }
- setShowCreateTenant(false);
- setEditingTenant(null);
- setNewTenant({ name: '', domain: '', parentId: null });
- fetchTenantsData();
- showSuccess(editingTenant ? 'Tenant updated' : 'Tenant created');
- } catch (e: any) {
- showError(e.message || 'Action failed');
- }
- };
- const handleRemoveTenant = async (tenantId: string) => {
- if (!(await confirm('Delete this organization?'))) return;
- try {
- await apiClient.delete(`/v1/tenants/${tenantId}`);
- setSelectedTenantId(null);
- fetchTenantsData();
- showSuccess('Tenant deleted');
- } catch (e: any) {
- showError(e.message || 'Delete failed');
- }
- };
- const handleUpdateUser = async () => {
- if (!editUserData) return;
- try {
- await userService.updateUserInfo(editUserData.userId, {
- username: editUserData.username,
- displayName: editUserData.displayName
- });
- showSuccess(t('featureUpdated'));
- setEditUserData(null);
- fetchUsers();
- } catch (error: any) {
- showError('Failed to update user');
- }
- };
- const handleDeleteUser = async (userId: string) => {
- if (!confirm(t('confirmDeleteUser') || "Are you sure?")) return;
- try {
- await userService.deleteUser(userId);
- showSuccess(t('userDeletedSuccessfully'));
- fetchUsers();
- } catch (error: any) {
- showError(error.message || t('deleteUserFailed'));
- }
- };
- const handleExportUsers = async () => {
- try {
- const blob = await userService.exportUsers();
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `users_${new Date().toISOString().split('T')[0]}.xlsx`;
- document.body.appendChild(a);
- a.click();
- window.URL.revokeObjectURL(url);
- document.body.removeChild(a);
- } catch (error) {
- console.error('Export users failed', error);
- showError(t('exportFailed'));
- }
- };
- const handleImportUsers = async (e: React.ChangeEvent<HTMLInputElement>) => {
- const file = e.target.files?.[0];
- if (!file) return;
- try {
- const result = await userService.importUsers(file);
- showSuccess(t('importSuccess').replace('$1', (result.created + result.updated).toString()).replace('$2', result.errors.length.toString()));
- fetchUsers();
- if (result.errors.length > 0) {
- console.warn('Import had errors:', result.errors);
- }
- } catch (error: any) {
- console.error('Import users failed', error);
- showError(t('importFailed') + (error.response?.data?.message ? `: ${error.response.data.message}` : ''));
- } finally {
- // Reset input
- e.target.value = '';
- }
- };
- const handleSaveModel = async () => {
- if (!authToken) return;
- setIsLoading(true);
- try {
- await onUpdateModels(editingId === 'new' ? 'create' : 'update', modelFormData as ModelConfig);
- setEditingId(null);
- } catch (err) {
- setError('Update failed');
- } finally {
- setIsLoading(false);
- }
- };
- const handleToggleModel = async (model: ModelConfig) => {
- if (currentUser?.role === 'TENANT_ADMIN') {
- const newEnabledIds = enabledModelIds.includes(model.id)
- ? enabledModelIds.filter(id => id !== model.id)
- : [...enabledModelIds, model.id];
- try {
- await apiClient.put('/v1/admin/settings', { enabledModelIds: newEnabledIds });
- setEnabledModelIds(newEnabledIds);
- setLocalKbSettings((prev: any) => ({ ...prev, enabledModelIds: newEnabledIds }));
- showSuccess('Updated');
- } catch (e: any) {
- showError(e.message || 'Update failed');
- }
- return;
- }
- await onUpdateModels('update', { ...model, isEnabled: !model.isEnabled });
- };
- const handleDeleteModel = async (id: string) => {
- if (await confirm(t('confirmClear'))) {
- await onUpdateModels('delete', { id } as ModelConfig);
- }
- };
- const TenantTreeNode: React.FC<{
- tenant: Tenant;
- selectedTenantId: string | null;
- onSelect: (id: string) => void;
- onCreateSubtenant: (parentId: string) => void;
- depth?: number;
- }> = ({ tenant, selectedTenantId, onSelect, onCreateSubtenant, depth = 0 }) => {
- const [collapsed, setCollapsed] = useState(false);
- const hasChildren = tenant.children && tenant.children.length > 0;
- const isSelected = selectedTenantId === tenant.id;
- return (
- <div className="select-none">
- <div
- 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'}`}
- style={{ paddingLeft: `${depth * 1.5 + 0.75}rem` }}
- onClick={() => onSelect(tenant.id)}
- >
- <div className="flex items-center gap-2 flex-1 min-w-0">
- {hasChildren ? (
- <button
- onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
- className={`p-0.5 rounded-md hover:bg-black/10 transition-colors ${isSelected ? 'text-white/70' : 'text-slate-400'}`}
- >
- {collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
- </button>
- ) : (
- <div className="w-5" />
- )}
- <Building size={16} className={isSelected ? 'text-white' : 'text-slate-400'} />
- <span className="text-sm font-bold truncate">{tenant.name}</span>
- </div>
- <button
- onClick={(e) => {
- e.stopPropagation();
- onCreateSubtenant(tenant.id);
- }}
- 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'}`}
- title={t('createSubOrg')}
- >
- <Plus size={14} />
- </button>
- </div>
- {hasChildren && !collapsed && (
- <div className="mt-1">
- {tenant.children?.map(child => (
- <TenantTreeNode
- key={child.id}
- tenant={child}
- selectedTenantId={selectedTenantId}
- onSelect={onSelect}
- onCreateSubtenant={onCreateSubtenant}
- depth={depth + 1}
- />
- ))}
- </div>
- )}
- </div>
- );
- };
- const getTypeLabel = (type: ModelType) => {
- switch (type) {
- case ModelType.LLM: return t('typeLLM');
- case ModelType.EMBEDDING: return t('typeEmbedding');
- case ModelType.RERANK: return t('typeRerank');
- }
- };
- // --- レンダリング関数 ---
- const renderGeneralTab = () => (
- <div className="space-y-8 animate-in slide-in-from-right duration-300 max-w-2xl">
- {/* パスワード変更セクション */}
- <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
- <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
- <Key className="w-4 h-4 text-blue-500" />
- {t('changePassword')}
- </h3>
- <form onSubmit={handleChangePassword} className="space-y-4 max-w-sm">
- <div>
- <input
- type="password"
- placeholder={t('currentPassword')}
- value={passwordForm.current}
- onChange={e => setPasswordForm({ ...passwordForm, current: e.target.value })}
- className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
- required
- />
- </div>
- <div>
- <input
- type="password"
- placeholder={t('newPassword')}
- value={passwordForm.new}
- onChange={e => setPasswordForm({ ...passwordForm, new: e.target.value })}
- className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
- required
- />
- </div>
- <div>
- <input
- type="password"
- placeholder={t('confirmPassword')}
- value={passwordForm.confirm}
- onChange={e => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
- className="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
- required
- />
- </div>
- {passwordSuccess && <p className="text-xs text-green-600">{passwordSuccess}</p>}
- <button
- type="submit"
- disabled={isLoading}
- className="w-full py-2 bg-slate-800 text-white rounded-md text-sm hover:bg-slate-900 transition-colors disabled:opacity-50"
- >
- {isLoading ? <Loader2 className="w-4 h-4 animate-spin mx-auto" /> : t('confirmChange')}
- </button>
- </form>
- </section>
- {/* 语言设置セクション */}
- <section className="bg-white p-6 rounded-lg border border-slate-200 shadow-sm">
- <h3 className="text-sm font-medium text-slate-800 mb-4 flex items-center gap-2">
- <Globe className="w-4 h-4 text-blue-500" />
- {t('languageSettings')}
- </h3>
- <div className="space-y-4 max-w-sm">
- <div className="space-y-2">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">
- {t('switchLanguage')}
- </label>
- <select
- value={language}
- onChange={async (e) => {
- const newLang = e.target.value as any;
- setLanguage(newLang);
- try {
- await settingsService.updateLanguage(newLang);
- showSuccess(t('confirm'));
- } catch (err) {
- console.error('Failed to update backend language preference:', err);
- }
- }}
- 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"
- >
- <option value="en">English</option>
- <option value="zh">中文 (Chinese)</option>
- <option value="ja">日本語 (Japanese)</option>
- </select>
- </div>
- </div>
- </section>
- </div >
- );
- const renderUserTab = () => (
- <div className="space-y-6 w-full">
- <div className="flex justify-between items-center mb-6">
- <div>
- <p className="text-xs text-slate-400 font-medium">{''}</p>
- </div>
- {currentUser?.role === 'SUPER_ADMIN' && (
- <div className="flex gap-2">
- <button
- onClick={handleExportUsers}
- 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"
- title={t('exportUsers')}
- >
- <Download className="w-4 h-4" />
- <span className="hidden sm:inline">{t('exportUsers')}</span>
- </button>
- <div className="relative">
- <input
- type="file"
- accept=".xlsx,.xls,.csv"
- onChange={handleImportUsers}
- className="absolute inset-0 opacity-0 cursor-pointer"
- title={t('importUsers')}
- />
- <button
- 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"
- >
- <Upload className="w-4 h-4" />
- <span className="hidden sm:inline">{t('importUsers')}</span>
- </button>
- </div>
- <button
- onClick={() => setShowAddUser(!showAddUser)}
- 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"
- >
- <Plus className="w-4 h-4" />
- {t('addUser')}
- </button>
- </div>
- )}
- </div>
- {showAddUser && (
- <motion.form
- initial={{ opacity: 0, y: -10 }}
- animate={{ opacity: 1, y: 0 }}
- onSubmit={handleCreateUser}
- className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-xl mb-8 space-y-5"
- >
- <div className="grid grid-cols-1 md:grid-cols-3 gap-5">
- <input
- type="text"
- placeholder={t('usernamePlaceholder')}
- value={newUser.username}
- onChange={e => setNewUser({ ...newUser, username: e.target.value })}
- 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"
- required
- />
- <input
- type="text"
- placeholder={t('displayNamePlaceholder') || t('name')}
- value={newUser.displayName}
- onChange={e => setNewUser({ ...newUser, displayName: e.target.value })}
- 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"
- required
- />
- <input
- type="password"
- placeholder={t('passwordPlaceholder')}
- value={newUser.password}
- onChange={e => setNewUser({ ...newUser, password: e.target.value })}
- 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"
- required
- />
- </div>
- <div className="flex items-center justify-between">
- <div></div>
- <div className="flex gap-3">
- <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>
- <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>
- </div>
- </div>
- </motion.form>
- )}
- {passwordChangeUserData && (
- <div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm flex items-center justify-center z-[100] p-6">
- <motion.div
- initial={{ scale: 0.95, opacity: 0 }}
- animate={{ scale: 1, opacity: 1 }}
- className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
- >
- <div className="flex items-center justify-between mb-8">
- <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('changeUserPassword')}</h3>
- <button onClick={() => setPasswordChangeUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
- <X size={20} className="text-slate-400" />
- </button>
- </div>
- <form onSubmit={(e) => { e.preventDefault(); handleUserPasswordChange(); }} className="space-y-6">
- <div>
- <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
- {t('newPassword')}
- </label>
- <input
- type="password"
- value={passwordChangeUserData.newPassword}
- onChange={(e) => setPasswordChangeUserData({ ...passwordChangeUserData, newPassword: e.target.value })}
- 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"
- placeholder={t('enterNewPassword')}
- required
- />
- </div>
- <div className="flex gap-4 pt-4">
- <button type="button" onClick={() => setPasswordChangeUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
- <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>
- </div>
- </form>
- </motion.div>
- </div>
- )}
- {/* Edit User Modal */}
- {editUserData && (
- <div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4">
- <motion.div
- initial={{ scale: 0.95, opacity: 0 }}
- animate={{ scale: 1, opacity: 1 }}
- className="bg-white rounded-3xl p-10 w-full max-w-md shadow-2xl border border-white/20"
- >
- <div className="flex items-center justify-between mb-8">
- <h3 className="text-xl font-black text-slate-900 tracking-tight">{t('editUser')}</h3>
- <button onClick={() => setEditUserData(null)} className="p-2 hover:bg-slate-100 rounded-xl transition-all">
- <X size={20} className="text-slate-400" />
- </button>
- </div>
- <form onSubmit={(e) => { e.preventDefault(); handleUpdateUser(); }} className="space-y-6">
- <div>
- <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
- {t('username')}
- </label>
- <input
- type="text"
- value={editUserData.username}
- onChange={(e) => setEditUserData({ ...editUserData, username: e.target.value })}
- 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"
- placeholder={t('usernamePlaceholder')}
- required
- />
- </div>
- <div>
- <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">
- {t('displayName') || t('name')}
- </label>
- <input
- type="text"
- value={editUserData.displayName}
- onChange={(e) => setEditUserData({ ...editUserData, displayName: e.target.value })}
- 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"
- placeholder={t('displayNamePlaceholder') || t('namePlaceholder')}
- required
- />
- </div>
- <div className="py-2.5 px-4 bg-indigo-50 rounded-2xl border border-indigo-100/50">
- <p className="text-[10px] font-black text-indigo-500 uppercase tracking-widest mb-1">{t('globalUserNote') || "Note"}</p>
- <p className="text-[11px] text-indigo-700/70 leading-relaxed font-medium">
- {t('roleManagedInOrg') || "Roles are managed within organizations."}
- </p>
- </div>
- <div className="flex gap-4 pt-4">
- <button type="button" onClick={() => setEditUserData(null)} className="flex-1 py-3.5 text-slate-500 font-bold text-sm">{t('cancel')}</button>
- <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>
- </div>
- </form>
- </motion.div>
- </div>
- )}
- <div className="w-full bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-2xl overflow-hidden shadow-sm">
- <table className="w-full border-collapse text-left">
- <thead>
- <tr className="bg-slate-50/50 border-b border-slate-200/50">
- <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('username')}</th>
- <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('displayName') || t('name')}</th>
- <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('organizations')}</th>
- <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('createdAt')}</th>
- <th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest text-right">{t('actions')}</th>
- </tr>
- </thead>
- <tbody className="divide-y divide-slate-100">
- <AnimatePresence>
- {users.filter((u: any) => u.username !== 'admin').map((user: any, index: number) => {
- let IconComponent = User;
- let iconColors = 'bg-slate-50 text-slate-400';
- if (user.isAdmin) {
- IconComponent = Shield;
- iconColors = 'bg-red-50 text-red-600';
- }
- return (
- <motion.tr
- key={user.id}
- initial={{ opacity: 0, y: 10 }}
- animate={{ opacity: 1, y: 0 }}
- transition={{ delay: index * 0.03 }}
- className="group hover:bg-slate-50/50 transition-all"
- >
- <td className="px-6 py-4">
- <div className="flex items-center gap-3">
- <div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${iconColors}`}>
- <IconComponent size={18} />
- </div>
- <div className="min-w-0">
- <p className="font-bold text-slate-900 truncate">{user.username}</p>
- </div>
- </div>
- </td>
- <td className="px-6 py-4">
- <p className="text-sm text-slate-600 truncate">{user.displayName || '-'}</p>
- </td>
- <td className="px-6 py-4">
- {user.tenantMembers && user.tenantMembers.length > 0 ? (
- <div className="flex flex-wrap gap-1">
- {user.tenantMembers
- .filter((m: any) => m.tenant?.name !== 'Default')
- .map((m: any) => (
- <span
- key={m.tenantId}
- 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"
- >
- <Building size={8} />
- {m.tenant?.name || m.tenantId}
- </span>
- ))}
- </div>
- ) : (
- <span className="text-[10px] text-slate-400 italic">{t('noOrganization')}</span>
- )}
- </td>
- <td className="px-6 py-4">
- <p className="text-[11px] font-medium text-slate-600">
- {new Date(user.createdAt).toLocaleDateString()}
- </p>
- </td>
- <td className="px-6 py-4 text-right">
- <div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-all">
- {user.username !== 'admin' && (
- <>
- <button
- onClick={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setEditUserData({
- userId: user.id,
- username: user.username,
- displayName: user.displayName || ''
- });
- }}
- className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
- title={t('edit')}
- >
- <Edit2 className="w-4 h-4" />
- </button>
- <button
- onClick={() => setPasswordChangeUserData({ userId: user.id, newPassword: '' })}
- className="p-2 rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-white shadow-sm transition-all"
- title={t('changeUserPassword')}
- >
- <Key className="w-4 h-4" />
- </button>
- {user.id !== currentUser?.id && (
- <button
- onClick={() => handleDeleteUser(user.id)}
- className="p-2 rounded-lg text-slate-400 hover:text-red-500 hover:bg-white shadow-sm transition-all"
- title={t('deleteUser')}
- >
- <Trash2 className="w-4 h-4" />
- </button>
- )}
- </>
- )}
- </div>
- </td>
- </motion.tr>
- );
- })}
- </AnimatePresence>
- </tbody>
- </table>
- </div>
- <Pagination
- current={userPage}
- total={userTotal}
- pageSize={USER_PAGE_SIZE}
- onChange={setUserPage}
- />
- </div>
- );
- const renderTenantsTab = () => {
- const tenantTree = buildTenantTree(tenants);
- const activeTenant = selectedTenantId ? tenants.find(t => t.id === selectedTenantId) : null;
- return (
- <div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-12rem)] animate-in fade-in duration-500">
- {/* Left: Organization Tree */}
- <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">
- <div className="p-6 border-b border-slate-100 flex items-center justify-between shrink-0">
- <div>
- <h3 className="font-black text-slate-900 text-lg tracking-tight">{t('orgManagement')}</h3>
- <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">{t('globalTenantControl')}</p>
- </div>
- <button
- onClick={() => {
- setNewTenant({ name: '', domain: '', parentId: null });
- setEditingTenant(null);
- setShowCreateTenant(true);
- }}
- className="p-2 bg-slate-900 text-white rounded-xl hover:bg-slate-700 transition-all"
- >
- <Plus size={18} />
- </button>
- </div>
- <div className="flex-1 overflow-y-auto p-4 space-y-1 custom-scrollbar">
- {tenantTree.length > 0 ? (
- tenantTree.map(t => (
- <TenantTreeNode
- key={t.id}
- tenant={t}
- selectedTenantId={selectedTenantId}
- onSelect={(id) => {
- if (id !== selectedTenantId) {
- setSelectedTenantId(id);
- setMemberPage(1);
- setUserPage(1);
- }
- }}
- onCreateSubtenant={(parentId) => {
- setNewTenant({ name: '', domain: '', parentId });
- setEditingTenant(null);
- setShowCreateTenant(true);
- }}
- />
- ))
- ) : (
- <div className="py-20 text-center">
- <Building size={32} className="mx-auto text-slate-200 mb-3" />
- <p className="text-xs font-black text-slate-300 uppercase tracking-widest">{t('noOrganizations')}</p>
- </div>
- )}
- </div>
- <div className="p-4 bg-slate-50 border-t border-slate-100 shrink-0">
- <div className="flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-2xl shadow-sm">
- <div className="w-10 h-10 rounded-xl bg-blue-50 flex items-center justify-center text-blue-600">
- <Building size={20} />
- </div>
- <div>
- <p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('totalTenants')}</p>
- <p className="text-xl font-black text-slate-900">{stats.tenants}</p>
- </div>
- </div>
- </div>
- </div>
- {/* Right: User List & Management */}
- <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">
- {activeTenant ? (
- <div className="flex flex-col h-full">
- {/* Organization Header */}
- <div className="p-8 border-b border-slate-100 bg-slate-50/50 flex items-center justify-between shrink-0">
- <div className="flex items-center gap-4">
- <div className="w-14 h-14 rounded-2xl bg-white border border-slate-200 shadow-sm flex items-center justify-center text-indigo-600">
- <Building size={28} />
- </div>
- <div>
- <div className="flex items-center gap-2">
- <h2 className="text-2xl font-black text-slate-900 tracking-tight">{activeTenant.name}</h2>
- <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>
- </div>
- <p className="text-xs font-medium text-slate-400">{activeTenant.domain || t('noCustomDomain')}</p>
- </div>
- </div>
- <div className="flex items-center gap-2">
- <button
- onClick={() => {
- setEditingTenant(activeTenant);
- setNewTenant({
- name: activeTenant.name,
- domain: activeTenant.domain || '',
- parentId: activeTenant.parentId || null
- });
- setShowCreateTenant(true);
- }}
- 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"
- title={t('orgSettings')}
- >
- <SettingsIcon size={18} />
- </button>
- <button
- onClick={() => handleRemoveTenant(activeTenant.id)}
- 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"
- title={t('deleteOrg')}
- >
- <Trash2 size={18} />
- </button>
- </div>
- </div>
- {/* Main Content Split: Members vs All Users */}
- <div className="flex-1 flex overflow-hidden">
- {/* Current Members */}
- <div className="flex-1 flex flex-col border-r border-slate-100 overflow-hidden">
- <div className="p-6 border-b border-slate-50 flex items-center justify-between shrink-0">
- <h4 className="text-xs font-black text-slate-400 uppercase tracking-widest">{t('orgMembers')}</h4>
- <span className="text-[10px] font-black px-2 py-0.5 bg-slate-100 text-slate-500 rounded-full">
- {t('membersCount').replace('$1', (memberTotal || 0).toString())}
- </span>
- </div>
- <div className="flex-1 overflow-y-auto p-6 scrollbar-hide">
- <div className="grid grid-cols-1 gap-3">
- {tenantMembers?.map((m: any) => (
- <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">
- <div className="flex items-center gap-3 min-w-0">
- <div className="w-10 h-10 rounded-xl bg-white border border-slate-200 flex items-center justify-center text-slate-400 shadow-sm">
- <User size={18} />
- </div>
- <div className="min-w-0">
- <p className="text-sm font-black text-slate-900 truncate">{m.user?.username || m.userId}</p>
- <select
- value={m.role}
- onChange={(e) => handleUpdateMemberRole(activeTenant.id, m.userId, e.target.value)}
- 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'}`}
- >
- <option value="USER">{t('roleRegularUser')}</option>
- <option value="TENANT_ADMIN">{t('roleTenantAdmin')}</option>
- </select>
- </div>
- </div>
- <button
- onClick={() => handleRemoveMember(activeTenant.id, m.userId)}
- className="p-2 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all"
- >
- <Trash2 size={14} />
- </button>
- </div>
- ))}
- {(!tenantMembers || tenantMembers.length === 0) && (
- <div className="py-20 text-center">
- <Users size={24} className="mx-auto text-slate-200 mb-2" />
- <p className="text-[10px] font-bold text-slate-300 uppercase tracking-wider">{t('noMembersAssigned')}</p>
- </div>
- )}
- </div>
- <Pagination
- current={memberPage}
- total={memberTotal}
- pageSize={MEMBER_PAGE_SIZE}
- onChange={setMemberPage}
- />
- </div>
- </div>
- {/* Add New Users (Right side of specific tenant view) */}
- <div className="w-72 flex flex-col bg-slate-50/30 overflow-hidden shrink-0">
- <div className="p-6 border-b border-slate-100 shrink-0">
- <h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3">{t('addMembers')}</h4>
- <div className="relative">
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={14} />
- <input
- 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"
- placeholder={t('searchSystemUsers')}
- value={userSearchQuery}
- onChange={e => setUserSearchQuery(e.target.value)}
- />
- </div>
- <div className="mt-3 flex gap-1 p-1 bg-white border border-slate-200 rounded-xl">
- <button
- onClick={() => setBindingRole('USER')}
- 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'}`}
- >
- {t('roleRegularUser')}
- </button>
- <button
- onClick={() => setBindingRole('TENANT_ADMIN')}
- 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'}`}
- >
- {t('roleTenantAdmin')}
- </button>
- </div>
- </div>
- <div className="flex-1 overflow-y-auto p-4 space-y-2">
- {users
- .filter(u =>
- u.username !== 'admin' &&
- u.username.toLowerCase().includes(userSearchQuery.toLowerCase())
- )
- .map(u => {
- const isAlreadyMember = allMemberIds.has(u.id);
- return (
- <button
- key={u.id}
- onClick={() => !isAlreadyMember && handleAddMember(activeTenant.id, u.id, bindingRole)}
- disabled={isAlreadyMember}
- className={`w-full p-3 border rounded-xl flex items-center justify-between group transition-all ${isAlreadyMember
- ? 'bg-slate-50 border-slate-100 cursor-not-allowed opacity-60'
- : 'bg-white border-slate-100 hover:border-indigo-500 hover:shadow-sm'
- }`}
- >
- <div className="flex items-center gap-2 min-w-0">
- <div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${isAlreadyMember
- ? 'bg-slate-100 text-slate-300'
- : 'bg-slate-50 text-slate-400 group-hover:bg-indigo-50 group-hover:text-indigo-600'
- }`}>
- <User size={14} />
- </div>
- <span className={`text-[13px] font-bold truncate ${isAlreadyMember ? 'text-slate-400' : 'text-slate-700'}`}>
- {u.username}
- </span>
- </div>
- {isAlreadyMember ? (
- <Check size={14} className="text-emerald-500" />
- ) : (
- <Plus size={14} className="text-slate-300 group-hover:text-indigo-600" />
- )}
- </button>
- );
- })
- }
- <Pagination
- current={userPage}
- total={userTotal}
- pageSize={USER_PAGE_SIZE}
- onChange={setUserPage}
- />
- </div>
- </div>
- </div>
- {/* Create Tenant Modal (Nested in Tab Content for scope) */}
- {showCreateTenant && (
- <div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
- <motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="bg-white rounded-3xl p-8 w-full max-w-md shadow-2xl">
- <h3 className="text-xl font-black text-slate-900 mb-6">{editingTenant ? t('editOrg') : t('newTenant')}</h3>
- <form onSubmit={handleCreateTenant} className="space-y-5">
- <div>
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
- <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 />
- </div>
- <div>
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
- <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 })} />
- </div>
- <div>
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
- {!editingTenant ? (
- <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">
- {newTenant.parentId ? tenants.find(t => t.id === newTenant.parentId)?.name : t('noneRoot')}
- </div>
- ) : (
- <select
- 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"
- value={newTenant.parentId || ''}
- onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
- >
- <option value="">{t('noneRoot')}</option>
- {tenants.filter(t => t.id !== editingTenant?.id).map(t => (
- <option key={t.id} value={t.id}>{t.name}</option>
- ))}
- </select>
- )}
- </div>
- <div className="flex gap-4 pt-2">
- <button type="button" onClick={() => { setShowCreateTenant(false); setEditingTenant(null); }} className="flex-1 py-3 text-slate-500 font-bold text-sm">{t('cancel')}</button>
- <button type="submit" className="flex-1 py-3 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600">{editingTenant ? t('update') : t('create')}</button>
- </div>
- </form>
- </motion.div>
- </div>
- )}
- </div>
- ) : (
- <div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-slate-50/30">
- <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">
- <Building size={48} />
- </div>
- <h2 className="text-2xl font-black text-slate-900 tracking-tight mb-2">{t('selectOrg')}</h2>
- <p className="text-sm text-slate-400 max-w-xs font-medium leading-relaxed">{t('selectOrgDesc')}</p>
- <div className="mt-12 grid grid-cols-2 gap-4 w-full max-w-lg">
- <div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
- <div className="w-10 h-10 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600 mb-4">
- <Users size={20} />
- </div>
- <p className="text-xl font-black text-slate-900">{stats.users}</p>
- <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{t('totalSystemUsers')}</p>
- </div>
- <div className="p-6 bg-white border border-slate-200 rounded-3xl text-left shadow-sm">
- <div className="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600 mb-4">
- <Shield size={20} />
- </div>
- <p className="text-xl font-black text-slate-900">{tenants.filter(t => t.parentId === null).length}</p>
- <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">{t('rootOrgs')}</p>
- </div>
- </div>
- {/* Scope Modal for Create even when no selection */}
- {showCreateTenant && (
- <div className="fixed inset-0 z-[120] bg-slate-900/40 backdrop-blur-sm flex items-center justify-center p-4 text-left">
- <motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="bg-white rounded-3xl p-8 w-full max-w-md shadow-2xl">
- <h3 className="text-xl font-black text-slate-900 mb-6">{t('newTenant')}</h3>
- <form onSubmit={handleCreateTenant} className="space-y-5">
- <div>
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('tenantName')}</label>
- <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 />
- </div>
- <div>
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('domainOptional')}</label>
- <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 })} />
- </div>
- <div>
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('parentOrg')}</label>
- <select
- 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"
- value={newTenant.parentId || ''}
- onChange={e => setNewTenant({ ...newTenant, parentId: e.target.value || null })}
- >
- <option value="">{t('noneRoot')}</option>
- {tenants.map(t => (
- <option key={t.id} value={t.id}>{t.name}</option>
- ))}
- </select>
- </div>
- <div className="flex gap-4 pt-2">
- <button
- type="button"
- onClick={() => {
- setShowCreateTenant(false);
- setNewTenant({ name: '', domain: '', parentId: null });
- }}
- className="flex-1 py-3 text-slate-500 font-bold text-sm"
- >
- {t('cancel')}
- </button>
- <button type="submit" className="flex-1 py-3 bg-slate-900 text-white rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-indigo-600">{t('create')}</button>
- </div>
- </form>
- </motion.div>
- </div>
- )}
- </div>
- )}
- </div>
- </div>
- );
- };
- const renderKnowledgeBaseTab = () => (
- <div className="space-y-8 animate-in slide-in-from-right duration-300 w-full max-w-5xl pb-10">
- {localKbSettings && (
- <>
- {/* Save/Cancel Bar */}
- <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">
- <button
- onClick={handleCancelKbSettings}
- disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
- className="px-6 py-2 text-sm font-bold text-slate-500 hover:text-slate-700 disabled:opacity-30"
- >
- {t('cancel')}
- </button>
- <button
- onClick={handleSaveKbSettings}
- disabled={isSavingKbSettings || JSON.stringify(localKbSettings) === JSON.stringify(kbSettings)}
- 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"
- >
- {isSavingKbSettings ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
- {t('saveChanges')}
- </button>
- </div>
- {/* Model Configuration */}
- <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
- <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
- <div className="w-8 h-8 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600">
- <Cpu size={16} />
- </div>
- {t('modelConfiguration')}
- </div>
- <div className="grid grid-cols-1 gap-6">
- <div>
- <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('defaultLLMModel')}</label>
- <select
- value={localKbSettings.selectedLLMId || ''}
- onChange={(e) => handleUpdateKbSettings('selectedLLMId', e.target.value)}
- 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"
- >
- <option value="">{t('selectLLM')}</option>
- {models.filter(m => m.type === ModelType.LLM).map(m => (
- <option key={m.id} value={m.id}>{m.name} ({m.modelId})</option>
- ))}
- </select>
- </div>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div>
- <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('embeddingModel')}</label>
- <select
- value={localKbSettings.selectedEmbeddingId || ''}
- onChange={(e) => handleUpdateKbSettings('selectedEmbeddingId', e.target.value)}
- 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"
- >
- <option value="">{t('selectEmbedding')}</option>
- {models.filter(m => m.type === ModelType.EMBEDDING).map(m => (
- <option key={m.id} value={m.id}>{m.name}</option>
- ))}
- </select>
- </div>
- <div>
- <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('rerankModel')}</label>
- <select
- value={localKbSettings.selectedRerankId || ''}
- onChange={(e) => handleUpdateKbSettings('selectedRerankId', e.target.value)}
- 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"
- >
- <option value="">{t('none')}</option>
- {models.filter(m => m.type === ModelType.RERANK).map(m => (
- <option key={m.id} value={m.id}>{m.name}</option>
- ))}
- </select>
- </div>
- </div>
- </div>
- </section>
- {/* Indexing & Chunking Configuration */}
- <section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6">
- <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
- <div className="w-8 h-8 rounded-xl bg-orange-50 flex items-center justify-center text-orange-600">
- <BookOpen size={16} />
- </div>
- {t('indexingChunkingConfig')}
- </div>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
- <div>
- <div className="flex justify-between mb-3 px-1">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkSize')}</label>
- <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkSize || 1000}</span>
- </div>
- <input
- type="range"
- min="100"
- max="8192"
- step="100"
- value={localKbSettings.chunkSize || 1000}
- onChange={(e) => handleUpdateKbSettings('chunkSize', parseInt(e.target.value))}
- className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
- />
- </div>
- <div>
- <div className="flex justify-between mb-3 px-1">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('chunkOverlap')}</label>
- <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.chunkOverlap || 100}</span>
- </div>
- <input
- type="range"
- min="0"
- max="2048"
- step="10"
- value={localKbSettings.chunkOverlap || 100}
- onChange={(e) => handleUpdateKbSettings('chunkOverlap', parseInt(e.target.value))}
- className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
- />
- </div>
- </div>
- </section >
- {/* Chat Hyperparameters */}
- < section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6" >
- <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
- <div className="w-8 h-8 rounded-xl bg-pink-50 flex items-center justify-center text-pink-600">
- <Sparkles size={16} />
- </div>
- {t('chatHyperparameters')}
- </div>
- <div className="space-y-8">
- <div>
- <div className="flex justify-between mb-3 px-1">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('temperature')}</label>
- <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.temperature}</span>
- </div>
- <input
- type="range"
- min="0"
- max="1"
- step="0.1"
- value={localKbSettings.temperature || 0.7}
- onChange={(e) => handleUpdateKbSettings('temperature', parseFloat(e.target.value))}
- className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
- />
- <div className="flex justify-between mt-2 px-1 text-[9px] font-bold text-slate-300 uppercase tracking-tighter">
- <span>{t('precise')}</span>
- <span>{t('creative')}</span>
- </div>
- </div>
- <div>
- <label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 px-1">{t('maxResponseTokens')}</label>
- <input
- type="number"
- value={localKbSettings.maxTokens || 2000}
- onChange={(e) => handleUpdateKbSettings('maxTokens', parseInt(e.target.value))}
- 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"
- />
- </div>
- </div>
- </section >
- {/* Retrieval & Search Settings */}
- < section className="bg-white/80 backdrop-blur-md p-8 rounded-3xl border border-slate-200/50 shadow-sm space-y-6" >
- <div className="flex items-center gap-3 text-slate-900 font-black uppercase tracking-widest text-[11px] border-b border-slate-100 pb-4">
- <div className="w-8 h-8 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600">
- <Database size={16} />
- </div>
- {t('retrievalSearchSettings')}
- </div>
- <div className="space-y-8">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
- <div>
- <div className="flex justify-between mb-3 px-1">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('topK')}</label>
- <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.topK}</span>
- </div>
- <input
- type="range"
- min="1"
- max="50"
- step="1"
- value={localKbSettings.topK || 10}
- onChange={(e) => handleUpdateKbSettings('topK', parseInt(e.target.value))}
- className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
- />
- </div>
- <div>
- <div className="flex justify-between mb-3 px-1">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('similarityThreshold')}</label>
- <span className="text-sm font-black text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded-lg">{localKbSettings.similarityThreshold}</span>
- </div>
- <input
- type="range"
- min="0"
- max="1"
- step="0.05"
- value={localKbSettings.similarityThreshold || 0.5}
- onChange={(e) => handleUpdateKbSettings('similarityThreshold', parseFloat(e.target.value))}
- className="w-full h-2 bg-slate-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
- />
- </div>
- </div>
- <div className="space-y-4 pt-4 border-t border-slate-100">
- <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
- <div>
- <div className="text-sm font-bold text-slate-800">{t('enableHybridSearch')}</div>
- <div className="text-[10px] text-slate-400 font-medium">{t('hybridSearchDesc')}</div>
- </div>
- <button
- onClick={() => handleUpdateKbSettings('enableFullTextSearch', !localKbSettings.enableFullTextSearch)}
- 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'}`}
- >
- <span className={`${localKbSettings.enableFullTextSearch ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
- </button>
- </div>
- {localKbSettings.enableFullTextSearch && (
- <motion.div
- initial={{ opacity: 0, y: -10 }}
- animate={{ opacity: 1, y: 0 }}
- className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
- >
- <div className="flex justify-between mb-2 px-1">
- <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('hybridWeight')}</label>
- <span className="text-sm font-black text-indigo-600">{localKbSettings.hybridVectorWeight || 0.5}</span>
- </div>
- <input
- type="range"
- min="0"
- max="1"
- step="0.05"
- value={localKbSettings.hybridVectorWeight || 0.5}
- onChange={(e) => handleUpdateKbSettings('hybridVectorWeight', parseFloat(e.target.value))}
- className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
- />
- <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
- <span>{t('pureText')}</span>
- <span>{t('pureVector')}</span>
- </div>
- </motion.div>
- )}
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
- <div>
- <div className="text-sm font-bold text-slate-800">{t('enableQueryExpansion')}</div>
- <div className="text-[10px] text-slate-400 font-medium">{t('queryExpansionDesc')}</div>
- </div>
- <button
- onClick={() => handleUpdateKbSettings('enableQueryExpansion', !localKbSettings.enableQueryExpansion)}
- 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'}`}
- >
- <span className={`${localKbSettings.enableQueryExpansion ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
- </button>
- </div>
- <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
- <div>
- <div className="text-sm font-bold text-slate-800">{t('enableHyDE')}</div>
- <div className="text-[10px] text-slate-400 font-medium">{t('hydeDesc')}</div>
- </div>
- <button
- onClick={() => handleUpdateKbSettings('enableHyDE', !localKbSettings.enableHyDE)}
- 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'}`}
- >
- <span className={`${localKbSettings.enableHyDE ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
- </button>
- </div>
- </div>
- <div className="flex items-center justify-between p-5 bg-slate-50/50 rounded-2xl border border-slate-200/30 transition-all hover:bg-white hover:border-indigo-100">
- <div>
- <div className="text-sm font-bold text-slate-800">{t('enableReranking')}</div>
- <div className="text-[10px] text-slate-400 font-medium">{t('rerankingDesc')}</div>
- </div>
- <button
- onClick={() => handleUpdateKbSettings('enableRerank', !localKbSettings.enableRerank)}
- 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'}`}
- >
- <span className={`${localKbSettings.enableRerank ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`} />
- </button>
- </div>
- {localKbSettings.enableRerank && (
- <motion.div
- initial={{ opacity: 0, y: -10 }}
- animate={{ opacity: 1, y: 0 }}
- className="p-5 bg-indigo-50/30 rounded-2xl border border-indigo-100/50 space-y-4"
- >
- <div className="flex justify-between mb-2 px-1">
- <label className="text-[10px] font-black text-indigo-400 uppercase tracking-widest">{t('rerankSimilarityThreshold')}</label>
- <span className="text-sm font-black text-indigo-600">{localKbSettings.rerankSimilarityThreshold || 0.5}</span>
- </div>
- <input
- type="range"
- min="0"
- max="1"
- step="0.05"
- value={localKbSettings.rerankSimilarityThreshold || 0.5}
- onChange={(e) => handleUpdateKbSettings('rerankSimilarityThreshold', parseFloat(e.target.value))}
- className="w-full h-2 bg-indigo-100 rounded-lg appearance-none cursor-pointer accent-indigo-600"
- />
- <div className="flex justify-between mt-1 px-1 text-[9px] font-bold text-indigo-300 uppercase">
- <span>{t('broad')}</span>
- <span>{t('strict')}</span>
- </div>
- </motion.div>
- )}
- </div>
- </div>
- </section>
- </>
- )}
- </div>
- );
- const renderModelTab = () => (
- <div className="w-full space-y-6">
- <div className="flex justify-between items-center mb-6">
- <div>
- </div>
- {!editingId && currentUser?.role === 'SUPER_ADMIN' && (
- <button
- onClick={() => { setEditingId('new'); setModelFormData({ type: ModelType.LLM, baseUrl: 'http://localhost:11434/v1', modelId: 'llama3', name: '', dimensions: 1536 }); }}
- 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"
- >
- <Plus className="w-4 h-4" />
- {t('mmAddBtn')}
- </button>
- )}
- </div>
- {editingId ? (
- <motion.div
- initial={{ opacity: 0, y: 10 }}
- animate={{ opacity: 1, y: 0 }}
- className="bg-white/90 backdrop-blur-md p-10 rounded-3xl border border-slate-200/50 shadow-xl space-y-8"
- >
- <div className="flex items-center gap-4 mb-2">
- <div className="w-12 h-12 rounded-2xl bg-indigo-50 flex items-center justify-center text-indigo-600">
- <Cpu className="w-6 h-6" />
- </div>
- <h3 className="text-xl font-black text-slate-900 tracking-tight">{editingId === 'new' ? t('mmAddBtn') : t('mmEdit')}</h3>
- </div>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div className="space-y-2">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormName')} *</label>
- <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} />
- </div>
- <div className="space-y-2">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormModelId')} *</label>
- <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} />
- </div>
- </div>
- <div className="space-y-2">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormType')} *</label>
- <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}>
- <option value={ModelType.LLM}>{t('typeLLM')}</option>
- <option value={ModelType.EMBEDDING}>{t('typeEmbedding')}</option>
- <option value={ModelType.RERANK}>{t('typeRerank')}</option>
- <option value={ModelType.VISION}>{t('typeVision')}</option>
- </select>
- </div>
- <div className="space-y-2">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormBaseUrl')} *</label>
- <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} />
- </div>
- <div className="space-y-2">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('mmFormApiKey')}</label>
- <input
- type="password"
- 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.apiKey || ''}
- onChange={e => setModelFormData({ ...modelFormData, apiKey: e.target.value })}
- disabled={isLoading}
- placeholder={t('mmFormApiKeyPlaceholder')}
- />
- </div>
- {modelFormData.type === ModelType.EMBEDDING && (
- <div className="grid grid-cols-2 gap-6 p-6 bg-slate-50 rounded-3xl border border-slate-200/50">
- <div className="space-y-2">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('maxInput')}</label>
- <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) })} />
- </div>
- <div className="space-y-2">
- <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1">{t('dimensions')}</label>
- <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) })} />
- </div>
- </div>
- )}
- <div className="flex justify-end gap-3 pt-4">
- <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>
- <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}>
- {isLoading && <Loader2 className="w-4 h-4 animate-spin" />}
- {t('mmSave')}
- </button>
- </div>
- </motion.div>
- ) : (
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-5 overflow-y-auto pr-2 pb-6 scrollbar-hide">
- <AnimatePresence>
- {models.map((model, index) => (
- <motion.div
- key={model.id}
- initial={{ opacity: 0, scale: 0.95 }}
- animate={{ opacity: 1, scale: 1 }}
- transition={{ delay: index * 0.05 }}
- className="bg-white/70 backdrop-blur-md border border-slate-200/50 rounded-[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"
- >
- {/* Subtle background pattern/glow */}
- <div className="absolute -top-12 -right-12 w-32 h-32 bg-indigo-500/5 rounded-full blur-3xl group-hover:bg-indigo-500/10 transition-all duration-500" />
- <div className="relative z-10">
- <div className="flex items-start justify-between mb-5">
- <div className={`w-14 h-14 rounded-2xl flex items-center justify-center transition-all duration-500 ${model.isEnabled !== false ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-100 rotate-0 group-hover:rotate-6' : 'bg-slate-100 text-slate-400 opacity-60'}`}>
- <Cpu size={26} strokeWidth={2.5} />
- </div>
- <div className="flex gap-1 items-center bg-slate-50 p-1 rounded-xl border border-slate-100/50">
- <button
- onClick={() => handleToggleModel(model)}
- className={`p-1.5 rounded-lg transition-all ${((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? 'text-indigo-600 bg-white shadow-sm' : 'text-slate-400 hover:text-slate-600'}`}
- title={((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? t('modelEnabled') : t('modelDisabled')}
- >
- {((currentUser?.role === 'SUPER_ADMIN' ? !!model.isEnabled : enabledModelIds.includes(model.id))) ? <ToggleRight size={24} /> : <ToggleLeft size={24} />}
- </button>
- </div>
- </div>
- <div className="space-y-1 mb-6">
- <div className="flex items-center gap-2.5">
- <h4 className="font-black text-slate-900 text-lg tracking-tight truncate">{model.name}</h4>
- </div>
- <div className="flex items-center gap-2">
- <span className="text-[9px] font-black bg-indigo-50 text-indigo-600 px-2 py-0.5 rounded-lg uppercase tracking-wider border border-indigo-100/50">
- {getTypeLabel(model.type)}
- </span>
- {model.isDefault && (
- <span className="text-[9px] font-black bg-amber-50 text-amber-600 px-2 py-0.5 rounded-lg uppercase tracking-wider border border-amber-100/50 flex items-center gap-1">
- <Sparkles size={8} /> {t('defaultBadge')}
- </span>
- )}
- </div>
- <p className="text-[11px] font-mono text-slate-400 mt-2 truncate bg-slate-50 px-2 py-1 rounded-lg border border-slate-100/50 inline-block max-w-full">
- {model.modelId}
- </p>
- </div>
- {/* Additional info grid */}
- <div className="grid grid-cols-2 gap-3 mb-6">
- {model.type === ModelType.EMBEDDING && (
- <>
- <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
- <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('dims')}</span>
- <span className="text-xs font-bold text-slate-700">{model.dimensions || '-'}</span>
- </div>
- <div className="bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50">
- <span className="block text-[8px] font-black text-slate-400 uppercase tracking-widest mb-0.5">{t('ctx')}</span>
- <span className="text-xs font-bold text-slate-700">{model.maxInputTokens || '-'}</span>
- </div>
- </>
- )}
- {model.type === ModelType.LLM && (
- <div className="col-span-2 bg-slate-50/50 p-2.5 rounded-2xl border border-slate-100/50 flex items-center justify-between">
- <span className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('baseApi')}</span>
- <span className="text-[9px] font-mono font-medium text-slate-600 max-w-[140px] truncate">{model.baseUrl}</span>
- </div>
- )}
- </div>
- </div>
- <div className="flex items-center justify-between pt-4 border-t border-slate-100/50 relative z-10">
- <div className="flex items-center gap-1 text-[10px] font-bold text-slate-400">
- <SettingsIcon size={12} />
- {t('configured')}
- </div>
- <div className="flex gap-2">
- {currentUser?.role === 'SUPER_ADMIN' && (
- <>
- <button
- onClick={() => { setEditingId(model.id); setModelFormData({ ...model }); }}
- className="w-9 h-9 flex items-center justify-center rounded-xl bg-slate-50 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 hover:shadow-sm transition-all"
- >
- <Edit2 size={15} />
- </button>
- <button
- onClick={() => handleDeleteModel(model.id)}
- className="w-9 h-9 flex items-center justify-center rounded-xl bg-slate-50 text-slate-400 hover:text-red-500 hover:bg-red-50 hover:shadow-sm transition-all"
- >
- <Trash2 size={15} />
- </button>
- </>
- )}
- </div>
- </div>
- </motion.div>
- ))}
- </AnimatePresence>
- {models.length === 0 && (
- <div className="bg-white/50 border-2 border-dashed border-slate-200 rounded-3xl p-16 text-center">
- <Cpu className="w-12 h-12 text-slate-200 mx-auto mb-4" />
- <p className="text-slate-400 font-bold uppercase tracking-widest text-xs">{t('mmEmpty')}</p>
- </div>
- )}
- </div>
- )}
- </div>
- );
- return (
- <div className="flex h-full bg-[#FCFDFF] overflow-hidden relative">
- {/* Settings Sidebar */}
- <div className="w-64 bg-slate-50/50 border-r border-slate-200/60 flex flex-col shrink-0 z-20">
- <div className="p-6 pb-2">
- <h2 className="text-lg font-bold text-slate-900 tracking-tight">{t('tabSettings')}</h2>
- </div>
- <div className="flex-1 overflow-y-auto p-3 space-y-1">
- <button
- onClick={() => setActiveTab('general')}
- 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'
- }`}
- >
- <SettingsIcon size={18} />
- {t('generalSettings')}
- </button>
- {currentUser?.role === 'SUPER_ADMIN' && (
- <button
- onClick={() => setActiveTab('user')}
- 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'
- }`}
- >
- <UserCircle size={18} />
- {t('userManagement')}
- </button>
- )}
- {isAdmin && (
- <>
- <button
- onClick={() => setActiveTab('model')}
- 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'
- }`}
- >
- <HardDrive size={18} />
- {t('modelManagement')}
- </button>
- <button
- onClick={() => setActiveTab('knowledge_base')}
- 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'
- }`}
- >
- <Database size={18} />
- {t('sidebarTitle')}
- </button>
- </>
- )}
- {currentUser?.role === 'SUPER_ADMIN' && (
- <button
- onClick={() => setActiveTab('tenants')}
- 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'
- }`}
- >
- <LayoutGrid size={18} />
- {t('navTenants')}
- </button>
- )}
- </div>
- </div>
- {/* Content Area */}
- <div className="flex-1 flex flex-col min-w-0 h-full overflow-hidden relative bg-white z-10">
- <div className="px-8 pt-8 pb-6 flex items-start justify-between shrink-0">
- <div>
- <h1 className="text-2xl font-bold text-slate-900 leading-tight">
- {activeTab === 'general' ? t('generalSettings') : activeTab === 'user' ? t('userManagement') : activeTab === 'model' ? t('modelManagement') : activeTab === 'knowledge_base' ? t('sidebarTitle') : t('navTenants')}
- </h1>
- <p className="text-[15px] text-slate-500 mt-1">
- {activeTab === 'general' ? t('generalSettingsSubtitle') : activeTab === 'user' ? t('userManagementSubtitle') : activeTab === 'model' ? t('modelManagementSubtitle') : activeTab === 'knowledge_base' ? t('kbSettingsSubtitle') : t('tenantsSubtitle')}
- </p>
- </div>
- </div>
- <div className="flex-1 overflow-y-auto px-10 pb-10 scrollbar-hide">
- <div className="w-full">
- {error && (
- <motion.div
- initial={{ opacity: 0, y: -10 }}
- animate={{ opacity: 1, y: 0 }}
- 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"
- >
- <div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center shrink-0">
- <X className="w-4 h-4 text-red-600" />
- </div>
- <div>
- <span className="font-black uppercase tracking-widest text-[10px] block mb-0.5">{t('errorLabel')}</span>
- {error}
- </div>
- </motion.div>
- )}
- <AnimatePresence mode="wait">
- <motion.div
- key={activeTab}
- initial={{ opacity: 0, x: 20 }}
- animate={{ opacity: 1, x: 0 }}
- exit={{ opacity: 0, x: -20 }}
- transition={{ duration: 0.3 }}
- >
- {activeTab === 'general' && renderGeneralTab()}
- {activeTab === 'user' && currentUser?.role === 'SUPER_ADMIN' && renderUserTab()}
- {activeTab === 'model' && isAdmin && renderModelTab()}
- {activeTab === 'knowledge_base' && isAdmin && renderKnowledgeBaseTab()}
- {activeTab === 'tenants' && currentUser?.role === 'SUPER_ADMIN' && renderTenantsTab()}
- </motion.div>
- </AnimatePresence>
- </div>
- </div>
- </div>
- </div>
- );
- };
|