import React, {useState} from 'react'; import { Computer, Server, File, Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings, Menu, ChevronRight } from "lucide-react"; import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarInset, SidebarHeader, } from "@/components/ui/sidebar.tsx" import { Separator, } from "@/components/ui/separator.tsx" import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu"; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from "@/components/ui/sheet"; import {Checkbox} from "@/components/ui/checkbox.tsx"; import {Input} from "@/components/ui/input.tsx"; import {Label} from "@/components/ui/label.tsx"; import {Button} from "@/components/ui/button.tsx"; import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx"; import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table.tsx"; import axios from "axios"; import {Card} from "@/components/ui/card.tsx"; import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx"; import {getSSHHosts} from "@/ui/SSH/ssh-axios"; import { useTabs } from "@/contexts/TabContext"; interface SSHHost { id: number; name: string; ip: string; port: number; username: string; folder: string; tags: string[]; pin: boolean; authType: string; password?: string; key?: string; keyPassword?: string; keyType?: string; enableTerminal: boolean; enableTunnel: boolean; enableConfigEditor: boolean; defaultPath: string; tunnelConnections: any[]; createdAt: string; updatedAt: string; } interface SidebarProps { onSelectView: (view: string) => void; getView?: () => string; disabled?: boolean; isAdmin?: boolean; username?: string | null; children?: React.ReactNode; } function handleLogout() { document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; window.location.reload(); } function getCookie(name: string) { return document.cookie.split('; ').reduce((r, v) => { const parts = v.split('='); return parts[0] === name ? decodeURIComponent(parts[1]) : r; }, ""); } const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users"; const API = axios.create({ baseURL: apiBase, }); export function LeftSidebar({ onSelectView, getView, disabled, isAdmin, username, children, }: SidebarProps): React.ReactElement { const [adminSheetOpen, setAdminSheetOpen] = React.useState(false); const [allowRegistration, setAllowRegistration] = React.useState(true); const [regLoading, setRegLoading] = React.useState(false); const [oidcConfig, setOidcConfig] = React.useState({ client_id: '', client_secret: '', issuer_url: '', authorization_url: '', token_url: '', identifier_path: 'sub', name_path: 'name', scopes: 'openid email profile' }); const [oidcLoading, setOidcLoading] = React.useState(false); const [oidcError, setOidcError] = React.useState(null); const [oidcSuccess, setOidcSuccess] = React.useState(null); const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false); const [deletePassword, setDeletePassword] = React.useState(""); const [deleteLoading, setDeleteLoading] = React.useState(false); const [deleteError, setDeleteError] = React.useState(null); const [adminCount, setAdminCount] = React.useState(0); const [users, setUsers] = React.useState>([]); const [usersLoading, setUsersLoading] = React.useState(false); const [newAdminUsername, setNewAdminUsername] = React.useState(""); const [makeAdminLoading, setMakeAdminLoading] = React.useState(false); const [makeAdminError, setMakeAdminError] = React.useState(null); const [makeAdminSuccess, setMakeAdminSuccess] = React.useState(null); const [isSidebarOpen, setIsSidebarOpen] = useState(true); // Tabs context for opening SSH Manager tab const { tabs: tabList, addTab, setCurrentTab, allSplitScreenTab } = useTabs() as any; const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0; const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager'); const openSshManagerTab = () => { if (sshManagerTab || isSplitScreenActive) return; const id = addTab({ type: 'ssh_manager', title: 'SSH Manager' } as any); setCurrentTab(id); }; // SSH Hosts state management const [hosts, setHosts] = useState([]); const [hostsLoading, setHostsLoading] = useState(false); const [hostsError, setHostsError] = useState(null); const prevHostsRef = React.useRef([]); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); React.useEffect(() => { if (adminSheetOpen) { const jwt = getCookie("jwt"); if (jwt && isAdmin) { API.get("/oidc-config").then(res => { if (res.data) { setOidcConfig(res.data); } }).catch((error) => { }); fetchUsers(); } } else { const jwt = getCookie("jwt"); if (jwt && isAdmin) { fetchAdminCount(); } } }, [adminSheetOpen, isAdmin]); React.useEffect(() => { if (!isAdmin) { setAdminSheetOpen(false); setUsers([]); setAdminCount(0); } }, [isAdmin]); // SSH Hosts data fetching const fetchHosts = React.useCallback(async () => { try { const newHosts = await getSSHHosts(); const terminalHosts = newHosts.filter(host => host.enableTerminal); const prevHosts = prevHostsRef.current; // Create a stable map of existing hosts by ID for comparison const existingHostsMap = new Map(prevHosts.map(h => [h.id, h])); const newHostsMap = new Map(terminalHosts.map(h => [h.id, h])); // Check if there are any meaningful changes let hasChanges = false; // Check for new hosts, removed hosts, or changed hosts if (terminalHosts.length !== prevHosts.length) { hasChanges = true; } else { for (const [id, newHost] of newHostsMap) { const existingHost = existingHostsMap.get(id); if (!existingHost) { hasChanges = true; break; } // Only check fields that affect the display if ( newHost.name !== existingHost.name || newHost.folder !== existingHost.folder || newHost.ip !== existingHost.ip || newHost.port !== existingHost.port || newHost.username !== existingHost.username || newHost.pin !== existingHost.pin || newHost.enableTerminal !== existingHost.enableTerminal || JSON.stringify(newHost.tags) !== JSON.stringify(existingHost.tags) ) { hasChanges = true; break; } } } if (hasChanges) { // Use a small delay to batch updates and reduce jittering setTimeout(() => { setHosts(terminalHosts); prevHostsRef.current = terminalHosts; }, 50); } } catch (err: any) { setHostsError('Failed to load hosts'); } }, []); React.useEffect(() => { fetchHosts(); const interval = setInterval(fetchHosts, 10000); return () => clearInterval(interval); }, [fetchHosts]); // Search debouncing React.useEffect(() => { const handler = setTimeout(() => setDebouncedSearch(search), 200); return () => clearTimeout(handler); }, [search]); // Filter and organize hosts with stable references const filteredHosts = React.useMemo(() => { if (!debouncedSearch.trim()) return hosts; const q = debouncedSearch.trim().toLowerCase(); return hosts.filter(h => { const searchableText = [ h.name || '', h.username, h.ip, h.folder || '', ...(h.tags || []), h.authType, h.defaultPath || '' ].join(' ').toLowerCase(); return searchableText.includes(q); }); }, [hosts, debouncedSearch]); const hostsByFolder = React.useMemo(() => { const map: Record = {}; filteredHosts.forEach(h => { const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder'; if (!map[folder]) map[folder] = []; map[folder].push(h); }); return map; }, [filteredHosts]); const sortedFolders = React.useMemo(() => { const folders = Object.keys(hostsByFolder); folders.sort((a, b) => { if (a === 'No Folder') return -1; if (b === 'No Folder') return 1; return a.localeCompare(b); }); return folders; }, [hostsByFolder]); const getSortedHosts = React.useCallback((arr: SSHHost[]) => { const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || '')); const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || '')); return [...pinned, ...rest]; }, []); const handleToggle = async (checked: boolean) => { if (!isAdmin) { return; } setRegLoading(true); const jwt = getCookie("jwt"); try { await API.patch( "/registration-allowed", {allowed: checked}, {headers: {Authorization: `Bearer ${jwt}`}} ); setAllowRegistration(checked); } catch (e) { } finally { setRegLoading(false); } }; const handleOIDCConfigSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!isAdmin) { return; } setOidcLoading(true); setOidcError(null); setOidcSuccess(null); const requiredFields = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url']; const missingFields = requiredFields.filter(field => !oidcConfig[field as keyof typeof oidcConfig]); if (missingFields.length > 0) { setOidcError(`Missing required fields: ${missingFields.join(', ')}`); setOidcLoading(false); return; } const jwt = getCookie("jwt"); try { await API.post( "/oidc-config", oidcConfig, {headers: {Authorization: `Bearer ${jwt}`}} ); setOidcSuccess("OIDC configuration updated successfully!"); } catch (err: any) { setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration"); } finally { setOidcLoading(false); } }; const handleOIDCConfigChange = (field: string, value: string) => { setOidcConfig(prev => ({ ...prev, [field]: value })); }; const handleDeleteAccount = async (e: React.FormEvent) => { e.preventDefault(); setDeleteLoading(true); setDeleteError(null); if (!deletePassword.trim()) { setDeleteError("Password is required"); setDeleteLoading(false); return; } const jwt = getCookie("jwt"); try { await API.delete("/delete-account", { headers: {Authorization: `Bearer ${jwt}`}, data: {password: deletePassword} }); handleLogout(); } catch (err: any) { setDeleteError(err?.response?.data?.error || "Failed to delete account"); setDeleteLoading(false); } }; const fetchUsers = async () => { const jwt = getCookie("jwt"); if (!jwt || !isAdmin) { return; } setUsersLoading(true); try { const response = await API.get("/list", { headers: {Authorization: `Bearer ${jwt}`} }); setUsers(response.data.users); const adminUsers = response.data.users.filter((user: any) => user.is_admin); setAdminCount(adminUsers.length); } catch (err: any) { console.error("Failed to fetch users:", err); } finally { setUsersLoading(false); } }; const fetchAdminCount = async () => { const jwt = getCookie("jwt"); if (!jwt || !isAdmin) { return; } try { const response = await API.get("/list", { headers: {Authorization: `Bearer ${jwt}`} }); const adminUsers = response.data.users.filter((user: any) => user.is_admin); setAdminCount(adminUsers.length); } catch (err: any) { console.error("Failed to fetch admin count:", err); } }; const makeUserAdmin = async (e: React.FormEvent) => { e.preventDefault(); if (!newAdminUsername.trim()) return; if (!isAdmin) { return; } setMakeAdminLoading(true); setMakeAdminError(null); setMakeAdminSuccess(null); const jwt = getCookie("jwt"); try { await API.post("/make-admin", {username: newAdminUsername.trim()}, {headers: {Authorization: `Bearer ${jwt}`}} ); setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`); setNewAdminUsername(""); fetchUsers(); } catch (err: any) { setMakeAdminError(err?.response?.data?.error || "Failed to make user admin"); } finally { setMakeAdminLoading(false); } }; const removeAdminStatus = async (username: string) => { if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return; if (!isAdmin) { return; } const jwt = getCookie("jwt"); try { await API.post("/remove-admin", {username}, {headers: {Authorization: `Bearer ${jwt}`}} ); fetchUsers(); } catch (err: any) { console.error("Failed to remove admin status:", err); } }; const deleteUser = async (username: string) => { if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return; if (!isAdmin) { return; } const jwt = getCookie("jwt"); try { await API.delete("/delete-user", { headers: {Authorization: `Bearer ${jwt}`}, data: {username} }); fetchUsers(); } catch (err: any) { console.error("Failed to delete user:", err); } }; return (
Termix {/* Search Input */}
setSearch(e.target.value)} placeholder="Search hosts by any info..." className="w-full h-8 text-sm border-2 border-[#272728] rounded-lg" autoComplete="off" />
{/* Error Display */} {hostsError && (
{hostsError}
)} {/* Loading State */} {hostsLoading && (
Loading hosts...
)} {/* Hosts by Folder */} {sortedFolders.map((folder, idx) => ( ))}
{username ? username : 'Signed out'} {isAdmin && ( { if (isAdmin) { setAdminSheetOpen(true); } }}> Admin Settings )} Sign out setDeleteAccountOpen(true)} disabled={isAdmin && adminCount <= 1} > Delete Account {isAdmin && adminCount <= 1 && " (Last Admin)"} {/* Admin Settings Sheet */} {isAdmin && ( { if (open && !isAdmin) return; setAdminSheetOpen(open); }}> Admin Settings
Reg OIDC Users Admins {/* Registration Settings Tab */}

User Registration

{/* OIDC Configuration Tab */}

External Authentication (OIDC)

Configure external identity provider for OIDC/OAuth2 authentication. Users will see an "External" login option once configured.

{oidcError && ( Error {oidcError} )}
handleOIDCConfigChange('client_id', e.target.value)} placeholder="your-client-id" required />
handleOIDCConfigChange('client_secret', e.target.value)} placeholder="your-client-secret" required />
handleOIDCConfigChange('authorization_url', e.target.value)} placeholder="https://your-provider.com/application/o/authorize/" required />
handleOIDCConfigChange('issuer_url', e.target.value)} placeholder="https://your-provider.com/application/o/termix/" required />
handleOIDCConfigChange('token_url', e.target.value)} placeholder="https://your-provider.com/application/o/token/" required />
handleOIDCConfigChange('identifier_path', e.target.value)} placeholder="sub" required />

JSON path to extract user ID from JWT (e.g., "sub", "email", "preferred_username")

handleOIDCConfigChange('name_path', e.target.value)} placeholder="name" required />

JSON path to extract display name from JWT (e.g., "name", "preferred_username")

handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)} placeholder="openid email profile" required />

Space-separated list of OAuth2 scopes to request

{oidcSuccess && ( Success {oidcSuccess} )}
{/* Users Management Tab */}

User Management

{usersLoading ? (
Loading users...
) : (
Username Type Actions {users.map((user) => ( {user.username} {user.is_admin && ( Admin )} {user.is_oidc ? "External" : "Local"} ))}
)}
{/* Admins Management Tab */}

Admin Management

{/* Add New Admin Form */}

Make User Admin

setNewAdminUsername(e.target.value)} placeholder="Enter username to make admin" required />
{makeAdminError && ( Error {makeAdminError} )} {makeAdminSuccess && ( Success {makeAdminSuccess} )}
{/* Current Admins Table */}

Current Admins

Username Type Actions {users.filter(user => user.is_admin).map((admin) => ( {admin.username} Admin {admin.is_oidc ? "External" : "Local"} ))}
)} {/* Delete Account Confirmation Sheet */} Delete Account This action cannot be undone. This will permanently delete your account and all associated data.
Warning Deleting your account will remove all your data including SSH hosts, configurations, and settings. This action is irreversible. {deleteError && ( Error {deleteError} )}
{isAdmin && adminCount <= 1 && ( Cannot Delete Account You are the last admin user. You cannot delete your account as this would leave the system without any administrators. Please make another user an admin first, or contact system support. )}
setDeletePassword(e.target.value)} placeholder="Enter your password to confirm" required disabled={isAdmin && adminCount <= 1} />
{children}
{!isSidebarOpen && (
setIsSidebarOpen(true)} className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md">
)}
) }