mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
fix: OIDC giving "session expired" error and reworked split screen and fixed black screen
This commit is contained in:
@@ -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);
|
||||
|
||||
+92
-77
@@ -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<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(
|
||||
(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}
|
||||
/>
|
||||
</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
|
||||
className="absolute inset-0 flex flex-col"
|
||||
style={{ display: isSplit && !isMobile ? "none" : "flex" }}
|
||||
ref={normalViewRef}
|
||||
className="absolute inset-0"
|
||||
style={{ display: isSplit && !isMobile ? "none" : undefined }}
|
||||
>
|
||||
{/* 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(
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
inPane || activeInline,
|
||||
),
|
||||
tabNode,
|
||||
tab.id,
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<NetworkElement[]>([]);
|
||||
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
||||
const [hostMap, setHostMap] = useState<HostMap>({});
|
||||
@@ -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<cytoscape.Core | null>(null);
|
||||
const statusIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -864,15 +883,17 @@ export function NetworkGraphCard({
|
||||
</div>
|
||||
)}
|
||||
{contextMenuEl}
|
||||
<CytoscapeComponent
|
||||
elements={elements}
|
||||
style={{ width: "100%", height: "100%", background: "transparent" }}
|
||||
layout={{ name: "preset" }}
|
||||
cy={handleNodeInit}
|
||||
wheelSensitivity={1.5}
|
||||
minZoom={0.2}
|
||||
maxZoom={3}
|
||||
/>
|
||||
{containerReady && (
|
||||
<CytoscapeComponent
|
||||
elements={elements}
|
||||
style={{ width: "100%", height: "100%", background: "transparent" }}
|
||||
layout={{ name: "preset" }}
|
||||
cy={handleNodeInit}
|
||||
wheelSensitivity={1.5}
|
||||
minZoom={0.2}
|
||||
maxZoom={3}
|
||||
/>
|
||||
)}
|
||||
{!loading && elements.length === 0 && (
|
||||
<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" />
|
||||
|
||||
@@ -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({
|
||||
<PaneHeader tab={tab} paneIndex={paneIndex} />
|
||||
<div className="flex-1 min-h-0 overflow-hidden relative">
|
||||
{tab ? (
|
||||
isTerminal ? (
|
||||
// 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}
|
||||
/>
|
||||
)
|
||||
<div ref={contentRef} className="absolute inset-0" />
|
||||
) : (
|
||||
<EmptyPane />
|
||||
)}
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
@@ -537,8 +496,6 @@ export const SplitView = memo(function SplitView({
|
||||
tab={tab(1)}
|
||||
paneIndex={1}
|
||||
isDragging={isDragging}
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
onOpenTab={onOpenTab}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
/>
|
||||
</div>
|
||||
@@ -554,8 +511,6 @@ export const SplitView = memo(function SplitView({
|
||||
tab={tab(2)}
|
||||
paneIndex={2}
|
||||
isDragging={isDragging}
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
onOpenTab={onOpenTab}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
/>
|
||||
</div>
|
||||
@@ -577,8 +532,6 @@ export const SplitView = memo(function SplitView({
|
||||
tab={tab(0)}
|
||||
paneIndex={0}
|
||||
isDragging={isDragging}
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
onOpenTab={onOpenTab}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
/>
|
||||
</div>
|
||||
@@ -591,8 +544,6 @@ export const SplitView = memo(function SplitView({
|
||||
tab={tab(1)}
|
||||
paneIndex={1}
|
||||
isDragging={isDragging}
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
onOpenTab={onOpenTab}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
/>
|
||||
</div>
|
||||
@@ -606,8 +557,6 @@ export const SplitView = memo(function SplitView({
|
||||
tab={tab(2)}
|
||||
paneIndex={2}
|
||||
isDragging={isDragging}
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
onOpenTab={onOpenTab}
|
||||
onPaneContentRef={onPaneContentRef}
|
||||
/>
|
||||
</div>
|
||||
@@ -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}
|
||||
|
||||
@@ -132,6 +132,7 @@ function TerminalTabContent({
|
||||
{
|
||||
...hostToSSHHost(host),
|
||||
sshPort: host.sshPort ?? host.port,
|
||||
instanceId: tab.id,
|
||||
} as any
|
||||
}
|
||||
isVisible={isVisible}
|
||||
|
||||
Reference in New Issue
Block a user