From 170080399b440fcc826efcb3fe51b264736f88ac Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 20 May 2026 22:55:16 -0500 Subject: [PATCH] feat: seperated host manager into 2 rail tabs, electron fixes for auth, shared credential fixes --- .../credential-system-encryption-migration.ts | 9 +- src/main.tsx | 8 +- src/ui/AppShell.tsx | 86 ++++++------ src/ui/auth/Auth.tsx | 27 ++-- src/ui/locales/en.json | 6 +- src/ui/main-axios.ts | 9 ++ src/ui/shell/MobileBottomBar.tsx | 5 + src/ui/sidebar/AppRail.tsx | 6 + src/ui/sidebar/CredentialsPanel.tsx | 66 +++++++++ src/ui/sidebar/HostManager.tsx | 127 ++++++++++++------ src/ui/sidebar/HostsPanel.tsx | 94 ++++++------- src/ui/sidebar/SidebarTree.tsx | 2 +- 12 files changed, 289 insertions(+), 156 deletions(-) create mode 100644 src/ui/sidebar/CredentialsPanel.tsx diff --git a/src/backend/utils/credential-system-encryption-migration.ts b/src/backend/utils/credential-system-encryption-migration.ts index 5fb48e92..a749ae29 100644 --- a/src/backend/utils/credential-system-encryption-migration.ts +++ b/src/backend/utils/credential-system-encryption-migration.ts @@ -1,5 +1,5 @@ import { db } from "../database/db/index.js"; -import { sshCredentials } from "../database/db/schema.js"; +import { sshCredentials, sharedCredentials } from "../database/db/schema.js"; import { eq, and, or, isNull } from "drizzle-orm"; import { DataCrypto } from "./data-crypto.js"; import { SystemCrypto } from "./system-crypto.js"; @@ -64,7 +64,7 @@ export class CredentialSystemEncryptionMigration { cred.keyPassword, userDEK, cred.id.toString(), - "key_password", + "keyPassword", ) : null; @@ -105,6 +105,11 @@ export class CredentialSystemEncryptionMigration { }) .where(eq(sshCredentials.id, cred.id)); + await db + .update(sharedCredentials) + .set({ needsReEncryption: true }) + .where(eq(sharedCredentials.originalCredentialId, cred.id)); + migrated++; } catch (error) { databaseLogger.warn( diff --git a/src/main.tsx b/src/main.tsx index 25ee7368..04e0d957 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,7 +8,7 @@ import "./ui/i18n/i18n"; import { isElectron } from "@/lib/electron"; import { Toaster } from "@/components/sonner"; import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth"; -import { getUserInfo } from "@/main-axios"; +import { getUserInfo, appReadyPromise } from "@/main-axios"; import { applyAccentColor, applyFontSize } from "@/lib/theme"; import type { FontSizeId } from "@/types/ui-types"; import { useServiceWorker } from "@/hooks/use-service-worker"; @@ -128,10 +128,12 @@ function App() { }; }, []); - // Verify stored session against the server before rendering AppShell + // Verify stored session against the server before rendering AppShell. + // Wait for API instances to be initialized with correct embedded/server config first. useEffect(() => { if (phase !== "verifying") return; - getUserInfo() + appReadyPromise + .then(() => getUserInfo()) .then(() => setPhase("idle-app")) .catch(() => { clearStoredAuth(); diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index f387c656..ab8e251c 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -18,6 +18,7 @@ import { HistoryPanel } from "@/sidebar/HistoryPanel"; import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel"; import { UserProfilePanel } from "@/sidebar/UserProfilePanel"; import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel"; +import { CredentialsPanel } from "@/sidebar/CredentialsPanel"; import { SplitView } from "@/shell/SplitView"; import { TabBar } from "@/shell/TabBar"; import type { @@ -138,9 +139,9 @@ export function AppShell({ const [sidebarOpen, setSidebarOpen] = useState(true); const [railView, setRailView] = useState("hosts"); const [profileDropdownOpen, setProfileDropdownOpen] = useState(false); - const [hostManagerExpanded, setHostManagerExpanded] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(266); const [sidebarDragging, setSidebarDragging] = useState(false); + const [sidebarEditing, setSidebarEditing] = useState(false); const isMobile = useIsMobile(); @@ -155,10 +156,6 @@ export function AppShell({ .catch(() => setIsAdmin(false)); }, []); - const pendingHostManagerEditId = useRef(null); - const pendingHostManagerAction = useRef<"add-host" | "add-credential" | null>( - null, - ); const lastShiftTime = useRef(0); const terminalRefs = useRef>>( new Map(), @@ -166,6 +163,7 @@ export function AppShell({ const sidebarTitle: Record = { hosts: "Hosts", + credentials: "Credentials", "quick-connect": "Quick Connect", "ssh-tools": "SSH Tools", snippets: "Snippets", @@ -340,21 +338,25 @@ export function AppShell({ function openSingletonTab(type: TabType, pendingEvent?: string) { if (type === "host-manager") { - if (pendingEvent === "host-manager:add-host") - pendingHostManagerAction.current = "add-host"; - else if (pendingEvent === "host-manager:add-credential") - pendingHostManagerAction.current = "add-credential"; - - setHostManagerExpanded(true); - setSidebarOpen(true); - setRailView("hosts"); - - if (pendingEvent) { - // Use a small delay to ensure HostManager is mounted if it wasn't already, - // and to allow the current render cycle to complete. - setTimeout(() => { - window.dispatchEvent(new CustomEvent(pendingEvent)); - }, 0); + if (pendingEvent === "host-manager:add-credential") { + setSidebarOpen(true); + setRailView("credentials"); + setTimeout( + () => + window.dispatchEvent( + new CustomEvent("host-manager:add-credential"), + ), + 0, + ); + } else { + setSidebarOpen(true); + setRailView("hosts"); + if (pendingEvent) { + setTimeout( + () => window.dispatchEvent(new CustomEvent(pendingEvent)), + 0, + ); + } } return; } @@ -422,16 +424,20 @@ export function AppShell({ if (railView === view && sidebarOpen) { setSidebarOpen(false); } else { + if (view !== railView) setSidebarEditing(false); setRailView(view); setSidebarOpen(true); } } function editHostInManager(host: Host) { - pendingHostManagerEditId.current = host.id; - setHostManagerExpanded(true); setSidebarOpen(true); setRailView("hosts"); + setTimeout(() => { + window.dispatchEvent( + new CustomEvent("host-manager:edit-host", { detail: host.id }), + ); + }, 0); } const onSidebarMouseDown = useCallback( @@ -465,23 +471,20 @@ export function AppShell({
{railView === "hosts" && ( setHostManagerExpanded(true)} - onCollapse={() => { - setHostManagerExpanded(false); - loadHosts(); - }} - pendingEditId={pendingHostManagerEditId} - pendingAction={pendingHostManagerAction} onOpenTab={(host, type) => { connectHost(host, type); if (isMobile) setSidebarOpen(false); }} onEditHost={editHostInManager} hostTree={realHostTree ?? undefined} + onEditingChange={setSidebarEditing} /> )} + {railView === "credentials" && ( + + )} + {railView === "quick-connect" && ( { @@ -547,7 +550,7 @@ export function AppShell({ {sidebarTitle[railView]} - {!hostManagerExpanded && !isMobile && ( + {!isMobile && ( <> @@ -597,18 +597,14 @@ export function AppShell({
{sidebarHeader} {sidebarPanelContent} - {sidebarOpen && !(hostManagerExpanded && railView === "hosts") && ( + {sidebarOpen && !sidebarEditing && (
{ - setSidebarOpen(open); - if (!open) setHostManagerExpanded(false); - }} - > + { + async (token: string | null) => { try { - const cookieReady = await window.electronAPI?.waitForSessionCookie?.( - "jwt", - currentServerUrl, - previousJwt, - 5000, - ); - if (cookieReady && !cookieReady.success) - throw new Error(cookieReady.error || "Auth cookie not ready"); + if (!token) { + // No token in postMessage — fall back to waiting for the HttpOnly cookie + const cookieReady = await window.electronAPI?.waitForSessionCookie?.( + "jwt", + currentServerUrl, + null, + 5000, + ); + if (cookieReady && !cookieReady.success) + throw new Error(cookieReady.error || "Auth cookie not ready"); + } const meRes = await getUserInfo(); if (!meRes) throw new Error("Failed to get user info"); storeAuth(meRes.username || ""); @@ -447,7 +450,7 @@ export function Auth({ onLogin }: AuthProps) { platform: "desktop", timestamp: Date.now(), }, - window.location.origin, + "*", ); setWebviewAuthSuccess(true); return; @@ -539,7 +542,7 @@ export function Auth({ onLogin }: AuthProps) { platform: "desktop", timestamp: Date.now(), }, - window.location.origin, + "*", ); setWebviewAuthSuccess(true); return; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index e35e901b..625fd658 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -8,7 +8,9 @@ "upload": "Upload", "keyPassword": "Key Password", "sshKey": "SSH Key", - "uploadPrivateKeyFile": "Upload Private Key File" + "uploadPrivateKeyFile": "Upload Private Key File", + "searchCredentials": "Search credentials...", + "addCredential": "Add Credential" }, "homepage": { "failedToLoadAlerts": "Failed to load alerts", @@ -138,6 +140,7 @@ "hosts": "Hosts", "snippets": "Snippets", "hostManager": "Host Manager", + "credentials": "Credentials", "roleAdministrator": "Administrator", "roleUser": "User" }, @@ -536,6 +539,7 @@ "loadingHost": "Loading host...", "hostNotFound": "Host not found", "searchHosts": "Search hosts...", + "addHost": "Add Host", "guac": { "connection": "Connection", "authentication": "Authentication", diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 4f46aecb..30d174c6 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -865,6 +865,11 @@ export let dockerApi: AxiosInstance; // Pre-initialize with default values to avoid undefined errors during early mounting initializeApiInstances(); +let _resolveAppReady!: () => void; +export const appReadyPromise: Promise = new Promise((resolve) => { + _resolveAppReady = resolve; +}); + function initializeApp() { if (isElectron()) { Promise.all([getServerConfig(), getEmbeddedServerStatus()]) @@ -895,9 +900,13 @@ function initializeApp() { error, ); initializeApiInstances(); + }) + .finally(() => { + _resolveAppReady(); }); } else { initializeApiInstances(); + _resolveAppReady(); } } diff --git a/src/ui/shell/MobileBottomBar.tsx b/src/ui/shell/MobileBottomBar.tsx index a25d33ff..cb051c14 100644 --- a/src/ui/shell/MobileBottomBar.tsx +++ b/src/ui/shell/MobileBottomBar.tsx @@ -37,6 +37,11 @@ const PRIMARY_ITEMS: { ]; const MORE_ITEMS: { view: RailView; icon: React.ReactNode; title: string }[] = [ + { + view: "credentials", + icon: , + title: "Credentials", + }, { view: "history", icon: , title: "History" }, { view: "split-screen", diff --git a/src/ui/sidebar/AppRail.tsx b/src/ui/sidebar/AppRail.tsx index efef227e..76132421 100644 --- a/src/ui/sidebar/AppRail.tsx +++ b/src/ui/sidebar/AppRail.tsx @@ -21,6 +21,7 @@ import type { SplitMode, ToolsTab } from "@/types/ui-types"; export type RailView = | "hosts" + | "credentials" | "quick-connect" | ToolsTab | "user-profile" @@ -42,6 +43,11 @@ function buildRailButtons( ): RailItem[] { return [ { view: "hosts", icon: , title: t("nav.hosts") }, + { + view: "credentials", + icon: , + title: t("nav.credentials"), + }, { kind: "separator" }, { view: "quick-connect", diff --git a/src/ui/sidebar/CredentialsPanel.tsx b/src/ui/sidebar/CredentialsPanel.tsx new file mode 100644 index 00000000..31fff2bc --- /dev/null +++ b/src/ui/sidebar/CredentialsPanel.tsx @@ -0,0 +1,66 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Search, X } from "lucide-react"; +import { HostManager } from "@/sidebar/HostManager"; + +export function CredentialsPanel({ + onEditingChange, +}: { + onEditingChange?: (editing: boolean) => void; +}) { + const { t } = useTranslation(); + const [search, setSearch] = useState(""); + const [managerEditing, setManagerEditing] = useState(false); + + function handleEditingChange(editing: boolean) { + setManagerEditing(editing); + onEditingChange?.(editing); + } + + return ( +
+ {!managerEditing && ( +
+
+ + setSearch(e.target.value)} + placeholder={t("credentials.searchCredentials")} + className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0" + /> + {search && ( + + )} +
+ +
+ )} + +
+ +
+
+ ); +} diff --git a/src/ui/sidebar/HostManager.tsx b/src/ui/sidebar/HostManager.tsx index de051d01..9bffc849 100644 --- a/src/ui/sidebar/HostManager.tsx +++ b/src/ui/sidebar/HostManager.tsx @@ -99,6 +99,7 @@ import { subscribeTunnelStatuses, connectTunnel, disconnectTunnel, + getCredentialDetails, } from "@/main-axios"; import type { SSHHostWithStatus } from "@/main-axios"; @@ -5047,7 +5048,15 @@ function CredentialEditorView({ {["password", "key"].map((m) => ( - )} + {/* Search bar — hidden when parent supplies its own */} + {!hideListHeader && ( +
+
+ + setSearchQuery(e.target.value)} + placeholder={ + section === "hosts" + ? t("hosts.searchHostsPlaceholder") + : t("hosts.searchCredentialsPlaceholder") + } + className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0" + /> + {searchQuery && ( + + )} +
-
+ )}
{section === "hosts" && ( @@ -6743,15 +6769,17 @@ export function HostManager({ {cred.type === "key" ? "KEY" : "PWD"}
- - {cred.username} - {usedByHosts.length > 0 && ( - - {" "} - · {usedByHosts.length}h - - )} - + {(cred.username || usedByHosts.length > 0) && ( + + {cred.username} + {usedByHosts.length > 0 && ( + + {cred.username ? " · " : ""} + {usedByHosts.length}h + + )} + + )} {cred.tags && cred.tags.length > 0 && (
{cred.tags.slice(0, 3).map((tag) => ( @@ -6805,8 +6833,23 @@ export function HostManager({ )} - )} + {!managerEditing && ( +
+
+ + setHostSearch(e.target.value)} + placeholder={t("hosts.searchHosts")} + className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0" + /> + {hostSearch && ( + + )} +
+
- -
-
+ )} + +
+ +
+ +
); } diff --git a/src/ui/sidebar/SidebarTree.tsx b/src/ui/sidebar/SidebarTree.tsx index 45c41d16..e44c9cfb 100644 --- a/src/ui/sidebar/SidebarTree.tsx +++ b/src/ui/sidebar/SidebarTree.tsx @@ -282,7 +282,7 @@ export function HostItem({ e.stopPropagation(); onEditHost(); }} - className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors" + className="flex items-center justify-center size-7 rounded text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors" >