From 184e573caae2b3a5b7ee494a25e5548d3aee83aa Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 27 May 2026 18:21:41 -0500 Subject: [PATCH] fix: OIDC giving "session expired" error and reworked split screen and fixed black screen --- src/backend/database/routes/users.ts | 3 + src/ui/AppShell.tsx | 169 +++++++++++--------- src/ui/dashboard/cards/NetworkGraphCard.tsx | 39 +++-- src/ui/shell/SplitView.tsx | 69 +------- src/ui/shell/tabUtils.tsx | 1 + 5 files changed, 129 insertions(+), 152 deletions(-) diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 965170ba..b7088cd8 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -1353,6 +1353,7 @@ router.get("/oidc/callback", async (req, res) => { } try { + await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type); } catch (setupError) { authLogger.error("Failed to setup OIDC user encryption", setupError, { operation: "oidc_user_encryption_setup_failed", @@ -2062,6 +2063,8 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => { "INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)", ) .run(allowed ? "true" : "false"); + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); res.json({ allowed }); } catch (err) { authLogger.error("Failed to set password login allowed", err); diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index cfac02b7..dc5bea3e 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -4,14 +4,8 @@ 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, - createRef, - createPortal, -} from "react"; +import { useState, useRef, useCallback, useEffect, createRef } from "react"; +import { createPortal } from "react-dom"; import { useIsMobile } from "@/hooks/use-mobile"; import { MobileBottomBar } from "@/shell/MobileBottomBar"; import { CommandPalette } from "@/shell/CommandPalette"; @@ -177,6 +171,25 @@ export function AppShell({ (HTMLDivElement | null)[] >(Array(6).fill(null)); + // Stable per-tab DOM nodes — created once per tab, never destroyed while the tab lives. + // We always portal each tab's content into its own node, then move that node between + // the normal-view container and the pane container via vanilla DOM so React's portal + // target never changes (changing the target causes a remount). + const tabNodesRef = useRef>(new Map()); + const normalViewRef = useRef(null); + + const getTabNode = useCallback((tabId: string, isTerminal: boolean) => { + if (!tabNodesRef.current.has(tabId)) { + const el = document.createElement("div"); + el.style.position = "absolute"; + el.style.inset = "0"; + el.style.overflow = "hidden"; + if (!isTerminal) el.classList.add("bg-background"); + tabNodesRef.current.set(tabId, el); + } + return tabNodesRef.current.get(tabId)!; + }, []); + const onPaneContentRef = useCallback( (paneIndex: number, el: HTMLDivElement | null) => { setPaneContentEls((prev) => { @@ -528,8 +541,57 @@ export function AppShell({ return () => cancelAnimationFrame(id); }, [splitMode, sidebarWidth, sidebarOpen]); - const activeTab = tabs.find((t) => t.id === activeTabId)!; const isSplit = splitMode !== "none"; + + // Move each tab's stable DOM node to the right container (pane or normal-view). + // This is vanilla DOM so React's portal target never changes — changing the portal + // target causes a remount which is exactly what we're trying to avoid. + useEffect(() => { + const normalView = normalViewRef.current; + if (!normalView) return; + + const tabIds = new Set(tabs.map((t) => t.id)); + + // Remove nodes for closed tabs + for (const [id, node] of tabNodesRef.current) { + if (!tabIds.has(id)) { + node.remove(); + tabNodesRef.current.delete(id); + } + } + + for (const tab of tabs) { + const isTerminal = tab.type === "terminal"; + const node = getTabNode(tab.id, isTerminal); + const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1; + const inPane = paneIdx !== -1; + const paneEl = inPane ? paneContentEls[paneIdx] : null; + const activeInline = !inPane && tab.id === activeTabId; + + if (inPane && paneEl) { + if (node.parentElement !== paneEl) paneEl.appendChild(node); + node.style.visibility = "visible"; + node.style.pointerEvents = "auto"; + node.style.display = ""; + node.style.zIndex = ""; + } else { + if (node.parentElement !== normalView) normalView.appendChild(node); + if (isTerminal) { + node.style.display = ""; + node.style.visibility = activeInline ? "visible" : "hidden"; + node.style.pointerEvents = activeInline ? "auto" : "none"; + node.style.zIndex = activeInline && !isSplit ? "1" : "0"; + } else { + node.style.visibility = ""; + node.style.pointerEvents = ""; + node.style.zIndex = activeInline ? "2" : ""; + node.style.display = activeInline ? "" : "none"; + } + } + } + }); + + const activeTab = tabs.find((t) => t.id === activeTabId)!; const terminalTabs = tabs.filter((t) => t.type === "terminal"); // Sidebar panel content — shared between desktop inline sidebar and mobile sheet @@ -740,86 +802,39 @@ export function AppShell({ tabs={tabs} paneTabIds={paneTabIds} splitMode={splitMode} - onOpenSingletonTab={openSingletonTab} - onOpenTab={openTab} onTerminalResize={resizeAllTerminals} onPaneContentRef={onPaneContentRef} /> )} - {/* Normal tab view — always mounted, hidden via CSS when split is active */} + {/* Normal-view container. Tab nodes are appended here (or to pane elements) + by the DOM-placement effect above. React portals each tab's content + into its stable per-tab node so the component is never remounted. + Hidden when split is active — pane-assigned nodes escape via vanilla DOM + appendChild to paneEl, so hiding this doesn't affect them. */}
- {/* Terminal tabs: always in DOM, visibility-toggled so xterm keeps its dimensions. - When split is active and this tab is assigned to a pane, portal it there. */} - {tabs - .filter((tab) => tab.type === "terminal") - .map((tab) => { - const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1; - const inPane = paneIdx !== -1; - const paneEl = inPane ? paneContentEls[paneIdx] : null; - const visible = inPane || tab.id === activeTabId; - - const terminalContent = renderTabContent( + {tabs.map((tab) => { + const tabNode = getTabNode(tab.id, tab.type === "terminal"); + const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1; + const inPane = paneIdx !== -1; + const activeInline = !inPane && tab.id === activeTabId; + return createPortal( + renderTabContent( tab, openSingletonTab, openTab, closeTab, - visible, - ); - - if (inPane && paneEl) { - return createPortal( -
- {terminalContent} -
, - paneEl, - tab.id, - ); - } - - return ( -
- {terminalContent} -
- ); - })} - {/* Non-terminal tabs */} - {tabs - .filter((tab) => tab.type !== "terminal") - .map((tab) => { - const visible = tab.id === activeTabId; - return ( -
- {renderTabContent( - tab, - openSingletonTab, - openTab, - closeTab, - visible, - )} -
- ); - })} + inPane || activeInline, + ), + tabNode, + tab.id, + ); + })}
diff --git a/src/ui/dashboard/cards/NetworkGraphCard.tsx b/src/ui/dashboard/cards/NetworkGraphCard.tsx index e0714c4f..49ca45f9 100644 --- a/src/ui/dashboard/cards/NetworkGraphCard.tsx +++ b/src/ui/dashboard/cards/NetworkGraphCard.tsx @@ -191,6 +191,10 @@ export function NetworkGraphCard({ const { t } = useTranslation(); const { addTab } = useTabsSafe(); + // Gate Cytoscape mounting on the container actually having non-zero dimensions. + // This avoids the "bb is undefined" crash when the card is hidden via display:none. + const [containerReady, setContainerReady] = useState(false); + const [elements, setElements] = useState([]); const [hosts, setHosts] = useState([]); const [hostMap, setHostMap] = useState({}); @@ -230,6 +234,21 @@ export function NetworkGraphCard({ const [, forceUpdate] = useReducer((x: number) => x + 1, 0); + useEffect(() => { + if (containerReady) return; + const el = cyContainerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + if (width > 0 && height > 0) { + setContainerReady(true); + ro.disconnect(); + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, [containerReady]); + const cyRef = useRef(null); const statusIntervalRef = useRef | null>(null); const saveTimeoutRef = useRef | null>(null); @@ -864,15 +883,17 @@ export function NetworkGraphCard({ )} {contextMenuEl} - + {containerReady && ( + + )} {!loading && elements.length === 0 && (
diff --git a/src/ui/shell/SplitView.tsx b/src/ui/shell/SplitView.tsx index 8f8abfe5..8fade15e 100644 --- a/src/ui/shell/SplitView.tsx +++ b/src/ui/shell/SplitView.tsx @@ -1,8 +1,8 @@ import React, { useState, useRef, useEffect, memo, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { splitDragState, notifyDragEnd } from "@/lib/splitDragging"; -import { renderTabContent, tabIcon } from "@/shell/tabUtils"; -import type { Tab, TabType, Host, SplitMode } from "@/types/ui-types"; +import { tabIcon } from "@/shell/tabUtils"; +import type { Tab, SplitMode } from "@/types/ui-types"; // ─── useSplitSizes ──────────────────────────────────────────────────────────── @@ -305,34 +305,17 @@ function EmptyPane() { ); } -const PaneContent = memo(function PaneContent({ - tab, - onOpenSingletonTab, - onOpenTab, -}: { - tab: Tab; - onOpenSingletonTab: (type: TabType) => void; - onOpenTab: (host: Host, type: TabType) => void; -}) { - return <>{renderTabContent(tab, onOpenSingletonTab, onOpenTab)}; -}); - const Pane = memo(function Pane({ tab, paneIndex, isDragging, - onOpenSingletonTab, - onOpenTab, onPaneContentRef, }: { tab: Tab | null; paneIndex: number; isDragging: boolean; - onOpenSingletonTab: (type: TabType) => void; - onOpenTab: (host: Host, type: TabType) => void; onPaneContentRef?: (paneIndex: number, el: HTMLDivElement | null) => void; }) { - const isTerminal = tab?.type === "terminal"; const contentRef = useCallback( (el: HTMLDivElement | null) => { onPaneContentRef?.(paneIndex, el); @@ -345,17 +328,7 @@ const Pane = memo(function Pane({
{tab ? ( - isTerminal ? ( - // Terminal tabs are portaled in from AppShell to avoid remounting -
- ) : ( - - ) +
) : ( )} @@ -377,8 +350,6 @@ const Row = memo(function Row({ paneTabIds, tabs, isDragging, - onOpenSingletonTab, - onOpenTab, onColDivider, onColDividerTouch, onPaneContentRef, @@ -390,8 +361,6 @@ const Row = memo(function Row({ paneTabIds: (string | null)[]; tabs: Tab[]; isDragging: boolean; - onOpenSingletonTab: (type: TabType) => void; - onOpenTab: (host: Host, type: TabType) => void; onColDivider: (e: React.MouseEvent, rowIdx: number, colIdx: number) => void; onColDividerTouch: ( e: React.TouchEvent, @@ -417,8 +386,6 @@ const Row = memo(function Row({ tab={tab} paneIndex={pIdx} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onPaneContentRef={onPaneContentRef} />
@@ -441,16 +408,12 @@ export const SplitView = memo(function SplitView({ tabs, paneTabIds, splitMode, - onOpenSingletonTab, - onOpenTab, onTerminalResize, onPaneContentRef, }: { tabs: Tab[]; paneTabIds: (string | null)[]; splitMode: SplitMode; - onOpenSingletonTab: (type: TabType) => void; - onOpenTab: (host: Host, type: TabType) => void; onTerminalResize?: () => void; onPaneContentRef?: (paneIndex: number, el: HTMLDivElement | null) => void; }) { @@ -501,8 +464,6 @@ export const SplitView = memo(function SplitView({ paneTabIds={paneTabIds} tabs={tabs} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onColDivider={onColDivider} onColDividerTouch={onColDividerTouch} onPaneContentRef={onPaneContentRef} @@ -519,8 +480,6 @@ export const SplitView = memo(function SplitView({ tab={tab(0)} paneIndex={0} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onPaneContentRef={onPaneContentRef} />
@@ -537,8 +496,6 @@ export const SplitView = memo(function SplitView({ tab={tab(1)} paneIndex={1} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onPaneContentRef={onPaneContentRef} />
@@ -554,8 +511,6 @@ export const SplitView = memo(function SplitView({ tab={tab(2)} paneIndex={2} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onPaneContentRef={onPaneContentRef} />
@@ -577,8 +532,6 @@ export const SplitView = memo(function SplitView({ tab={tab(0)} paneIndex={0} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onPaneContentRef={onPaneContentRef} /> @@ -591,8 +544,6 @@ export const SplitView = memo(function SplitView({ tab={tab(1)} paneIndex={1} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onPaneContentRef={onPaneContentRef} /> @@ -606,8 +557,6 @@ export const SplitView = memo(function SplitView({ tab={tab(2)} paneIndex={2} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onPaneContentRef={onPaneContentRef} /> @@ -624,8 +573,6 @@ export const SplitView = memo(function SplitView({ paneTabIds={paneTabIds} tabs={tabs} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onColDivider={onColDivider} onColDividerTouch={onColDividerTouch} onPaneContentRef={onPaneContentRef} @@ -642,8 +589,6 @@ export const SplitView = memo(function SplitView({ paneTabIds={paneTabIds} tabs={tabs} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onColDivider={onColDivider} onColDividerTouch={onColDividerTouch} onPaneContentRef={onPaneContentRef} @@ -661,8 +606,6 @@ export const SplitView = memo(function SplitView({ paneTabIds={paneTabIds} tabs={tabs} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onColDivider={onColDivider} onColDividerTouch={onColDividerTouch} onPaneContentRef={onPaneContentRef} @@ -679,8 +622,6 @@ export const SplitView = memo(function SplitView({ paneTabIds={paneTabIds} tabs={tabs} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onColDivider={onColDivider} onColDividerTouch={onColDividerTouch} onPaneContentRef={onPaneContentRef} @@ -698,8 +639,6 @@ export const SplitView = memo(function SplitView({ paneTabIds={paneTabIds} tabs={tabs} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onColDivider={onColDivider} onColDividerTouch={onColDividerTouch} onPaneContentRef={onPaneContentRef} @@ -716,8 +655,6 @@ export const SplitView = memo(function SplitView({ paneTabIds={paneTabIds} tabs={tabs} isDragging={isDragging} - onOpenSingletonTab={onOpenSingletonTab} - onOpenTab={onOpenTab} onColDivider={onColDivider} onColDividerTouch={onColDividerTouch} onPaneContentRef={onPaneContentRef} diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx index a5a0d906..d644966f 100644 --- a/src/ui/shell/tabUtils.tsx +++ b/src/ui/shell/tabUtils.tsx @@ -132,6 +132,7 @@ function TerminalTabContent({ { ...hostToSSHHost(host), sshPort: host.sshPort ?? host.port, + instanceId: tab.id, } as any } isVisible={isVisible}