mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
feat: seperated host manager into 2 rail tabs, electron fixes for auth, shared credential fixes
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { db } from "../database/db/index.js";
|
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 { eq, and, or, isNull } from "drizzle-orm";
|
||||||
import { DataCrypto } from "./data-crypto.js";
|
import { DataCrypto } from "./data-crypto.js";
|
||||||
import { SystemCrypto } from "./system-crypto.js";
|
import { SystemCrypto } from "./system-crypto.js";
|
||||||
@@ -64,7 +64,7 @@ export class CredentialSystemEncryptionMigration {
|
|||||||
cred.keyPassword,
|
cred.keyPassword,
|
||||||
userDEK,
|
userDEK,
|
||||||
cred.id.toString(),
|
cred.id.toString(),
|
||||||
"key_password",
|
"keyPassword",
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -105,6 +105,11 @@ export class CredentialSystemEncryptionMigration {
|
|||||||
})
|
})
|
||||||
.where(eq(sshCredentials.id, cred.id));
|
.where(eq(sshCredentials.id, cred.id));
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(sharedCredentials)
|
||||||
|
.set({ needsReEncryption: true })
|
||||||
|
.where(eq(sharedCredentials.originalCredentialId, cred.id));
|
||||||
|
|
||||||
migrated++;
|
migrated++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
databaseLogger.warn(
|
databaseLogger.warn(
|
||||||
|
|||||||
+5
-3
@@ -8,7 +8,7 @@ import "./ui/i18n/i18n";
|
|||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
import { Toaster } from "@/components/sonner";
|
import { Toaster } from "@/components/sonner";
|
||||||
import { Auth, getStoredAuth, clearStoredAuth } from "@/auth/Auth";
|
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 { applyAccentColor, applyFontSize } from "@/lib/theme";
|
||||||
import type { FontSizeId } from "@/types/ui-types";
|
import type { FontSizeId } from "@/types/ui-types";
|
||||||
import { useServiceWorker } from "@/hooks/use-service-worker";
|
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(() => {
|
useEffect(() => {
|
||||||
if (phase !== "verifying") return;
|
if (phase !== "verifying") return;
|
||||||
getUserInfo()
|
appReadyPromise
|
||||||
|
.then(() => getUserInfo())
|
||||||
.then(() => setPhase("idle-app"))
|
.then(() => setPhase("idle-app"))
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
clearStoredAuth();
|
clearStoredAuth();
|
||||||
|
|||||||
+38
-48
@@ -18,6 +18,7 @@ import { HistoryPanel } from "@/sidebar/HistoryPanel";
|
|||||||
import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel";
|
import { SplitScreenPanel } from "@/sidebar/SplitScreenPanel";
|
||||||
import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
|
import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
|
||||||
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
|
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
|
||||||
|
import { CredentialsPanel } from "@/sidebar/CredentialsPanel";
|
||||||
import { SplitView } from "@/shell/SplitView";
|
import { SplitView } from "@/shell/SplitView";
|
||||||
import { TabBar } from "@/shell/TabBar";
|
import { TabBar } from "@/shell/TabBar";
|
||||||
import type {
|
import type {
|
||||||
@@ -138,9 +139,9 @@ export function AppShell({
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [railView, setRailView] = useState<RailView>("hosts");
|
const [railView, setRailView] = useState<RailView>("hosts");
|
||||||
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
|
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
|
||||||
const [hostManagerExpanded, setHostManagerExpanded] = useState(false);
|
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(266);
|
const [sidebarWidth, setSidebarWidth] = useState(266);
|
||||||
const [sidebarDragging, setSidebarDragging] = useState(false);
|
const [sidebarDragging, setSidebarDragging] = useState(false);
|
||||||
|
const [sidebarEditing, setSidebarEditing] = useState(false);
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
@@ -155,10 +156,6 @@ export function AppShell({
|
|||||||
.catch(() => setIsAdmin(false));
|
.catch(() => setIsAdmin(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pendingHostManagerEditId = useRef<string | null>(null);
|
|
||||||
const pendingHostManagerAction = useRef<"add-host" | "add-credential" | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const lastShiftTime = useRef(0);
|
const lastShiftTime = useRef(0);
|
||||||
const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>(
|
const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
@@ -166,6 +163,7 @@ export function AppShell({
|
|||||||
|
|
||||||
const sidebarTitle: Record<RailView, string> = {
|
const sidebarTitle: Record<RailView, string> = {
|
||||||
hosts: "Hosts",
|
hosts: "Hosts",
|
||||||
|
credentials: "Credentials",
|
||||||
"quick-connect": "Quick Connect",
|
"quick-connect": "Quick Connect",
|
||||||
"ssh-tools": "SSH Tools",
|
"ssh-tools": "SSH Tools",
|
||||||
snippets: "Snippets",
|
snippets: "Snippets",
|
||||||
@@ -340,21 +338,25 @@ export function AppShell({
|
|||||||
|
|
||||||
function openSingletonTab(type: TabType, pendingEvent?: string) {
|
function openSingletonTab(type: TabType, pendingEvent?: string) {
|
||||||
if (type === "host-manager") {
|
if (type === "host-manager") {
|
||||||
if (pendingEvent === "host-manager:add-host")
|
if (pendingEvent === "host-manager:add-credential") {
|
||||||
pendingHostManagerAction.current = "add-host";
|
setSidebarOpen(true);
|
||||||
else if (pendingEvent === "host-manager:add-credential")
|
setRailView("credentials");
|
||||||
pendingHostManagerAction.current = "add-credential";
|
setTimeout(
|
||||||
|
() =>
|
||||||
setHostManagerExpanded(true);
|
window.dispatchEvent(
|
||||||
setSidebarOpen(true);
|
new CustomEvent("host-manager:add-credential"),
|
||||||
setRailView("hosts");
|
),
|
||||||
|
0,
|
||||||
if (pendingEvent) {
|
);
|
||||||
// Use a small delay to ensure HostManager is mounted if it wasn't already,
|
} else {
|
||||||
// and to allow the current render cycle to complete.
|
setSidebarOpen(true);
|
||||||
setTimeout(() => {
|
setRailView("hosts");
|
||||||
window.dispatchEvent(new CustomEvent(pendingEvent));
|
if (pendingEvent) {
|
||||||
}, 0);
|
setTimeout(
|
||||||
|
() => window.dispatchEvent(new CustomEvent(pendingEvent)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -422,16 +424,20 @@ export function AppShell({
|
|||||||
if (railView === view && sidebarOpen) {
|
if (railView === view && sidebarOpen) {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
} else {
|
} else {
|
||||||
|
if (view !== railView) setSidebarEditing(false);
|
||||||
setRailView(view);
|
setRailView(view);
|
||||||
setSidebarOpen(true);
|
setSidebarOpen(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function editHostInManager(host: Host) {
|
function editHostInManager(host: Host) {
|
||||||
pendingHostManagerEditId.current = host.id;
|
|
||||||
setHostManagerExpanded(true);
|
|
||||||
setSidebarOpen(true);
|
setSidebarOpen(true);
|
||||||
setRailView("hosts");
|
setRailView("hosts");
|
||||||
|
setTimeout(() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("host-manager:edit-host", { detail: host.id }),
|
||||||
|
);
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSidebarMouseDown = useCallback(
|
const onSidebarMouseDown = useCallback(
|
||||||
@@ -465,23 +471,20 @@ export function AppShell({
|
|||||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
{railView === "hosts" && (
|
{railView === "hosts" && (
|
||||||
<HostsPanel
|
<HostsPanel
|
||||||
expanded={hostManagerExpanded}
|
|
||||||
onExpand={() => setHostManagerExpanded(true)}
|
|
||||||
onCollapse={() => {
|
|
||||||
setHostManagerExpanded(false);
|
|
||||||
loadHosts();
|
|
||||||
}}
|
|
||||||
pendingEditId={pendingHostManagerEditId}
|
|
||||||
pendingAction={pendingHostManagerAction}
|
|
||||||
onOpenTab={(host, type) => {
|
onOpenTab={(host, type) => {
|
||||||
connectHost(host, type);
|
connectHost(host, type);
|
||||||
if (isMobile) setSidebarOpen(false);
|
if (isMobile) setSidebarOpen(false);
|
||||||
}}
|
}}
|
||||||
onEditHost={editHostInManager}
|
onEditHost={editHostInManager}
|
||||||
hostTree={realHostTree ?? undefined}
|
hostTree={realHostTree ?? undefined}
|
||||||
|
onEditingChange={setSidebarEditing}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{railView === "credentials" && (
|
||||||
|
<CredentialsPanel onEditingChange={setSidebarEditing} />
|
||||||
|
)}
|
||||||
|
|
||||||
{railView === "quick-connect" && (
|
{railView === "quick-connect" && (
|
||||||
<QuickConnectPanel
|
<QuickConnectPanel
|
||||||
onConnect={(host, type) => {
|
onConnect={(host, type) => {
|
||||||
@@ -547,7 +550,7 @@ export function AppShell({
|
|||||||
<span className="flex-1 text-base font-bold tracking-tight text-foreground px-3">
|
<span className="flex-1 text-base font-bold tracking-tight text-foreground px-3">
|
||||||
{sidebarTitle[railView]}
|
{sidebarTitle[railView]}
|
||||||
</span>
|
</span>
|
||||||
{!hostManagerExpanded && !isMobile && (
|
{!isMobile && (
|
||||||
<>
|
<>
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
<Button
|
<Button
|
||||||
@@ -566,10 +569,7 @@ export function AppShell({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => {
|
onClick={() => setSidebarOpen(false)}
|
||||||
setSidebarOpen(false);
|
|
||||||
setHostManagerExpanded(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" />
|
<ChevronLeft className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -597,18 +597,14 @@ export function AppShell({
|
|||||||
<div
|
<div
|
||||||
className={`relative flex flex-col bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
|
className={`relative flex flex-col bg-sidebar shrink-0 overflow-hidden ${sidebarOpen ? `border-r transition-colors ${sidebarDragging ? "border-accent-brand/60" : "border-border"}` : ""}`}
|
||||||
style={{
|
style={{
|
||||||
width: sidebarOpen
|
width: sidebarOpen ? (sidebarEditing ? 560 : sidebarWidth) : 0,
|
||||||
? hostManagerExpanded && railView === "hosts"
|
|
||||||
? 720
|
|
||||||
: sidebarWidth
|
|
||||||
: 0,
|
|
||||||
transition: sidebarDragging ? "none" : "width 0.2s",
|
transition: sidebarDragging ? "none" : "width 0.2s",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sidebarHeader}
|
{sidebarHeader}
|
||||||
{sidebarPanelContent}
|
{sidebarPanelContent}
|
||||||
|
|
||||||
{sidebarOpen && !(hostManagerExpanded && railView === "hosts") && (
|
{sidebarOpen && !sidebarEditing && (
|
||||||
<div
|
<div
|
||||||
onMouseDown={onSidebarMouseDown}
|
onMouseDown={onSidebarMouseDown}
|
||||||
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
|
className={`absolute right-0 top-0 bottom-0 w-1 cursor-col-resize z-30 transition-colors ${sidebarDragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40"}`}
|
||||||
@@ -619,13 +615,7 @@ export function AppShell({
|
|||||||
|
|
||||||
{/* Mobile: sidebar as overlay sheet */}
|
{/* Mobile: sidebar as overlay sheet */}
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Sheet
|
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||||
open={sidebarOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setSidebarOpen(open);
|
|
||||||
if (!open) setHostManagerExpanded(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="left"
|
side="left"
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
|
|||||||
+15
-12
@@ -347,7 +347,7 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
platform: "desktop",
|
platform: "desktop",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
window.location.origin,
|
"*",
|
||||||
);
|
);
|
||||||
setWebviewAuthSuccess(true);
|
setWebviewAuthSuccess(true);
|
||||||
window.history.replaceState(
|
window.history.replaceState(
|
||||||
@@ -378,16 +378,19 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
}, [onLogin, t]);
|
}, [onLogin, t]);
|
||||||
|
|
||||||
const handleElectronAuthSuccess = useCallback(
|
const handleElectronAuthSuccess = useCallback(
|
||||||
async (previousJwt: string | null) => {
|
async (token: string | null) => {
|
||||||
try {
|
try {
|
||||||
const cookieReady = await window.electronAPI?.waitForSessionCookie?.(
|
if (!token) {
|
||||||
"jwt",
|
// No token in postMessage — fall back to waiting for the HttpOnly cookie
|
||||||
currentServerUrl,
|
const cookieReady = await window.electronAPI?.waitForSessionCookie?.(
|
||||||
previousJwt,
|
"jwt",
|
||||||
5000,
|
currentServerUrl,
|
||||||
);
|
null,
|
||||||
if (cookieReady && !cookieReady.success)
|
5000,
|
||||||
throw new Error(cookieReady.error || "Auth cookie not ready");
|
);
|
||||||
|
if (cookieReady && !cookieReady.success)
|
||||||
|
throw new Error(cookieReady.error || "Auth cookie not ready");
|
||||||
|
}
|
||||||
const meRes = await getUserInfo();
|
const meRes = await getUserInfo();
|
||||||
if (!meRes) throw new Error("Failed to get user info");
|
if (!meRes) throw new Error("Failed to get user info");
|
||||||
storeAuth(meRes.username || "");
|
storeAuth(meRes.username || "");
|
||||||
@@ -447,7 +450,7 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
platform: "desktop",
|
platform: "desktop",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
window.location.origin,
|
"*",
|
||||||
);
|
);
|
||||||
setWebviewAuthSuccess(true);
|
setWebviewAuthSuccess(true);
|
||||||
return;
|
return;
|
||||||
@@ -539,7 +542,7 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
platform: "desktop",
|
platform: "desktop",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
window.location.origin,
|
"*",
|
||||||
);
|
);
|
||||||
setWebviewAuthSuccess(true);
|
setWebviewAuthSuccess(true);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"keyPassword": "Key Password",
|
"keyPassword": "Key Password",
|
||||||
"sshKey": "SSH Key",
|
"sshKey": "SSH Key",
|
||||||
"uploadPrivateKeyFile": "Upload Private Key File"
|
"uploadPrivateKeyFile": "Upload Private Key File",
|
||||||
|
"searchCredentials": "Search credentials...",
|
||||||
|
"addCredential": "Add Credential"
|
||||||
},
|
},
|
||||||
"homepage": {
|
"homepage": {
|
||||||
"failedToLoadAlerts": "Failed to load alerts",
|
"failedToLoadAlerts": "Failed to load alerts",
|
||||||
@@ -138,6 +140,7 @@
|
|||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
"snippets": "Snippets",
|
"snippets": "Snippets",
|
||||||
"hostManager": "Host Manager",
|
"hostManager": "Host Manager",
|
||||||
|
"credentials": "Credentials",
|
||||||
"roleAdministrator": "Administrator",
|
"roleAdministrator": "Administrator",
|
||||||
"roleUser": "User"
|
"roleUser": "User"
|
||||||
},
|
},
|
||||||
@@ -536,6 +539,7 @@
|
|||||||
"loadingHost": "Loading host...",
|
"loadingHost": "Loading host...",
|
||||||
"hostNotFound": "Host not found",
|
"hostNotFound": "Host not found",
|
||||||
"searchHosts": "Search hosts...",
|
"searchHosts": "Search hosts...",
|
||||||
|
"addHost": "Add Host",
|
||||||
"guac": {
|
"guac": {
|
||||||
"connection": "Connection",
|
"connection": "Connection",
|
||||||
"authentication": "Authentication",
|
"authentication": "Authentication",
|
||||||
|
|||||||
@@ -865,6 +865,11 @@ export let dockerApi: AxiosInstance;
|
|||||||
// Pre-initialize with default values to avoid undefined errors during early mounting
|
// Pre-initialize with default values to avoid undefined errors during early mounting
|
||||||
initializeApiInstances();
|
initializeApiInstances();
|
||||||
|
|
||||||
|
let _resolveAppReady!: () => void;
|
||||||
|
export const appReadyPromise: Promise<void> = new Promise((resolve) => {
|
||||||
|
_resolveAppReady = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
function initializeApp() {
|
function initializeApp() {
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
Promise.all([getServerConfig(), getEmbeddedServerStatus()])
|
Promise.all([getServerConfig(), getEmbeddedServerStatus()])
|
||||||
@@ -895,9 +900,13 @@ function initializeApp() {
|
|||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
initializeApiInstances();
|
initializeApiInstances();
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
_resolveAppReady();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
initializeApiInstances();
|
initializeApiInstances();
|
||||||
|
_resolveAppReady();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ const PRIMARY_ITEMS: {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const MORE_ITEMS: { view: RailView; icon: React.ReactNode; title: string }[] = [
|
const MORE_ITEMS: { view: RailView; icon: React.ReactNode; title: string }[] = [
|
||||||
|
{
|
||||||
|
view: "credentials",
|
||||||
|
icon: <KeyRound className="size-4" />,
|
||||||
|
title: "Credentials",
|
||||||
|
},
|
||||||
{ view: "history", icon: <Clock className="size-4" />, title: "History" },
|
{ view: "history", icon: <Clock className="size-4" />, title: "History" },
|
||||||
{
|
{
|
||||||
view: "split-screen",
|
view: "split-screen",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type { SplitMode, ToolsTab } from "@/types/ui-types";
|
|||||||
|
|
||||||
export type RailView =
|
export type RailView =
|
||||||
| "hosts"
|
| "hosts"
|
||||||
|
| "credentials"
|
||||||
| "quick-connect"
|
| "quick-connect"
|
||||||
| ToolsTab
|
| ToolsTab
|
||||||
| "user-profile"
|
| "user-profile"
|
||||||
@@ -42,6 +43,11 @@ function buildRailButtons(
|
|||||||
): RailItem[] {
|
): RailItem[] {
|
||||||
return [
|
return [
|
||||||
{ view: "hosts", icon: <Server size={16} />, title: t("nav.hosts") },
|
{ view: "hosts", icon: <Server size={16} />, title: t("nav.hosts") },
|
||||||
|
{
|
||||||
|
view: "credentials",
|
||||||
|
icon: <KeyRound size={16} />,
|
||||||
|
title: t("nav.credentials"),
|
||||||
|
},
|
||||||
{ kind: "separator" },
|
{ kind: "separator" },
|
||||||
{
|
{
|
||||||
view: "quick-connect",
|
view: "quick-connect",
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
|
{!managerEditing && (
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1.5 shrink-0 border-b border-border/60">
|
||||||
|
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm flex-1 min-w-0">
|
||||||
|
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch("")}
|
||||||
|
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("host-manager:add-credential"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={t("credentials.addCredential")}
|
||||||
|
className="flex items-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 border border-accent-brand/30 rounded-sm shrink-0 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="size-3 shrink-0" />
|
||||||
|
{t("credentials.addCredential")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
<HostManager
|
||||||
|
initialSection="credentials"
|
||||||
|
hideListHeader
|
||||||
|
externalSearch={managerEditing ? undefined : search}
|
||||||
|
onEditingChange={handleEditingChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -99,6 +99,7 @@ import {
|
|||||||
subscribeTunnelStatuses,
|
subscribeTunnelStatuses,
|
||||||
connectTunnel,
|
connectTunnel,
|
||||||
disconnectTunnel,
|
disconnectTunnel,
|
||||||
|
getCredentialDetails,
|
||||||
} from "@/main-axios";
|
} from "@/main-axios";
|
||||||
import type { SSHHostWithStatus } from "@/main-axios";
|
import type { SSHHostWithStatus } from "@/main-axios";
|
||||||
|
|
||||||
@@ -5047,7 +5048,15 @@ function CredentialEditorView({
|
|||||||
{["password", "key"].map((m) => (
|
{["password", "key"].map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
onClick={() => setCredField("type", m as any)}
|
onClick={() =>
|
||||||
|
setCredForm((p) => ({
|
||||||
|
...p,
|
||||||
|
type: m as any,
|
||||||
|
value: "",
|
||||||
|
publicKey: "",
|
||||||
|
passphrase: "",
|
||||||
|
}))
|
||||||
|
}
|
||||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${type === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${type === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||||
>
|
>
|
||||||
{m === "key"
|
{m === "key"
|
||||||
@@ -5275,13 +5284,23 @@ export function HostManager({
|
|||||||
onCollapse,
|
onCollapse,
|
||||||
pendingEditId,
|
pendingEditId,
|
||||||
pendingAction,
|
pendingAction,
|
||||||
|
onEditingChange,
|
||||||
|
initialSection,
|
||||||
|
hideListHeader,
|
||||||
|
externalSearch,
|
||||||
}: {
|
}: {
|
||||||
onCollapse?: () => void;
|
onCollapse?: () => void;
|
||||||
pendingEditId?: MutableRefObject<string | null>;
|
pendingEditId?: MutableRefObject<string | null>;
|
||||||
pendingAction?: MutableRefObject<"add-host" | "add-credential" | null>;
|
pendingAction?: MutableRefObject<"add-host" | "add-credential" | null>;
|
||||||
|
onEditingChange?: (editing: boolean) => void;
|
||||||
|
initialSection?: "hosts" | "credentials";
|
||||||
|
hideListHeader?: boolean;
|
||||||
|
externalSearch?: string;
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [section, setSection] = useState<"hosts" | "credentials">("hosts");
|
const [section, setSection] = useState<"hosts" | "credentials">(
|
||||||
|
initialSection ?? "hosts",
|
||||||
|
);
|
||||||
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
|
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
|
||||||
const [editingCredential, setEditingCredential] = useState<
|
const [editingCredential, setEditingCredential] = useState<
|
||||||
Credential | "new" | null
|
Credential | "new" | null
|
||||||
@@ -5289,6 +5308,7 @@ export function HostManager({
|
|||||||
const [activeHostTab, setActiveHostTab] = useState("general");
|
const [activeHostTab, setActiveHostTab] = useState("general");
|
||||||
const [activeCredentialTab, setActiveCredentialTab] = useState("general");
|
const [activeCredentialTab, setActiveCredentialTab] = useState("general");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const effectiveSearch = externalSearch ?? searchQuery;
|
||||||
const [hosts, setHosts] = useState<Host[]>([]);
|
const [hosts, setHosts] = useState<Host[]>([]);
|
||||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
||||||
@@ -5501,16 +5521,16 @@ export function HostManager({
|
|||||||
const allHosts = hosts;
|
const allHosts = hosts;
|
||||||
const filteredHosts = allHosts.filter(
|
const filteredHosts = allHosts.filter(
|
||||||
(h) =>
|
(h) =>
|
||||||
h.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
h.name.toLowerCase().includes(effectiveSearch.toLowerCase()) ||
|
||||||
h.ip.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
h.ip.toLowerCase().includes(effectiveSearch.toLowerCase()) ||
|
||||||
h.tags?.some((tg) =>
|
h.tags?.some((tg) =>
|
||||||
tg.toLowerCase().includes(searchQuery.toLowerCase()),
|
tg.toLowerCase().includes(effectiveSearch.toLowerCase()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const filteredCredentials = credentials.filter(
|
const filteredCredentials = credentials.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
c.name.toLowerCase().includes(effectiveSearch.toLowerCase()) ||
|
||||||
c.username.toLowerCase().includes(searchQuery.toLowerCase()),
|
c.username.toLowerCase().includes(effectiveSearch.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
const folders = Array.from(new Set(allHosts.map((h) => h.folder))).sort();
|
const folders = Array.from(new Set(allHosts.map((h) => h.folder))).sort();
|
||||||
@@ -6193,10 +6213,14 @@ export function HostManager({
|
|||||||
|
|
||||||
const isEditing = !!editingHost || !!editingCredential;
|
const isEditing = !!editingHost || !!editingCredential;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onEditingChange?.(isEditing);
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
{/* Top bar: section switcher + actions */}
|
{/* Top bar: section switcher + actions */}
|
||||||
{!isEditing && (
|
{!isEditing && !hideListHeader && (
|
||||||
<div className="flex items-center gap-0 shrink-0 border-b border-border/60">
|
<div className="flex items-center gap-0 shrink-0 border-b border-border/60">
|
||||||
{/* Section tabs */}
|
{/* Section tabs */}
|
||||||
<button
|
<button
|
||||||
@@ -6414,30 +6438,32 @@ export function HostManager({
|
|||||||
renderEditorView()
|
renderEditorView()
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
{/* Search bar */}
|
{/* Search bar — hidden when parent supplies its own */}
|
||||||
<div className="px-2 py-1.5 shrink-0 border-b border-border/40">
|
{!hideListHeader && (
|
||||||
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60">
|
<div className="px-2 py-1.5 shrink-0 border-b border-border/40">
|
||||||
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60">
|
||||||
<input
|
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
||||||
value={searchQuery}
|
<input
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
value={searchQuery}
|
||||||
placeholder={
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
section === "hosts"
|
placeholder={
|
||||||
? t("hosts.searchHostsPlaceholder")
|
section === "hosts"
|
||||||
: t("hosts.searchCredentialsPlaceholder")
|
? 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"
|
}
|
||||||
/>
|
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
|
||||||
{searchQuery && (
|
/>
|
||||||
<button
|
{searchQuery && (
|
||||||
onClick={() => setSearchQuery("")}
|
<button
|
||||||
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
onClick={() => setSearchQuery("")}
|
||||||
>
|
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
<X className="size-3" />
|
>
|
||||||
</button>
|
<X className="size-3" />
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
{section === "hosts" && (
|
{section === "hosts" && (
|
||||||
@@ -6743,15 +6769,17 @@ export function HostManager({
|
|||||||
{cred.type === "key" ? "KEY" : "PWD"}
|
{cred.type === "key" ? "KEY" : "PWD"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] text-muted-foreground/50 truncate">
|
{(cred.username || usedByHosts.length > 0) && (
|
||||||
{cred.username}
|
<span className="text-[11px] text-muted-foreground/50 truncate">
|
||||||
{usedByHosts.length > 0 && (
|
{cred.username}
|
||||||
<span className="text-muted-foreground/30">
|
{usedByHosts.length > 0 && (
|
||||||
{" "}
|
<span className="text-muted-foreground/30">
|
||||||
· {usedByHosts.length}h
|
{cred.username ? " · " : ""}
|
||||||
</span>
|
{usedByHosts.length}h
|
||||||
)}
|
</span>
|
||||||
</span>
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{cred.tags && cred.tags.length > 0 && (
|
{cred.tags && cred.tags.length > 0 && (
|
||||||
<div className="flex items-center gap-0.5 mt-0.5 flex-wrap">
|
<div className="flex items-center gap-0.5 mt-0.5 flex-wrap">
|
||||||
{cred.tags.slice(0, 3).map((tag) => (
|
{cred.tags.slice(0, 3).map((tag) => (
|
||||||
@@ -6805,8 +6833,23 @@ export function HostManager({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted rounded transition-colors"
|
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted rounded transition-colors"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
setEditingCredential(cred);
|
try {
|
||||||
|
const full = await getCredentialDetails(
|
||||||
|
Number(cred.id),
|
||||||
|
);
|
||||||
|
setEditingCredential({
|
||||||
|
...cred,
|
||||||
|
value:
|
||||||
|
(full as any).password ??
|
||||||
|
(full as any).key ??
|
||||||
|
"",
|
||||||
|
passphrase:
|
||||||
|
(full as any).keyPassword ?? "",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setEditingCredential(cred);
|
||||||
|
}
|
||||||
setActiveCredentialTab("general");
|
setActiveCredentialTab("general");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,73 +1,67 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Search, Settings, X } from "lucide-react";
|
import { Plus, Search, X } from "lucide-react";
|
||||||
import { SidebarTree } from "@/sidebar/SidebarTree";
|
import { SidebarTree } from "@/sidebar/SidebarTree";
|
||||||
import { HostManager } from "@/sidebar/HostManager";
|
import { HostManager } from "@/sidebar/HostManager";
|
||||||
import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
||||||
import type { MutableRefObject } from "react";
|
|
||||||
|
|
||||||
export function HostsPanel({
|
export function HostsPanel({
|
||||||
expanded,
|
|
||||||
onExpand,
|
|
||||||
onCollapse,
|
|
||||||
pendingEditId,
|
|
||||||
pendingAction,
|
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
onEditHost,
|
onEditHost,
|
||||||
hostTree,
|
hostTree,
|
||||||
|
onEditingChange,
|
||||||
}: {
|
}: {
|
||||||
expanded: boolean;
|
|
||||||
onExpand: () => void;
|
|
||||||
onCollapse: () => void;
|
|
||||||
pendingEditId: MutableRefObject<string | null>;
|
|
||||||
pendingAction: MutableRefObject<"add-host" | "add-credential" | null>;
|
|
||||||
onOpenTab: (host: Host, type: TabType) => void;
|
onOpenTab: (host: Host, type: TabType) => void;
|
||||||
onEditHost: (host: Host) => void;
|
onEditHost: (host: Host) => void;
|
||||||
hostTree?: HostFolder;
|
hostTree?: HostFolder;
|
||||||
|
onEditingChange?: (editing: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [hostSearch, setHostSearch] = useState("");
|
const [hostSearch, setHostSearch] = useState("");
|
||||||
|
const [managerEditing, setManagerEditing] = useState(false);
|
||||||
|
|
||||||
if (expanded) {
|
function handleEditingChange(editing: boolean) {
|
||||||
return (
|
setManagerEditing(editing);
|
||||||
<HostManager
|
onEditingChange?.(editing);
|
||||||
onCollapse={onCollapse}
|
|
||||||
pendingEditId={pendingEditId}
|
|
||||||
pendingAction={pendingAction}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1.5 shrink-0 border-b border-border/60">
|
{!managerEditing && (
|
||||||
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm flex-1 min-w-0">
|
<div className="flex items-center gap-1.5 px-2 py-1.5 shrink-0 border-b border-border/60">
|
||||||
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm flex-1 min-w-0">
|
||||||
<input
|
<Search className="size-3 text-muted-foreground/60 shrink-0" />
|
||||||
value={hostSearch}
|
<input
|
||||||
onChange={(e) => setHostSearch(e.target.value)}
|
value={hostSearch}
|
||||||
placeholder={t("hosts.searchHosts")}
|
onChange={(e) => setHostSearch(e.target.value)}
|
||||||
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
|
placeholder={t("hosts.searchHosts")}
|
||||||
/>
|
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
|
||||||
{hostSearch && (
|
/>
|
||||||
<button
|
{hostSearch && (
|
||||||
onClick={() => setHostSearch("")}
|
<button
|
||||||
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
onClick={() => setHostSearch("")}
|
||||||
>
|
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
<X className="size-3" />
|
>
|
||||||
</button>
|
<X className="size-3" />
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
window.dispatchEvent(new CustomEvent("host-manager:add-host"))
|
||||||
|
}
|
||||||
|
title={t("hosts.addHost")}
|
||||||
|
className="flex items-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 border border-accent-brand/30 rounded-sm shrink-0 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="size-3 shrink-0" />
|
||||||
|
{t("hosts.addHost")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
)}
|
||||||
onClick={onExpand}
|
|
||||||
title="Manage Hosts"
|
<div
|
||||||
className="flex items-center gap-1 h-7 px-2 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted/60 border border-border/60 rounded-sm shrink-0 transition-colors"
|
className={`flex-1 min-h-0 overflow-y-auto py-1 ${managerEditing ? "hidden" : ""}`}
|
||||||
>
|
>
|
||||||
<Settings className="size-3 shrink-0" />
|
|
||||||
Manage
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto py-1">
|
|
||||||
<SidebarTree
|
<SidebarTree
|
||||||
children={hostTree?.children ?? []}
|
children={hostTree?.children ?? []}
|
||||||
onOpenTab={onOpenTab}
|
onOpenTab={onOpenTab}
|
||||||
@@ -75,6 +69,12 @@ export function HostsPanel({
|
|||||||
query={hostSearch.trim().toLowerCase()}
|
query={hostSearch.trim().toLowerCase()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={managerEditing ? "flex flex-col flex-1 min-h-0" : "hidden"}
|
||||||
|
>
|
||||||
|
<HostManager onEditingChange={handleEditingChange} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ export function HostItem({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEditHost();
|
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"
|
||||||
>
|
>
|
||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user