fix: OIDC giving "session expired" error and reworked split screen and fixed black screen

This commit is contained in:
LukeGus
2026-05-27 18:21:41 -05:00
parent 9f035ea354
commit 184e573caa
5 changed files with 129 additions and 152 deletions
+3
View File
@@ -1353,6 +1353,7 @@ router.get("/oidc/callback", async (req, res) => {
} }
try { try {
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
} catch (setupError) { } catch (setupError) {
authLogger.error("Failed to setup OIDC user encryption", setupError, { authLogger.error("Failed to setup OIDC user encryption", setupError, {
operation: "oidc_user_encryption_setup_failed", 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', ?)", "INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)",
) )
.run(allowed ? "true" : "false"); .run(allowed ? "true" : "false");
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
await saveMemoryDatabaseToFile();
res.json({ allowed }); res.json({ allowed });
} catch (err) { } catch (err) {
authLogger.error("Failed to set password login allowed", err); authLogger.error("Failed to set password login allowed", err);
+92 -77
View File
@@ -4,14 +4,8 @@ import { Separator } from "@/components/separator";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Sheet, SheetContent } from "@/components/sheet"; import { Sheet, SheetContent } from "@/components/sheet";
import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react"; import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react";
import { import { useState, useRef, useCallback, useEffect, createRef } from "react";
useState, import { createPortal } from "react-dom";
useRef,
useCallback,
useEffect,
createRef,
createPortal,
} from "react";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { MobileBottomBar } from "@/shell/MobileBottomBar"; import { MobileBottomBar } from "@/shell/MobileBottomBar";
import { CommandPalette } from "@/shell/CommandPalette"; import { CommandPalette } from "@/shell/CommandPalette";
@@ -177,6 +171,25 @@ export function AppShell({
(HTMLDivElement | null)[] (HTMLDivElement | null)[]
>(Array(6).fill(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<Map<string, HTMLDivElement>>(new Map());
const normalViewRef = useRef<HTMLDivElement>(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( const onPaneContentRef = useCallback(
(paneIndex: number, el: HTMLDivElement | null) => { (paneIndex: number, el: HTMLDivElement | null) => {
setPaneContentEls((prev) => { setPaneContentEls((prev) => {
@@ -528,8 +541,57 @@ export function AppShell({
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}, [splitMode, sidebarWidth, sidebarOpen]); }, [splitMode, sidebarWidth, sidebarOpen]);
const activeTab = tabs.find((t) => t.id === activeTabId)!;
const isSplit = splitMode !== "none"; 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"); const terminalTabs = tabs.filter((t) => t.type === "terminal");
// Sidebar panel content — shared between desktop inline sidebar and mobile sheet // Sidebar panel content — shared between desktop inline sidebar and mobile sheet
@@ -740,86 +802,39 @@ export function AppShell({
tabs={tabs} tabs={tabs}
paneTabIds={paneTabIds} paneTabIds={paneTabIds}
splitMode={splitMode} splitMode={splitMode}
onOpenSingletonTab={openSingletonTab}
onOpenTab={openTab}
onTerminalResize={resizeAllTerminals} onTerminalResize={resizeAllTerminals}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
/> />
</div> </div>
)} )}
{/* 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. */}
<div <div
className="absolute inset-0 flex flex-col" ref={normalViewRef}
style={{ display: isSplit && !isMobile ? "none" : "flex" }} className="absolute inset-0"
style={{ display: isSplit && !isMobile ? "none" : undefined }}
> >
{/* Terminal tabs: always in DOM, visibility-toggled so xterm keeps its dimensions. {tabs.map((tab) => {
When split is active and this tab is assigned to a pane, portal it there. */} const tabNode = getTabNode(tab.id, tab.type === "terminal");
{tabs const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
.filter((tab) => tab.type === "terminal") const inPane = paneIdx !== -1;
.map((tab) => { const activeInline = !inPane && tab.id === activeTabId;
const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1; return createPortal(
const inPane = paneIdx !== -1; renderTabContent(
const paneEl = inPane ? paneContentEls[paneIdx] : null;
const visible = inPane || tab.id === activeTabId;
const terminalContent = renderTabContent(
tab, tab,
openSingletonTab, openSingletonTab,
openTab, openTab,
closeTab, closeTab,
visible, inPane || activeInline,
); ),
tabNode,
if (inPane && paneEl) { tab.id,
return createPortal( );
<div className="absolute inset-0 overflow-hidden"> })}
{terminalContent}
</div>,
paneEl,
tab.id,
);
}
return (
<div
key={tab.id}
className="absolute inset-0 overflow-hidden"
style={{
visibility: visible ? "visible" : "hidden",
pointerEvents: visible ? "auto" : "none",
zIndex: tab.id === activeTabId && !isSplit ? 1 : 0,
}}
>
{terminalContent}
</div>
);
})}
{/* Non-terminal tabs */}
{tabs
.filter((tab) => tab.type !== "terminal")
.map((tab) => {
const visible = tab.id === activeTabId;
return (
<div
key={tab.id}
className="flex flex-col overflow-hidden bg-background"
style={
visible
? { position: "absolute", inset: 0, zIndex: 2 }
: { display: "none" }
}
>
{renderTabContent(
tab,
openSingletonTab,
openTab,
closeTab,
visible,
)}
</div>
);
})}
</div> </div>
</div> </div>
</div> </div>
+30 -9
View File
@@ -191,6 +191,10 @@ export function NetworkGraphCard({
const { t } = useTranslation(); const { t } = useTranslation();
const { addTab } = useTabsSafe(); 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<NetworkElement[]>([]); const [elements, setElements] = useState<NetworkElement[]>([]);
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]); const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
const [hostMap, setHostMap] = useState<HostMap>({}); const [hostMap, setHostMap] = useState<HostMap>({});
@@ -230,6 +234,21 @@ export function NetworkGraphCard({
const [, forceUpdate] = useReducer((x: number) => x + 1, 0); 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<cytoscape.Core | null>(null); const cyRef = useRef<cytoscape.Core | null>(null);
const statusIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null); const statusIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -864,15 +883,17 @@ export function NetworkGraphCard({
</div> </div>
)} )}
{contextMenuEl} {contextMenuEl}
<CytoscapeComponent {containerReady && (
elements={elements} <CytoscapeComponent
style={{ width: "100%", height: "100%", background: "transparent" }} elements={elements}
layout={{ name: "preset" }} style={{ width: "100%", height: "100%", background: "transparent" }}
cy={handleNodeInit} layout={{ name: "preset" }}
wheelSensitivity={1.5} cy={handleNodeInit}
minZoom={0.2} wheelSensitivity={1.5}
maxZoom={3} minZoom={0.2}
/> maxZoom={3}
/>
)}
{!loading && elements.length === 0 && ( {!loading && elements.length === 0 && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 pointer-events-none"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 pointer-events-none">
<Network className="size-8 text-muted-foreground/30" /> <Network className="size-8 text-muted-foreground/30" />
+3 -66
View File
@@ -1,8 +1,8 @@
import React, { useState, useRef, useEffect, memo, useCallback } from "react"; import React, { useState, useRef, useEffect, memo, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { splitDragState, notifyDragEnd } from "@/lib/splitDragging"; import { splitDragState, notifyDragEnd } from "@/lib/splitDragging";
import { renderTabContent, tabIcon } from "@/shell/tabUtils"; import { tabIcon } from "@/shell/tabUtils";
import type { Tab, TabType, Host, SplitMode } from "@/types/ui-types"; import type { Tab, SplitMode } from "@/types/ui-types";
// ─── useSplitSizes ──────────────────────────────────────────────────────────── // ─── 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({ const Pane = memo(function Pane({
tab, tab,
paneIndex, paneIndex,
isDragging, isDragging,
onOpenSingletonTab,
onOpenTab,
onPaneContentRef, onPaneContentRef,
}: { }: {
tab: Tab | null; tab: Tab | null;
paneIndex: number; paneIndex: number;
isDragging: boolean; isDragging: boolean;
onOpenSingletonTab: (type: TabType) => void;
onOpenTab: (host: Host, type: TabType) => void;
onPaneContentRef?: (paneIndex: number, el: HTMLDivElement | null) => void; onPaneContentRef?: (paneIndex: number, el: HTMLDivElement | null) => void;
}) { }) {
const isTerminal = tab?.type === "terminal";
const contentRef = useCallback( const contentRef = useCallback(
(el: HTMLDivElement | null) => { (el: HTMLDivElement | null) => {
onPaneContentRef?.(paneIndex, el); onPaneContentRef?.(paneIndex, el);
@@ -345,17 +328,7 @@ const Pane = memo(function Pane({
<PaneHeader tab={tab} paneIndex={paneIndex} /> <PaneHeader tab={tab} paneIndex={paneIndex} />
<div className="flex-1 min-h-0 overflow-hidden relative"> <div className="flex-1 min-h-0 overflow-hidden relative">
{tab ? ( {tab ? (
isTerminal ? ( <div ref={contentRef} className="absolute inset-0" />
// Terminal tabs are portaled in from AppShell to avoid remounting
<div ref={contentRef} className="absolute inset-0" />
) : (
<PaneContent
key={tab.id}
tab={tab}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
)
) : ( ) : (
<EmptyPane /> <EmptyPane />
)} )}
@@ -377,8 +350,6 @@ const Row = memo(function Row({
paneTabIds, paneTabIds,
tabs, tabs,
isDragging, isDragging,
onOpenSingletonTab,
onOpenTab,
onColDivider, onColDivider,
onColDividerTouch, onColDividerTouch,
onPaneContentRef, onPaneContentRef,
@@ -390,8 +361,6 @@ const Row = memo(function Row({
paneTabIds: (string | null)[]; paneTabIds: (string | null)[];
tabs: Tab[]; tabs: Tab[];
isDragging: boolean; isDragging: boolean;
onOpenSingletonTab: (type: TabType) => void;
onOpenTab: (host: Host, type: TabType) => void;
onColDivider: (e: React.MouseEvent, rowIdx: number, colIdx: number) => void; onColDivider: (e: React.MouseEvent, rowIdx: number, colIdx: number) => void;
onColDividerTouch: ( onColDividerTouch: (
e: React.TouchEvent, e: React.TouchEvent,
@@ -417,8 +386,6 @@ const Row = memo(function Row({
tab={tab} tab={tab}
paneIndex={pIdx} paneIndex={pIdx}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
/> />
</div> </div>
@@ -441,16 +408,12 @@ export const SplitView = memo(function SplitView({
tabs, tabs,
paneTabIds, paneTabIds,
splitMode, splitMode,
onOpenSingletonTab,
onOpenTab,
onTerminalResize, onTerminalResize,
onPaneContentRef, onPaneContentRef,
}: { }: {
tabs: Tab[]; tabs: Tab[];
paneTabIds: (string | null)[]; paneTabIds: (string | null)[];
splitMode: SplitMode; splitMode: SplitMode;
onOpenSingletonTab: (type: TabType) => void;
onOpenTab: (host: Host, type: TabType) => void;
onTerminalResize?: () => void; onTerminalResize?: () => void;
onPaneContentRef?: (paneIndex: number, el: HTMLDivElement | null) => void; onPaneContentRef?: (paneIndex: number, el: HTMLDivElement | null) => void;
}) { }) {
@@ -501,8 +464,6 @@ export const SplitView = memo(function SplitView({
paneTabIds={paneTabIds} paneTabIds={paneTabIds}
tabs={tabs} tabs={tabs}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider} onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch} onColDividerTouch={onColDividerTouch}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
@@ -519,8 +480,6 @@ export const SplitView = memo(function SplitView({
tab={tab(0)} tab={tab(0)}
paneIndex={0} paneIndex={0}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
/> />
</div> </div>
@@ -537,8 +496,6 @@ export const SplitView = memo(function SplitView({
tab={tab(1)} tab={tab(1)}
paneIndex={1} paneIndex={1}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
/> />
</div> </div>
@@ -554,8 +511,6 @@ export const SplitView = memo(function SplitView({
tab={tab(2)} tab={tab(2)}
paneIndex={2} paneIndex={2}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
/> />
</div> </div>
@@ -577,8 +532,6 @@ export const SplitView = memo(function SplitView({
tab={tab(0)} tab={tab(0)}
paneIndex={0} paneIndex={0}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
/> />
</div> </div>
@@ -591,8 +544,6 @@ export const SplitView = memo(function SplitView({
tab={tab(1)} tab={tab(1)}
paneIndex={1} paneIndex={1}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
/> />
</div> </div>
@@ -606,8 +557,6 @@ export const SplitView = memo(function SplitView({
tab={tab(2)} tab={tab(2)}
paneIndex={2} paneIndex={2}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
/> />
</div> </div>
@@ -624,8 +573,6 @@ export const SplitView = memo(function SplitView({
paneTabIds={paneTabIds} paneTabIds={paneTabIds}
tabs={tabs} tabs={tabs}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider} onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch} onColDividerTouch={onColDividerTouch}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
@@ -642,8 +589,6 @@ export const SplitView = memo(function SplitView({
paneTabIds={paneTabIds} paneTabIds={paneTabIds}
tabs={tabs} tabs={tabs}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider} onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch} onColDividerTouch={onColDividerTouch}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
@@ -661,8 +606,6 @@ export const SplitView = memo(function SplitView({
paneTabIds={paneTabIds} paneTabIds={paneTabIds}
tabs={tabs} tabs={tabs}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider} onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch} onColDividerTouch={onColDividerTouch}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
@@ -679,8 +622,6 @@ export const SplitView = memo(function SplitView({
paneTabIds={paneTabIds} paneTabIds={paneTabIds}
tabs={tabs} tabs={tabs}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider} onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch} onColDividerTouch={onColDividerTouch}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
@@ -698,8 +639,6 @@ export const SplitView = memo(function SplitView({
paneTabIds={paneTabIds} paneTabIds={paneTabIds}
tabs={tabs} tabs={tabs}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider} onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch} onColDividerTouch={onColDividerTouch}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
@@ -716,8 +655,6 @@ export const SplitView = memo(function SplitView({
paneTabIds={paneTabIds} paneTabIds={paneTabIds}
tabs={tabs} tabs={tabs}
isDragging={isDragging} isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider} onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch} onColDividerTouch={onColDividerTouch}
onPaneContentRef={onPaneContentRef} onPaneContentRef={onPaneContentRef}
+1
View File
@@ -132,6 +132,7 @@ function TerminalTabContent({
{ {
...hostToSSHHost(host), ...hostToSSHHost(host),
sshPort: host.sshPort ?? host.port, sshPort: host.sshPort ?? host.port,
instanceId: tab.id,
} as any } as any
} }
isVisible={isVisible} isVisible={isVisible}