From 5bf59f87c07f3a969745a32b0745fb84145ec638 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 13 May 2026 17:44:13 -0500 Subject: [PATCH] feat: improve all connection types --- src/backend/ssh/terminal.ts | 1 + src/types/ui-types.ts | 8 +- src/ui/AppShell.tsx | 29 +- src/ui/auth/Auth.tsx | 21 +- src/ui/components/section-card.tsx | 2 +- src/ui/features/docker/DockerManager.tsx | 5 +- .../docker/components/ConsoleTerminal.tsx | 87 +- src/ui/features/file-manager/FileManager.tsx | 52 +- .../file-manager/FileManagerContextMenu.tsx | 22 +- .../features/file-manager/FileManagerGrid.tsx | 121 +- .../components/DraggableWindow.tsx | 91 +- .../file-manager/components/PdfPreview.tsx | 6 +- .../components/PermissionsDialog.tsx | 50 +- .../components/TerminalWindow.tsx | 19 +- .../file-manager/components/WindowManager.tsx | 21 +- src/ui/features/server-stats/ServerStats.tsx | 10 - .../server-stats/widgets/CpuWidget.tsx | 52 +- .../server-stats/widgets/DiskWidget.tsx | 52 +- .../server-stats/widgets/FirewallWidget.tsx | 2 +- .../server-stats/widgets/LoginStatsWidget.tsx | 64 +- .../server-stats/widgets/MemoryWidget.tsx | 52 +- .../server-stats/widgets/NetworkWidget.tsx | 52 +- .../server-stats/widgets/PortsWidget.tsx | 26 +- .../server-stats/widgets/ProcessesWidget.tsx | 28 +- src/ui/features/terminal/Terminal.tsx | 24 +- src/ui/features/tunnel/TunnelTab.tsx | 9 +- src/ui/lib/terminal-themes.ts | 4 +- src/ui/locales/en.json | 48 +- src/ui/shell/TabBar.tsx | 4 +- src/ui/shell/tabUtils.tsx | 4 +- src/ui/sidebar/AdminSettingsPanel.tsx | 213 ++- src/ui/sidebar/HistoryPanel.tsx | 51 +- src/ui/sidebar/HostManager.tsx | 1438 +++++++++++++---- src/ui/sidebar/QuickConnectPanel.tsx | 68 +- src/ui/sidebar/SnippetsPanel.tsx | 667 ++++++-- src/ui/sidebar/SshToolsPanel.tsx | 117 +- src/ui/sidebar/UserProfilePanel.tsx | 91 +- src/ui/ssh/connection-log/ConnectionLog.tsx | 2 +- 38 files changed, 2661 insertions(+), 952 deletions(-) diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index fb775493..1298cff8 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -1580,6 +1580,7 @@ wss.on("connection", async (ws: WebSocket, req) => { JSON.stringify({ type: "disconnected", message: "Connection lost", + graceful: true, }), ); } diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index 515b65af..aeac2a4a 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -118,6 +118,7 @@ export type Host = { telnetPassword?: string; guacamoleConfig?: Record; + forceKeyboardInteractive?: boolean; }; export type Credential = { @@ -180,6 +181,9 @@ export type Tab = { type: TabType; label: string; host?: Host; + terminalRef?: import("react").RefObject<{ + sendInput?: (data: string) => void; + } | null>; }; export type DockerContainerStatus = @@ -274,8 +278,8 @@ export type Snippet = { id: number; name: string; description?: string; - command: string; - folderId: number | null; + content: string; + folder: string | null; }; export const FOLDER_ICONS = [ diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 8c789c43..672b8e5e 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -4,7 +4,7 @@ import { Separator } from "@/components/separator"; import { Button } from "@/components/button"; import { Sheet, SheetContent } from "@/components/sheet"; import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react"; -import { useState, useRef, useCallback, useEffect } from "react"; +import { useState, useRef, useCallback, useEffect, createRef } from "react"; import { useIsMobile } from "@/hooks/use-mobile"; import { MobileBottomBar } from "@/shell/MobileBottomBar"; import { CommandPalette } from "@/shell/CommandPalette"; @@ -164,6 +164,9 @@ export function AppShell({ null, ); const lastShiftTime = useRef(0); + const terminalRefs = useRef>>( + new Map(), + ); const sidebarTitle: Record = { hosts: "Hosts", @@ -282,11 +285,15 @@ export function AppShell({ t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name, ); if (same.length === 0) { + const tabId = `${host.name}-${type}`; + const ref = type === "terminal" ? createRef() : undefined; + if (ref) terminalRefs.current.set(tabId, ref); const tab = { - id: `${host.name}-${type}`, + id: tabId, type, label: host.name, host, + terminalRef: ref, }; setActiveTabId(tab.id); return [...prev, tab]; @@ -296,11 +303,15 @@ export function AppShell({ ? { ...t, label: `${host.name} (1)`, host } : t, ); + const tabId = `${host.name}-${type}-${Date.now()}`; + const ref = type === "terminal" ? createRef() : undefined; + if (ref) terminalRefs.current.set(tabId, ref); const tab = { - id: `${host.name}-${type}-${Date.now()}`, + id: tabId, type, label: `${host.name} (${same.length + 1})`, host, + terminalRef: ref, }; setActiveTabId(tab.id); return [...next, tab]; @@ -355,6 +366,7 @@ export function AppShell({ } function closeTab(id: string) { + terminalRefs.current.delete(id); setTabs((prev) => { const next = prev.filter((t) => t.id !== id); if (id === activeTabId) setActiveTabId(next[next.length - 1].id); @@ -428,7 +440,14 @@ export function AppShell({ /> )} - {railView === "quick-connect" && } + {railView === "quick-connect" && ( + { + openTab(host, type); + if (isMobile) setSidebarOpen(false); + }} + /> + )} {railView === "ssh-tools" && (
@@ -607,7 +626,7 @@ export function AppShell({ onOpenTab={openTab} /> ) : activeTab ? ( - renderTabContent(activeTab, openSingletonTab, openTab) + renderTabContent(activeTab, openSingletonTab, openTab, closeTab) ) : null}
diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 142840d2..ed130512 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -19,6 +19,7 @@ import { getUserInfo, getRegistrationAllowed, getPasswordLoginAllowed, + getPasswordResetAllowed, getOIDCConfig, getSetupRequired, initiatePasswordReset, @@ -231,6 +232,7 @@ export function Auth({ onLogin }: AuthProps) { const [registrationAllowed, setRegistrationAllowed] = useState(true); const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); + const [passwordResetAllowed, setPasswordResetAllowed] = useState(true); const [oidcConfigured, setOidcConfigured] = useState(false); const [firstUser, setFirstUser] = useState(false); const [dbConnectionFailed, setDbConnectionFailed] = useState(false); @@ -255,6 +257,9 @@ export function Auth({ onLogin }: AuthProps) { getPasswordLoginAllowed() .then((res) => setPasswordLoginAllowed(res.allowed)) .catch(() => {}); + getPasswordResetAllowed() + .then((allowed) => setPasswordResetAllowed(allowed)) + .catch(() => setPasswordResetAllowed(false)); getOIDCConfig() .then((res) => setOidcConfigured(!!res)) .catch(() => setOidcConfigured(false)); @@ -1108,13 +1113,15 @@ export function Auth({ onLogin }: AuthProps) { {t("auth.rememberMe")} - + {passwordResetAllowed && ( + + )} - diff --git a/src/ui/features/docker/components/ConsoleTerminal.tsx b/src/ui/features/docker/components/ConsoleTerminal.tsx index 7b166fc0..7d2104ef 100644 --- a/src/ui/features/docker/components/ConsoleTerminal.tsx +++ b/src/ui/features/docker/components/ConsoleTerminal.tsx @@ -20,6 +20,12 @@ import type { SSHHost } from "@/types"; import { isElectron } from "@/main-axios.ts"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { useTranslation } from "react-i18next"; +import { + TERMINAL_THEMES, + DEFAULT_TERMINAL_CONFIG, + TERMINAL_FONTS, +} from "@/lib/terminal-themes"; +import { useTheme } from "@/components/theme-provider"; interface ConsoleTerminalProps { containerId: string; @@ -35,7 +41,35 @@ export function ConsoleTerminal({ hostConfig, }: ConsoleTerminalProps): React.ReactElement { const { t } = useTranslation(); + const { theme: appTheme } = useTheme(); const { instance: terminal, ref: xtermRef } = useXTerm(); + + const terminalConfig = React.useMemo( + () => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }), + [hostConfig.terminalConfig], + ); + + const isDarkMode = + appTheme === "dark" || + appTheme === "dracula" || + appTheme === "gentlemansChoice" || + appTheme === "midnightEspresso" || + appTheme === "catppuccinMocha" || + (appTheme === "system" && + window.matchMedia("(prefers-color-scheme: dark)").matches); + + const themeColors = React.useMemo(() => { + const activeTheme = terminalConfig.theme; + if (activeTheme === "termix") { + return isDarkMode + ? TERMINAL_THEMES.termixDark.colors + : TERMINAL_THEMES.termixLight.colors; + } + return ( + TERMINAL_THEMES[activeTheme]?.colors ?? TERMINAL_THEMES.termixDark.colors + ); + }, [terminalConfig.theme, isDarkMode]); + const [isConnected, setIsConnected] = React.useState(false); const [isConnecting, setIsConnecting] = React.useState(false); const [selectedShell, setSelectedShell] = React.useState("bash"); @@ -57,9 +91,18 @@ export function ConsoleTerminal({ terminal.loadAddon(clipboardAddon); terminal.loadAddon(webLinksAddon); - terminal.options.cursorBlink = true; - terminal.options.fontSize = 14; - terminal.options.fontFamily = "monospace"; + const fontConfig = TERMINAL_FONTS.find( + (f) => f.value === terminalConfig.fontFamily, + ); + const fontFamily = fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback; + + terminal.options.cursorBlink = terminalConfig.cursorBlink; + terminal.options.cursorStyle = terminalConfig.cursorStyle; + terminal.options.fontSize = terminalConfig.fontSize; + terminal.options.fontFamily = fontFamily; + terminal.options.scrollback = terminalConfig.scrollback; + terminal.options.letterSpacing = terminalConfig.letterSpacing; + terminal.options.lineHeight = terminalConfig.lineHeight; const readTextFromClipboard = async (): Promise => { if (window.electronClipboard) { @@ -157,16 +200,29 @@ export function ConsoleTerminal({ return true; }); - const backgroundColor = getComputedStyle(document.documentElement) - .getPropertyValue("--bg-elevated") - .trim(); - const foregroundColor = getComputedStyle(document.documentElement) - .getPropertyValue("--foreground") - .trim(); - terminal.options.theme = { - background: backgroundColor || "var(--bg-elevated)", - foreground: foregroundColor || "var(--foreground)", + background: themeColors.background, + foreground: themeColors.foreground, + cursor: themeColors.cursor, + cursorAccent: themeColors.cursorAccent, + selectionBackground: themeColors.selectionBackground, + selectionForeground: themeColors.selectionForeground, + black: themeColors.black, + red: themeColors.red, + green: themeColors.green, + yellow: themeColors.yellow, + blue: themeColors.blue, + magenta: themeColors.magenta, + cyan: themeColors.cyan, + white: themeColors.white, + brightBlack: themeColors.brightBlack, + brightRed: themeColors.brightRed, + brightGreen: themeColors.brightGreen, + brightYellow: themeColors.brightYellow, + brightBlue: themeColors.brightBlue, + brightMagenta: themeColors.brightMagenta, + brightCyan: themeColors.brightCyan, + brightWhite: themeColors.brightWhite, }; setTimeout(() => { @@ -207,7 +263,7 @@ export function ConsoleTerminal({ terminal.dispose(); }; - }, [terminal, t]); + }, [terminal, t, terminalConfig, themeColors]); const disconnect = React.useCallback(() => { if (wsRef.current) { @@ -498,7 +554,10 @@ export function ConsoleTerminal({ - +
{ @@ -2442,7 +2445,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { } return ( -
+
e.preventDefault()} > { + setTimeout(() => handleCreateNewFolder(), 0); + }} className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand" > {t("fileManager.newFolder")} { + setTimeout(() => handleCreateNewFile(), 0); + }} className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand" > @@ -2767,6 +2775,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { onSelectionChange={setSelection} currentPath={currentPath} isLoading={isLoading} + isConnected={!!sshSessionId} onPathChange={navigateTo} onRefresh={handleRefreshDirectory} onUpload={handleFilesDropped} @@ -2780,7 +2789,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { setSortOrder("asc"); } }} - onDownload={(files) => files.forEach(handleDownloadFile)} + onDownload={(files) => + files + .filter((f) => f.type === "file") + .forEach(handleDownloadFile) + } onContextMenu={handleContextMenu} viewMode={viewMode} onRename={handleRenameConfirm} @@ -2812,7 +2825,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { onClose={() => setContextMenu((prev) => ({ ...prev, isVisible: false })) } - onDownload={(files) => files.forEach(handleDownloadFile)} + onDownload={(files) => + files + .filter((f) => f.type === "file") + .forEach(handleDownloadFile) + } + onPreview={handleFileOpen} onRename={handleRenameFile} onCopy={handleCopyFiles} onCut={handleCutFiles} diff --git a/src/ui/features/file-manager/FileManagerContextMenu.tsx b/src/ui/features/file-manager/FileManagerContextMenu.tsx index 8b141b0d..fccb75fd 100644 --- a/src/ui/features/file-manager/FileManagerContextMenu.tsx +++ b/src/ui/features/file-manager/FileManagerContextMenu.tsx @@ -536,7 +536,7 @@ export function FileManagerContextMenu({ ref={menuRef} data-context-menu className={cn( - "fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-x-hidden overflow-y-auto", + "fixed bg-card border border-border rounded-none shadow-md min-w-[220px] max-w-[300px] z-[99995] overflow-x-hidden overflow-y-auto py-1", )} style={{ left: menuPosition.x, @@ -549,7 +549,7 @@ export function FileManagerContextMenu({ return (
); } @@ -558,10 +558,10 @@ export function FileManagerContextMenu({ {onClose && ( - )} @@ -2513,7 +2529,7 @@ const TerminalInner = forwardRef( diff --git a/src/ui/features/tunnel/TunnelTab.tsx b/src/ui/features/tunnel/TunnelTab.tsx index 97558abb..d2e3a1a2 100644 --- a/src/ui/features/tunnel/TunnelTab.tsx +++ b/src/ui/features/tunnel/TunnelTab.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/button"; import { Card } from "@/components/card"; -import { Separator } from "@/components/separator"; import { AlertCircle, @@ -397,7 +396,7 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) { const connectedCount = tunnels.filter((t, i) => { if (!sshHost) return false; const name = tunnelName(sshHost, i, t); - return tunnelStatuses[name]?.connected; + return tunnelStatuses[name]?.status === "connected"; }).length; return ( @@ -418,12 +417,6 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
-
- - -
{tunnels.length > 0 ? ( diff --git a/src/ui/lib/terminal-themes.ts b/src/ui/lib/terminal-themes.ts index 7bfcdcff..d4f80bc7 100644 --- a/src/ui/lib/terminal-themes.ts +++ b/src/ui/lib/terminal-themes.ts @@ -34,7 +34,7 @@ export const TERMINAL_THEMES: Record = { colors: { background: "#0c0d0b", foreground: "#f7f7f7", - cursor: "#fb923c", + cursor: "#f7f7f7", cursorAccent: "#0c0d0b", selectionBackground: "#3a3a3d", black: "#2e3436", @@ -62,7 +62,7 @@ export const TERMINAL_THEMES: Record = { colors: { background: "#0c0d0b", foreground: "#f7f7f7", - cursor: "#fb923c", + cursor: "#f7f7f7", cursorAccent: "#0c0d0b", selectionBackground: "#3a3a3d", black: "#2e3436", diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 2e1d0395..a332840f 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -3252,7 +3252,9 @@ "noTerminalSelectedHint": "Open an SSH terminal tab to view its command history", "searchPlaceholder": "Search history...", "clearAll": "Clear All", - "noHistoryEntries": "No history entries" + "noHistoryEntries": "No history entries", + "trackingDisabled": "History tracking is disabled", + "trackingDisabledHint": "Enable it in your profile settings to record commands." }, "sshTools": { "keyRecordingTitle": "Key Recording", @@ -3261,6 +3263,9 @@ "selectNone": "None", "noTerminalTabsOpen": "No terminal tabs open", "selectTerminalsAbove": "Select terminals above", + "broadcastInputPlaceholder": "Type here to broadcast keystrokes...", + "stopRecording": "Stop Recording", + "startRecording": "Start Recording", "settingsTitle": "Settings", "enableRightClickCopyPaste": "Enable right-click copy/paste" }, @@ -3307,7 +3312,33 @@ "newSnippet": "New Snippet", "newFolder": "New Folder", "run": "Run", - "noSnippetsInFolder": "No snippets in this folder" + "noSnippetsInFolder": "No snippets in this folder", + "uncategorized": "Uncategorized", + "editSnippetTitle": "Edit Snippet", + "editSnippetDescription": "Update this command snippet", + "saveSnippetButton": "Save Changes", + "createSuccess": "Snippet created successfully", + "createFailed": "Failed to create snippet", + "updateSuccess": "Snippet updated successfully", + "updateFailed": "Failed to update snippet", + "deleteFailed": "Failed to delete snippet", + "folderCreateSuccess": "Folder created successfully", + "folderCreateFailed": "Failed to create folder", + "runSuccess": "Ran \"{{name}}\" in {{count}} terminal(s)", + "copySuccess": "Copied \"{{name}}\" to clipboard", + "shareTitle": "Share Snippet", + "shareUser": "User", + "shareRole": "Role", + "selectUser": "Select a user...", + "selectRole": "Select a role...", + "shareSuccess": "Snippet shared successfully", + "shareFailed": "Failed to share snippet", + "revokeSuccess": "Access revoked", + "revokeFailed": "Failed to revoke access", + "currentAccess": "Current Access", + "shareLoadError": "Failed to load share data", + "loading": "Loading...", + "close": "Close" }, "userProfile": { "sectionAccount": "Account", @@ -3427,7 +3458,18 @@ "passwordUpdateFailed": "Failed to update password", "deletePasswordRequired": "Password is required to delete your account", "deleteFailed": "Failed to delete account", - "deleting": "Deleting..." + "deleting": "Deleting...", + "colorPickerTooltip": "Open color picker", + "themeSystem": "System", + "themeLight": "Light", + "themeDark": "Dark", + "themeDracula": "Dracula", + "themeCatppuccin": "Catppuccin", + "themeNord": "Nord", + "themeSolarized": "Solarized", + "themeTokyoNight": "Tokyo Night", + "themeOneDark": "One Dark", + "themeGruvbox": "Gruvbox" } } } diff --git a/src/ui/shell/TabBar.tsx b/src/ui/shell/TabBar.tsx index 0ac61d59..56b6b90f 100644 --- a/src/ui/shell/TabBar.tsx +++ b/src/ui/shell/TabBar.tsx @@ -68,8 +68,8 @@ export function TabBar({ const barRect = tabBarRef.current.getBoundingClientRect(); const x = Math.max( - barRect.left, - Math.min(barRect.right - d.width - 4, e.clientX - d.offsetX), + barRect.left + 2, + Math.min(barRect.right - d.width - 6, e.clientX - d.offsetX), ); const y = d.barTop; setDragPos({ x, y }); diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index e5e3639e..1d22c112 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -113,6 +113,7 @@ export function renderTabContent( tab: Tab, onOpenSingletonTab?: (type: TabType) => void, onOpenTab?: (host: Host, type: TabType) => void, + onCloseTab?: (id: string) => void, ) { const { host, label } = tab; @@ -136,6 +137,7 @@ export function renderTabContent( return ( {}} + onClose={() => onCloseTab?.(tab.id)} /> ); diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index b1e57cde..f17bf316 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -32,8 +32,11 @@ import { updateOIDCConfig, disableOIDCConfig, isElectron, + getUserRoles, + assignRoleToUser, + removeRoleFromUser, } from "@/main-axios"; -import type { ApiKey, CreatedApiKey } from "@/main-axios"; +import type { ApiKey, CreatedApiKey, UserRole } from "@/main-axios"; import type React from "react"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; @@ -154,6 +157,8 @@ export function AdminSettingsPanel() { const [editUserOpen, setEditUserOpen] = useState(false); const [editUserTarget, setEditUserTarget] = useState(null); const [editUserLoading, setEditUserLoading] = useState(false); + const [editUserRoles, setEditUserRoles] = useState([]); + const [editUserRolesLoading, setEditUserRolesLoading] = useState(false); // Link account dialog const [linkAccountOpen, setLinkAccountOpen] = useState(false); @@ -196,6 +201,17 @@ export function AdminSettingsPanel() { loadOidcConfig(); }, []); + useEffect(() => { + if (editUserOpen && editUserTarget) { + setEditUserRoles([]); + setEditUserRolesLoading(true); + getUserRoles(editUserTarget.id) + .then(({ roles: r }) => setEditUserRoles(r)) + .catch(() => {}) + .finally(() => setEditUserRolesLoading(false)); + } + }, [editUserOpen, editUserTarget?.id]); + function loadUsers() { getUserList() .then(({ users: u }) => setUsers(u)) @@ -257,18 +273,21 @@ export function AdminSettingsPanel() { try { const config = await getAdminOIDCConfig(); if (!config) return; - setOidcClientId((config.clientId as string) ?? ""); - setOidcClientSecret((config.clientSecret as string) ?? ""); - setOidcAuthUrl((config.authorizationUrl as string) ?? ""); - setOidcIssuerUrl((config.issuerUrl as string) ?? ""); - setOidcTokenUrl((config.tokenUrl as string) ?? ""); - setOidcUserIdentifier((config.userIdentifierPath as string) ?? "sub"); - setOidcDisplayName((config.displayNamePath as string) ?? "name"); + setOidcClientId((config.client_id as string) ?? ""); + setOidcClientSecret((config.client_secret as string) ?? ""); + setOidcAuthUrl((config.authorization_url as string) ?? ""); + setOidcIssuerUrl((config.issuer_url as string) ?? ""); + setOidcTokenUrl((config.token_url as string) ?? ""); + setOidcUserIdentifier((config.identifier_path as string) ?? "sub"); + setOidcDisplayName((config.name_path as string) ?? "name"); setOidcScopes((config.scopes as string) ?? "openid email profile"); - setOidcUserinfoUrl((config.overrideUserinfoUrl as string) ?? ""); + setOidcUserinfoUrl((config.userinfo_url as string) ?? ""); setOidcAllowedUsers( - Array.isArray(config.allowedUsers) - ? (config.allowedUsers as string[]).join("\n") + typeof config.allowed_users === "string" + ? (config.allowed_users as string) + .split(",") + .filter(Boolean) + .join("\n") : "", ); } catch { @@ -378,18 +397,18 @@ export function AdminSettingsPanel() { setOidcSaving(true); try { await updateOIDCConfig({ - clientId: oidcClientId, - clientSecret: oidcClientSecret, - authorizationUrl: oidcAuthUrl, - issuerUrl: oidcIssuerUrl, - tokenUrl: oidcTokenUrl, - userIdentifierPath: oidcUserIdentifier, - displayNamePath: oidcDisplayName, + client_id: oidcClientId, + client_secret: oidcClientSecret, + authorization_url: oidcAuthUrl, + issuer_url: oidcIssuerUrl, + token_url: oidcTokenUrl, + identifier_path: oidcUserIdentifier, + name_path: oidcDisplayName, scopes: oidcScopes, - overrideUserinfoUrl: oidcUserinfoUrl || null, - allowedUsers: oidcAllowedUsers - ? oidcAllowedUsers.split("\n").filter(Boolean) - : [], + userinfo_url: oidcUserinfoUrl || "", + allowed_users: oidcAllowedUsers + ? oidcAllowedUsers.split("\n").filter(Boolean).join(",") + : "", }); toast.success("OIDC configuration saved"); } catch (e: any) { @@ -497,18 +516,19 @@ export function AdminSettingsPanel() { return; } setCreateRoleLoading(true); + const displayName = newRoleDisplayName.trim(); try { - const { role } = await createRole({ + await createRole({ name: newRoleName.trim(), - displayName: newRoleDisplayName.trim(), + displayName, description: newRoleDescription.trim() || null, }); - setRoles((prev) => [...prev, role]); setShowCreateRole(false); setNewRoleName(""); setNewRoleDisplayName(""); setNewRoleDescription(""); - toast.success(`Role "${role.displayName}" created`); + toast.success(`Role "${displayName}" created`); + loadRoles(); } catch (e: any) { toast.error(e?.response?.data?.error || "Failed to create role"); } finally { @@ -532,7 +552,7 @@ export function AdminSettingsPanel() { newKeyUserId.trim(), newKeyExpiry ? new Date(newKeyExpiry).toISOString() : undefined, ); - setApiKeys((prev) => [created, ...prev]); + setApiKeys((prev) => [{ ...created, isActive: true }, ...prev]); setCreatedKeyToken(created.token); setNewKeyName(""); setNewKeyUserId(""); @@ -682,7 +702,7 @@ export function AdminSettingsPanel() {
- setNewKeyUserId(e.target.value)} - className="text-xs font-mono" - /> - {users.length > 0 && ( - - )} + > + + {users.map((u) => ( + + ))} +
+
+ Roles + {editUserRolesLoading ? ( + + Loading... + + ) : ( + <> + {editUserRoles.length > 0 && ( +
+ {editUserRoles.map((ur) => { + const roleInfo = roles.find( + (r) => r.id === ur.roleId, + ); + const isSystem = roleInfo?.isSystem ?? false; + return ( + + {ur.roleDisplayName} + {!isSystem && ( + + )} + + ); + })} +
+ )} + {roles.filter( + (r) => + !r.isSystem && + !editUserRoles.some((ur) => ur.roleId === r.id), + ).length > 0 && ( +
+ + Add role + +
+ {roles + .filter( + (r) => + !r.isSystem && + !editUserRoles.some((ur) => ur.roleId === r.id), + ) + .map((r) => ( + + ))} +
+
+ )} + {editUserRoles.length === 0 && + roles.filter((r) => !r.isSystem).length === 0 && ( + + No custom roles defined + + )} + + )} +
diff --git a/src/ui/sidebar/HistoryPanel.tsx b/src/ui/sidebar/HistoryPanel.tsx index 57655031..a75d71ad 100644 --- a/src/ui/sidebar/HistoryPanel.tsx +++ b/src/ui/sidebar/HistoryPanel.tsx @@ -3,7 +3,11 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Copy, Search, Terminal, Trash2 } from "lucide-react"; -import { getCommandHistory, deleteCommandFromHistory } from "@/main-axios"; +import { + getCommandHistory, + deleteCommandFromHistory, + clearCommandHistory, +} from "@/main-axios"; import type { Tab } from "@/types/ui-types"; export function HistoryPanel({ @@ -16,20 +20,51 @@ export function HistoryPanel({ const { t } = useTranslation(); const [search, setSearch] = useState(""); const [commands, setCommands] = useState([]); + const [trackingEnabled, setTrackingEnabled] = useState( + () => localStorage.getItem("commandHistoryTracking") === "true", + ); const activeTab = terminalTabs.find((t) => t.id === activeTabId); const activeIsTerminal = !!activeTab; const hostId = activeTab?.host?.id ? parseInt(activeTab.host.id, 10) : null; useEffect(() => { - if (!hostId) { + const handler = () => + setTrackingEnabled( + localStorage.getItem("commandHistoryTracking") === "true", + ); + window.addEventListener("commandHistoryTrackingChanged", handler); + return () => + window.removeEventListener("commandHistoryTrackingChanged", handler); + }, []); + + useEffect(() => { + if (!hostId || !trackingEnabled) { setCommands([]); return; } getCommandHistory(hostId) .then(setCommands) .catch(() => setCommands([])); - }, [hostId]); + }, [hostId, trackingEnabled]); + + if (activeIsTerminal && !trackingEnabled) { + return ( +
+
+ +
+
+ + {t("newUi.sidebar.history.trackingDisabled")} + + + {t("newUi.sidebar.history.trackingDisabledHint")} + +
+
+ ); + } if (!activeIsTerminal) { return ( @@ -85,7 +120,15 @@ export function HistoryPanel({ {filtered.length} command{filtered.length !== 1 ? "s" : ""}
)} - } - > -
- - setField("useSocks5", v)} - /> - - {form.useSocks5 && ( -
-
- - setField("socks5Host", e.target.value)} - /> -
-
- - - setField("socks5Port", Number(e.target.value) as any) - } - /> -
-
- - - setField("socks5Username", e.target.value) - } - /> -
-
- - - setField("socks5Password", e.target.value) - } - /> -
-
- )} -
-
- - Jump Host Chain - - -
- {form.jumpHosts.length === 0 && ( -

- No jump hosts configured. -

- )} -
- {form.jumpHosts.map((jh, i) => ( -
- - {i + 1}. - - - -
- ))} -
-
-
-
- } @@ -1328,6 +1277,147 @@ function HostEditor({
+ + } + > +
+ + setField("useSocks5", v)} + /> + + {form.useSocks5 && ( +
+
+ + setField("socks5Host", e.target.value)} + /> +
+
+ + + setField("socks5Port", Number(e.target.value) as any) + } + /> +
+
+ + + setField("socks5Username", e.target.value) + } + /> +
+
+ + + setField("socks5Password", e.target.value) + } + /> +
+
+ )} +
+
+ + Jump Host Chain + + +
+ {form.jumpHosts.length === 0 && ( +

+ No jump hosts configured. +

+ )} +
+ {form.jumpHosts.map((jh, i) => ( +
+ + {i + 1}. + + + +
+ ))} +
+
+
+
)} @@ -1456,7 +1546,10 @@ function HostEditor({ label="Force Keyboard Interactive" description="Force manual password entry even if keys are present" > - + setField("forceKeyboardInteractive", v)} + />
@@ -1533,10 +1626,11 @@ function HostEditor({ onChange={(e) => setField("theme", e.target.value)} className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" > - - - - + {Object.entries(TERMINAL_THEMES).map(([key, t]) => ( + + ))}
@@ -1548,9 +1642,11 @@ function HostEditor({ onChange={(e) => setField("fontFamily", e.target.value)} className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono" > - - - + {TERMINAL_FONTS.map((f) => ( + + ))}
@@ -1577,9 +1673,71 @@ function HostEditor({ } className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" > - - - + {CURSOR_STYLES.map((s) => ( + + ))} + +
+
+ + + setField("letterSpacing", Number(e.target.value) as any) + } + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+
+ + + setField("lineHeight", Number(e.target.value) as any) + } + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+
+ + +
+
+ +
@@ -1592,6 +1750,15 @@ function HostEditor({ onChange={(v) => setField("cursorBlink", v)} /> + + setField("rightClickSelectsWord", v)} + /> + @@ -1732,6 +1899,78 @@ function HostEditor({ ))} +
+
+ + +
+
+ + + setField( + "fastScrollSensitivity", + Number(e.target.value) as any, + ) + } + className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+
+ {form.autoMosh && ( +
+ + setField("moshCommand", e.target.value)} + /> +
+ )} +
+
+ + +
+
)} +
+ + { + const updated = [...form.serverTunnels]; + updated[i] = { + ...updated[i], + bindHost: e.target.value, + }; + setField("serverTunnels", updated); + }} + /> +
- + setGuacField("height", e.target.value)} + />
- + setGuacField("dpi", e.target.value)} + />
- + setGuacField("resize-method", e.target.value) + } + > @@ -2449,7 +2742,10 @@ function HostEditor({ label="Force Lossless" description="Force lossless image encoding (higher quality, more bandwidth)" > - + setGuacField("force-lossless", v)} + />
@@ -2463,13 +2759,19 @@ function HostEditor({ label="Disable Audio" description="Mute all audio from the remote session" > - + setGuacField("disable-audio", v)} + /> - + setGuacField("enable-audio-input", v)} + /> @@ -2483,61 +2785,101 @@ function HostEditor({ label="Wallpaper" description="Show desktop wallpaper (disabling improves performance)" > - + setGuacField("enable-wallpaper", v)} + /> - + setGuacField("enable-theming", v)} + /> - + setGuacField("enable-font-smoothing", v)} + /> - + setGuacField("enable-full-window-drag", v)} + /> - + + setGuacField("enable-desktop-composition", v) + } + /> - + setGuacField("enable-menu-animations", v)} + /> - + setGuacField("disable-bitmap-caching", v)} + /> - + + setGuacField("disable-offscreen-caching", v) + } + /> - + setGuacField("disable-glyph-caching", v)} + /> - + setGuacField("enable-gfx", v)} + /> @@ -2551,51 +2893,81 @@ function HostEditor({ label="Enable Printing" description="Redirect local printers to the remote session" > - + setGuacField("enable-printing", v)} + /> - + setGuacField("enable-drive", v)} + />
- + + setGuacField("drive-name", e.target.value) + } + />
- + + setGuacField("drive-path", e.target.value) + } + />
- + setGuacField("create-drive-path", v)} + /> - + setGuacField("disable-download", v)} + /> - + setGuacField("disable-upload", v)} + /> - + setGuacField("enable-touch", v)} + /> @@ -2606,25 +2978,46 @@ function HostEditor({ - + + setGuacField("client-name", e.target.value) + } + /> - + setGuacField("console", v)} + />
- + + setGuacField("initial-program", e.target.value) + } + />
- + setGuacField("server-layout", e.target.value) + } + > @@ -2642,7 +3035,11 @@ function HostEditor({ - + setGuacField("timezone", e.target.value)} + />
@@ -2657,31 +3054,63 @@ function HostEditor({ - + + setGuacField("gateway-hostname", e.target.value) + } + />
- + + setGuacField("gateway-port", e.target.value) + } + />
- + + setGuacField("gateway-username", e.target.value) + } + />
- + + setGuacField("gateway-password", e.target.value) + } + />
- + + setGuacField("gateway-domain", e.target.value) + } + />
@@ -2696,19 +3125,35 @@ function HostEditor({ - + setGuacField("remote-app", e.target.value)} + />
- + + setGuacField("remote-app-dir", e.target.value) + } + />
- + + setGuacField("remote-app-args", e.target.value) + } + />
@@ -2719,7 +3164,15 @@ function HostEditor({ - + setGuacField("normalize-clipboard", e.target.value) + } + > @@ -2730,13 +3183,19 @@ function HostEditor({ label="Disable Copy" description="Prevent copying text from the remote session" > - + setGuacField("disable-copy", v)} + /> - + setGuacField("disable-paste", v)} + /> @@ -2750,37 +3209,63 @@ function HostEditor({ - + + setGuacField("recording-path", e.target.value) + } + />
- + + setGuacField("recording-name", e.target.value) + } + />
- + setGuacField("create-recording-path", v)} + /> - + + setGuacField("recording-exclude-output", v) + } + /> - + setGuacField("recording-exclude-mouse", v)} + /> - + setGuacField("recording-include-keys", v)} + /> @@ -2794,7 +3279,10 @@ function HostEditor({ label="Send WOL Packet" description="Send a magic packet to wake this host before connecting" > - + setGuacField("wol-send-packet", v)} + />
@@ -2803,26 +3291,53 @@ function HostEditor({ + setGuacField("wol-mac-addr", e.target.value) + } />
- + + setGuacField("wol-broadcast-addr", e.target.value) + } + />
- + + setGuacField("wol-udp-port", e.target.value) + } + />
- + + setGuacField("wol-wait-time", e.target.value) + } + />
@@ -2891,7 +3406,13 @@ function HostEditor({ - + setGuacField("color-depth", e.target.value) + } + > @@ -2904,20 +3425,36 @@ function HostEditor({ - + setGuacField("width", e.target.value)} + />
- + setGuacField("height", e.target.value)} + />
- + setGuacField("resize-method", e.target.value) + } + > @@ -2927,7 +3464,10 @@ function HostEditor({ label="Force Lossless" description="Force lossless image encoding (higher quality, more bandwidth)" > - + setGuacField("force-lossless", v)} + />
@@ -2941,7 +3481,10 @@ function HostEditor({ label="Disable Audio" description="Mute all audio from the remote session" > - + setGuacField("disable-audio", v)} + /> @@ -2955,7 +3498,11 @@ function HostEditor({ - setGuacField("cursor", e.target.value)} + > @@ -2965,13 +3512,19 @@ function HostEditor({ label="Swap Red/Blue" description="Swap the red and blue color channels (fixes some colour issues)" > - + setGuacField("swap-red-blue", v)} + /> - + setGuacField("read-only", v)} + /> @@ -2982,7 +3535,15 @@ function HostEditor({ - + setGuacField("normalize-clipboard", e.target.value) + } + > @@ -2993,13 +3554,19 @@ function HostEditor({ label="Disable Copy" description="Prevent copying text from the remote session" > - + setGuacField("disable-copy", v)} + /> - + setGuacField("disable-paste", v)} + /> @@ -3013,37 +3580,63 @@ function HostEditor({ - + + setGuacField("recording-path", e.target.value) + } + />
- + + setGuacField("recording-name", e.target.value) + } + />
- + setGuacField("create-recording-path", v)} + /> - + + setGuacField("recording-exclude-output", v) + } + /> - + setGuacField("recording-exclude-mouse", v)} + /> - + setGuacField("recording-include-keys", v)} + /> @@ -3057,7 +3650,10 @@ function HostEditor({ label="Send WOL Packet" description="Send a magic packet to wake this host before connecting" > - + setGuacField("wol-send-packet", v)} + />
@@ -3066,26 +3662,53 @@ function HostEditor({ + setGuacField("wol-mac-addr", e.target.value) + } />
- + + setGuacField("wol-broadcast-addr", e.target.value) + } + />
- + + setGuacField("wol-udp-port", e.target.value) + } + />
- + + setGuacField("wol-wait-time", e.target.value) + } + />
@@ -3155,13 +3778,23 @@ function HostEditor({ - + setGuacField("width", e.target.value)} + />
- + setGuacField("height", e.target.value)} + />
@@ -3176,7 +3809,13 @@ function HostEditor({ - + setGuacField("terminal-type", e.target.value) + } + > @@ -3188,19 +3827,35 @@ function HostEditor({ - + setGuacField("font-name", e.target.value)} + />
- + + setGuacField("font-size", Number(e.target.value)) + } + />
- + setGuacField("color-scheme", e.target.value) + } + > @@ -3212,7 +3867,11 @@ function HostEditor({ - setGuacField("backspace", e.target.value)} + > @@ -3230,31 +3889,54 @@ function HostEditor({ - + + setGuacField("recording-path", e.target.value) + } + />
- + + setGuacField("recording-name", e.target.value) + } + />
- + setGuacField("create-recording-path", v)} + /> - + + setGuacField("recording-exclude-output", v) + } + /> - + setGuacField("recording-include-keys", v)} + /> @@ -3828,6 +4510,19 @@ function CredentialEditorView({ ? "Generating..." : "Generate from Private Key"} +