Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e74f65729c | ||
|
|
97f53ed87f | ||
|
|
ec4512eb06 | ||
|
|
93c1f1b427 | ||
|
|
58ccd4bfb9 | ||
|
|
2fb82e1cb7 | ||
|
|
159589a09f | ||
|
|
04e1ed569d | ||
|
|
38fb5e8dd4 | ||
|
|
6f2b27206a | ||
|
|
f6eb693fac | ||
|
|
32935e4e87 | ||
|
|
f55c21fc0e | ||
|
|
26d03ace3f | ||
|
|
d85709d42d | ||
|
|
5470e19ae0 | ||
|
|
cd2c18b77c | ||
|
|
7355e29b89 | ||
|
|
64686cc237 | ||
|
|
d65440ace7 | ||
|
|
2dbeddd9aa | ||
|
|
4758345448 | ||
|
|
4d3fa93083 | ||
|
|
2746aae274 | ||
|
|
a7b22b3580 |
81
App.tsx
81
App.tsx
@@ -21,6 +21,7 @@ import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
@@ -155,6 +156,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
|
||||
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
|
||||
const {
|
||||
theme,
|
||||
@@ -349,6 +352,76 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Passphrase request event listener for encrypted SSH keys
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPassphraseRequest) return;
|
||||
|
||||
const unsubscribe = bridge.onPassphraseRequest((request) => {
|
||||
console.log('[App] Passphrase request received:', request);
|
||||
setPassphraseQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
keyPath: request.keyPath,
|
||||
keyName: request.keyName,
|
||||
hostname: request.hostname,
|
||||
}]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle passphrase submit
|
||||
const handlePassphraseSubmit = useCallback((requestId: string, passphrase: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphrase) {
|
||||
void bridge.respondPassphrase(requestId, passphrase, false);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase cancel
|
||||
const handlePassphraseCancel = useCallback((requestId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphrase) {
|
||||
// Cancel = stop the entire passphrase flow
|
||||
void bridge.respondPassphrase(requestId, '', true);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase skip (skip this key, continue with others)
|
||||
const handlePassphraseSkip = useCallback((requestId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphraseSkip) {
|
||||
// Skip = skip this key but continue asking for others
|
||||
void bridge.respondPassphraseSkip(requestId);
|
||||
} else if (bridge?.respondPassphrase) {
|
||||
// Fallback for older API
|
||||
void bridge.respondPassphrase(requestId, '', false);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase timeout (request expired on backend)
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPassphraseTimeout) return;
|
||||
|
||||
const unsubscribe = bridge.onPassphraseTimeout((event) => {
|
||||
console.log('[App] Passphrase request timed out:', event.requestId);
|
||||
// Remove from queue - the modal will close automatically
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== event.requestId));
|
||||
// Show a toast notification to inform user
|
||||
toast.error('Passphrase request timed out. Please try connecting again.');
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debounce ref for moveFocus to prevent double-triggering when focus switches
|
||||
const lastMoveFocusTimeRef = useRef<number>(0);
|
||||
const MOVE_FOCUS_DEBOUNCE_MS = 200;
|
||||
@@ -1083,6 +1156,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
{keyboardInteractiveQueue.length - 1} more pending
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Passphrase Modal for encrypted SSH keys */}
|
||||
<PassphraseModal
|
||||
request={passphraseQueue[0] || null}
|
||||
onSubmit={handlePassphraseSubmit}
|
||||
onCancel={handlePassphraseCancel}
|
||||
onSkip={handlePassphraseSkip}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -313,6 +313,8 @@ const en: Messages = {
|
||||
'vault.groups.createDialog.desc': 'Create a new group for organizing hosts.',
|
||||
'vault.groups.renameDialogTitle': 'Rename Group',
|
||||
'vault.groups.renameDialog.desc': 'Rename an existing group.',
|
||||
'vault.groups.deleteDialogTitle': 'Delete Group',
|
||||
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
|
||||
'vault.groups.field.name': 'Group Name',
|
||||
'vault.groups.placeholder.example': 'e.g. Production',
|
||||
'vault.groups.parentLabel': 'Parent',
|
||||
@@ -328,6 +330,9 @@ const en: Messages = {
|
||||
'vault.hosts.connect': 'Connect',
|
||||
'vault.view.grid': 'Grid',
|
||||
'vault.view.list': 'List',
|
||||
'vault.view.tree': 'Tree',
|
||||
'vault.tree.expandAll': 'Expand All',
|
||||
'vault.tree.collapseAll': 'Collapse All',
|
||||
'vault.hosts.newHost': 'New Host',
|
||||
'vault.hosts.newGroup': 'New Group',
|
||||
'vault.hosts.import': 'Import',
|
||||
@@ -1162,6 +1167,14 @@ const en: Messages = {
|
||||
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Use "/" to create nested packages.',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': 'Rename Package',
|
||||
'snippets.renameDialog.currentPath': 'Current path: {path}',
|
||||
'snippets.renameDialog.placeholder': 'Enter new name',
|
||||
'snippets.renameDialog.error.empty': 'Package name cannot be empty',
|
||||
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
|
||||
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Connect to Serial Port',
|
||||
@@ -1216,6 +1229,16 @@ const en: Messages = {
|
||||
'keyboard.interactive.fillSaved': 'Fill with saved password',
|
||||
'keyboard.interactive.useSaved': 'Use saved',
|
||||
'keyboard.interactive.useSavedPassword': 'Use saved password',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
'passphrase.desc': 'Enter the passphrase for {keyName}',
|
||||
'passphrase.descWithHost': 'Enter the passphrase for {keyName} to connect to {hostname}',
|
||||
'passphrase.label': 'Passphrase',
|
||||
'passphrase.keyPath': 'Key',
|
||||
'passphrase.unlock': 'Unlock',
|
||||
'passphrase.unlocking': 'Unlocking...',
|
||||
'passphrase.skip': 'Skip',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -184,6 +184,8 @@ const zhCN: Messages = {
|
||||
'vault.groups.createDialog.desc': '创建新的分组用于组织主机。',
|
||||
'vault.groups.renameDialogTitle': '重命名分组',
|
||||
'vault.groups.renameDialog.desc': '重命名已有分组。',
|
||||
'vault.groups.deleteDialogTitle': '删除分组',
|
||||
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
|
||||
'vault.groups.field.name': '分组名称',
|
||||
'vault.groups.placeholder.example': '例如:Production',
|
||||
'vault.groups.parentLabel': '父级',
|
||||
@@ -199,6 +201,9 @@ const zhCN: Messages = {
|
||||
'vault.hosts.connect': '连接',
|
||||
'vault.view.grid': '网格',
|
||||
'vault.view.list': '列表',
|
||||
'vault.view.tree': '树形',
|
||||
'vault.tree.expandAll': '展开全部',
|
||||
'vault.tree.collapseAll': '折叠全部',
|
||||
'vault.hosts.newHost': '新建主机',
|
||||
'vault.hosts.newGroup': '新建分组',
|
||||
'vault.hosts.import': '导入',
|
||||
@@ -1151,6 +1156,14 @@ const zhCN: Messages = {
|
||||
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
|
||||
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': '重命名代码包',
|
||||
'snippets.renameDialog.currentPath': '当前路径:{path}',
|
||||
'snippets.renameDialog.placeholder': '输入新名称',
|
||||
'snippets.renameDialog.error.empty': '代码包名称不能为空',
|
||||
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
|
||||
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': '串口',
|
||||
'serial.modal.title': '连接串口',
|
||||
@@ -1205,6 +1218,16 @@ const zhCN: Messages = {
|
||||
'keyboard.interactive.fillSaved': '填入已保存的密码',
|
||||
'keyboard.interactive.useSaved': '使用已保存',
|
||||
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
'passphrase.desc': '请输入 {keyName} 的密码',
|
||||
'passphrase.descWithHost': '请输入 {keyName} 的密码以连接到 {hostname}',
|
||||
'passphrase.label': '密码',
|
||||
'passphrase.keyPath': '密钥',
|
||||
'passphrase.unlock': '解锁',
|
||||
'passphrase.unlocking': '解锁中...',
|
||||
'passphrase.skip': '跳过',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export type ViewMode = "grid" | "list";
|
||||
export type ViewMode = "grid" | "list" | "tree";
|
||||
|
||||
const isViewMode = (value: string | null): value is ViewMode =>
|
||||
value === "grid" || value === "list";
|
||||
value === "grid" || value === "list" || value === "tree";
|
||||
|
||||
export const useStoredViewMode = (
|
||||
storageKey: string,
|
||||
|
||||
47
application/state/useTreeExpandedState.ts
Normal file
47
application/state/useTreeExpandedState.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export const useTreeExpandedState = (storageKey: string) => {
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
if (stored) {
|
||||
try {
|
||||
const paths = JSON.parse(stored) as string[];
|
||||
return new Set(paths);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
return new Set();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const pathsArray = Array.from(expandedPaths);
|
||||
localStorageAdapter.writeString(storageKey, JSON.stringify(pathsArray));
|
||||
}, [storageKey, expandedPaths]);
|
||||
|
||||
const togglePath = (path: string) => {
|
||||
const newExpanded = new Set(expandedPaths);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedPaths(newExpanded);
|
||||
};
|
||||
|
||||
const expandAll = (allPaths: string[]) => {
|
||||
setExpandedPaths(new Set(allPaths));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedPaths(new Set());
|
||||
};
|
||||
|
||||
return {
|
||||
expandedPaths,
|
||||
togglePath,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
};
|
||||
};
|
||||
477
components/HostTreeView.tsx
Normal file
477
components/HostTreeView.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import { ChevronRight, Folder, FolderOpen, Monitor, Server, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
import { sanitizeHost } from '../domain/host';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface HostTreeViewProps {
|
||||
groupTree: GroupNode[];
|
||||
hosts: Host[];
|
||||
sortMode?: 'az' | 'za' | 'newest' | 'oldest';
|
||||
expandedPaths?: Set<string>;
|
||||
onTogglePath?: (path: string) => void;
|
||||
onExpandAll?: (paths: string[]) => void;
|
||||
onCollapseAll?: () => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: GroupNode;
|
||||
depth: number;
|
||||
sortMode: 'az' | 'za' | 'newest' | 'oldest';
|
||||
expandedPaths: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
}
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
depth,
|
||||
sortMode,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const hasChildren = node.children && Object.keys(node.children).length > 0;
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
|
||||
const childNodes = useMemo(() => {
|
||||
if (!node.children) return [];
|
||||
const nodes = Object.values(node.children) as unknown as GroupNode[];
|
||||
return nodes.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'az':
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
}, [node.children, sortMode]);
|
||||
|
||||
const sortedHosts = useMemo(() => {
|
||||
return [...node.hosts].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
case 'za':
|
||||
return b.label.localeCompare(a.label);
|
||||
case 'newest':
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
}, [node.hosts, sortMode]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Group Node */}
|
||||
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath) moveGroup(groupPath, node.path);
|
||||
}}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4 flex items-center justify-center">
|
||||
{(hasChildren || node.hosts.length > 0) && (
|
||||
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
|
||||
<ChevronRight size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
|
||||
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
|
||||
</div>
|
||||
<span className="truncate flex-1 font-semibold">{node.name}</span>
|
||||
{(node.hosts.length > 0 || hasChildren) && (
|
||||
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
|
||||
{node.hosts.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onNewHost(node.path)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.newHost")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onNewGroup(node.path)}>
|
||||
<Folder className="mr-2 h-4 w-4" /> {t("vault.hosts.newGroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditGroup(node.path)}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteGroup(node.path)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<CollapsibleContent>
|
||||
{/* Child Groups */}
|
||||
{childNodes.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
sortMode={sortMode}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={onToggle}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Hosts in this group */}
|
||||
{sortedHosts.map((host) => (
|
||||
<HostTreeItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
depth={depth + 1}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface HostTreeItemProps {
|
||||
host: Host;
|
||||
depth: number;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
}
|
||||
|
||||
const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
host,
|
||||
depth,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const safeHost = sanitizeHost(host);
|
||||
const tags = host.tags || [];
|
||||
const isTelnet = host.protocol === 'telnet';
|
||||
const displayUsername = isTelnet
|
||||
? (host.telnetUsername?.trim() || host.username?.trim() || '')
|
||||
: (host.username?.trim() || '');
|
||||
const displayPort = isTelnet
|
||||
? (host.telnetPort ?? host.port ?? 23)
|
||||
: (host.port ?? 22);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className="flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg"
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onClick={() => onConnect(safeHost)}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4" />
|
||||
<div className="mr-3 flex-shrink-0">
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{displayUsername}@{host.hostname}:{displayPort}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{host.protocol && host.protocol !== 'ssh' && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
|
||||
{host.protocol.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<span className="text-xs opacity-60">
|
||||
{tags.slice(0, 2).join(', ')}
|
||||
{tags.length > 2 && '...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onConnect(safeHost)}>
|
||||
<Monitor className="mr-2 h-4 w-4" /> {t("vault.hosts.connect")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.duplicate")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteHost(host)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
groupTree,
|
||||
hosts,
|
||||
sortMode = 'az',
|
||||
expandedPaths: externalExpandedPaths,
|
||||
onTogglePath: externalOnTogglePath,
|
||||
onExpandAll: externalOnExpandAll,
|
||||
onCollapseAll: externalOnCollapseAll,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Use external state if provided, otherwise use local persistent state
|
||||
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
|
||||
const expandedPaths = externalExpandedPaths || localTreeState.expandedPaths;
|
||||
const togglePath = externalOnTogglePath || localTreeState.togglePath;
|
||||
const expandAll = externalOnExpandAll || localTreeState.expandAll;
|
||||
const collapseAll = externalOnCollapseAll || localTreeState.collapseAll;
|
||||
|
||||
// Get all possible group paths for expand/collapse all functionality
|
||||
const getAllGroupPaths = (nodes: GroupNode[]): string[] => {
|
||||
const paths: string[] = [];
|
||||
const traverse = (nodeList: GroupNode[]) => {
|
||||
nodeList.forEach(node => {
|
||||
paths.push(node.path);
|
||||
if (node.children) {
|
||||
traverse(Object.values(node.children) as GroupNode[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
traverse(nodes);
|
||||
return paths;
|
||||
};
|
||||
|
||||
const allGroupPaths = useMemo(() => getAllGroupPaths(groupTree), [groupTree]);
|
||||
|
||||
const handleExpandAll = () => {
|
||||
expandAll(allGroupPaths);
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
collapseAll();
|
||||
};
|
||||
|
||||
// Get ungrouped hosts (hosts without a group or with empty group) and sort them
|
||||
const ungroupedHosts = useMemo(() => {
|
||||
const hosts_without_group = hosts.filter(host => !host.group || host.group === '');
|
||||
return hosts_without_group.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
case 'za':
|
||||
return b.label.localeCompare(a.label);
|
||||
case 'newest':
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
}, [hosts, sortMode]);
|
||||
|
||||
// Sort group tree based on sort mode
|
||||
const sortedGroupTree = useMemo(() => {
|
||||
return [...groupTree].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'az':
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
}, [groupTree, sortMode]);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* Expand/Collapse controls */}
|
||||
{groupTree.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleExpandAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Expand size={12} className="mr-1" />
|
||||
{t("vault.tree.expandAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCollapseAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Minimize2 size={12} className="mr-1" />
|
||||
{t("vault.tree.collapseAll")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group tree */}
|
||||
{sortedGroupTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
sortMode={sortMode}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={togglePath}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ungrouped hosts at root level */}
|
||||
{ungroupedHosts.map((host) => (
|
||||
<HostTreeItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
depth={0}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{ungroupedHosts.length === 0 && groupTree.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Server size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{t("vault.hosts.empty")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
169
components/PassphraseModal.tsx
Normal file
169
components/PassphraseModal.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Passphrase Modal
|
||||
* Modal for requesting passphrase for encrypted SSH keys
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export interface PassphraseRequest {
|
||||
requestId: string;
|
||||
keyPath: string;
|
||||
keyName: string;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
interface PassphraseModalProps {
|
||||
request: PassphraseRequest | null;
|
||||
onSubmit: (requestId: string, passphrase: string) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
onSkip?: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export const PassphraseModal: React.FC<PassphraseModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onSkip,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [showPassphrase, setShowPassphrase] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
setPassphrase("");
|
||||
setShowPassphrase(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || isSubmitting || !passphrase) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, passphrase);
|
||||
}, [request, passphrase, onSubmit, isSubmitting]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!request) return;
|
||||
onCancel(request.requestId);
|
||||
}, [request, onCancel]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
if (!request || !onSkip) return;
|
||||
onSkip(request.requestId);
|
||||
}, [request, onSkip]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting && passphrase) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, isSubmitting, passphrase]
|
||||
);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const keyDisplayName = request.keyName || request.keyPath.split("/").pop() || "SSH Key";
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{t("passphrase.title")}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{request.hostname
|
||||
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
|
||||
: t("passphrase.desc", { keyName: keyDisplayName })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="passphrase-input">
|
||||
{t("passphrase.label")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="passphrase-input"
|
||||
type={showPassphrase ? "text" : "password"}
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className="pr-10"
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("passphrase.keyPath")}: <code className="text-xs">{request.keyPath}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{onSkip && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("passphrase.skip")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || !passphrase}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("passphrase.unlocking")}
|
||||
</>
|
||||
) : (
|
||||
t("passphrase.unlock")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PassphraseModal;
|
||||
@@ -67,6 +67,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
|
||||
|
||||
// Rename package state
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [renamingPackagePath, setRenamingPackagePath] = useState<string | null>(null);
|
||||
const [renamePackageName, setRenamePackageName] = useState('');
|
||||
const [renameError, setRenameError] = useState('');
|
||||
|
||||
// Search, sort, and view mode state
|
||||
const [search, setSearch] = useState('');
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
@@ -144,23 +150,60 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const displayedPackages = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
const roots = packages
|
||||
// Separate absolute paths (starting with /) from relative paths
|
||||
const absolutePaths = packages.filter(p => p.startsWith('/'));
|
||||
const relativePaths = packages.filter(p => !p.startsWith('/'));
|
||||
|
||||
const results: { name: string; path: string; count: number }[] = [];
|
||||
|
||||
// Process relative paths (traditional behavior)
|
||||
const relativeRoots = relativePaths
|
||||
.map((p) => p.split('/')[0])
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(roots)).map((name) => {
|
||||
const path = name;
|
||||
const count = snippets.filter((s) => (s.package || '') === path).length;
|
||||
return { name, path, count };
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
|
||||
Array.from(new Set(relativeRoots)).forEach((name: string) => {
|
||||
const path: string = name;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name, path, count });
|
||||
});
|
||||
|
||||
// Process absolute paths - show them as separate roots with "/" prefix
|
||||
const absoluteRoots = absolutePaths
|
||||
.map((p) => {
|
||||
const cleanPath = p.substring(1); // Remove leading slash
|
||||
const firstSegment = cleanPath.split('/')[0];
|
||||
return firstSegment;
|
||||
})
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
|
||||
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
|
||||
const path: string = `/${name}`;
|
||||
const displayName: string = `/${name}`; // Show with leading slash to distinguish
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name: displayName, path, count });
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const prefix = selectedPackage + '/';
|
||||
const children = packages
|
||||
.filter((p) => p.startsWith(prefix))
|
||||
.map((p) => p.replace(prefix, '').split('/')[0])
|
||||
.filter(Boolean);
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
return Array.from(new Set(children)).map((name) => {
|
||||
const path = `${selectedPackage}/${name}`;
|
||||
const count = snippets.filter((s) => (s.package || '') === path).length;
|
||||
// Count snippets in this package AND all nested packages
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
return { name, path, count };
|
||||
});
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
@@ -191,28 +234,76 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (!selectedPackage) return [];
|
||||
const isAbsolute = selectedPackage.startsWith('/');
|
||||
const parts = selectedPackage.split('/').filter(Boolean);
|
||||
return parts.map((name, idx) => ({ name, path: parts.slice(0, idx + 1).join('/') }));
|
||||
return parts.map((name, idx) => {
|
||||
const pathSegments = parts.slice(0, idx + 1);
|
||||
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
|
||||
return { name, path };
|
||||
});
|
||||
}, [selectedPackage]);
|
||||
|
||||
const createPackage = () => {
|
||||
const name = newPackageName.trim();
|
||||
if (!name) return;
|
||||
const full = selectedPackage ? `${selectedPackage}/${name}` : name;
|
||||
if (!packages.includes(full)) onPackagesChange([...packages, full]);
|
||||
|
||||
// Allow leading slash and validate the rest - allow hyphens anywhere in package names
|
||||
if (!/^\/?([\w-]+(\/[\w-]+)*)\/?$/.test(name)) {
|
||||
// Could add toast notification here for invalid characters
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize path construction to avoid double slashes
|
||||
let full: string;
|
||||
if (selectedPackage) {
|
||||
// Strip leading slash from name when we're inside a package to avoid double slashes
|
||||
const normalizedName = name.startsWith('/') ? name.substring(1) : name;
|
||||
full = `${selectedPackage}/${normalizedName}`;
|
||||
} else {
|
||||
// At root level, preserve the leading slash if user intended it
|
||||
full = name;
|
||||
}
|
||||
|
||||
// Strip trailing slash to ensure consistent path handling
|
||||
if (full.endsWith('/')) {
|
||||
full = full.slice(0, -1);
|
||||
}
|
||||
|
||||
// Check for duplicate package names (case-insensitive)
|
||||
const existingPackage = packages.find(p => p.toLowerCase() === full.toLowerCase());
|
||||
if (existingPackage) {
|
||||
// Could add toast notification here for duplicate package
|
||||
return;
|
||||
}
|
||||
|
||||
onPackagesChange([...packages, full]);
|
||||
setNewPackageName('');
|
||||
setIsPackageDialogOpen(false);
|
||||
};
|
||||
|
||||
const deletePackage = (path: string) => {
|
||||
// Remove the package and all its children
|
||||
const keep = packages.filter((p) => !(p === path || p.startsWith(path + '/')));
|
||||
|
||||
// Move all snippets from deleted packages to root
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === path || s.package.startsWith(path + '/')) return { ...s, package: '' };
|
||||
if (s.package === path || s.package.startsWith(path + '/')) {
|
||||
return { ...s, package: '' };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
// Update packages first, then save snippets
|
||||
onPackagesChange(keep);
|
||||
updatedSnippets.forEach(onSave);
|
||||
|
||||
// Only save snippets that were actually modified
|
||||
const modifiedSnippets = updatedSnippets.filter((s, index) =>
|
||||
s.package !== snippets[index].package
|
||||
);
|
||||
modifiedSnippets.forEach(onSave);
|
||||
|
||||
// Reset selected package if it was deleted
|
||||
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
|
||||
setSelectedPackage(null);
|
||||
}
|
||||
@@ -220,24 +311,125 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const movePackage = (source: string, target: string | null) => {
|
||||
const name = source.split('/').pop() || '';
|
||||
const newPath = target ? `${target}/${name}` : name;
|
||||
const isAbsolute = source.startsWith('/');
|
||||
const newPath = target ? `${target}/${name}` : (isAbsolute ? `/${name}` : name);
|
||||
if (newPath === source || newPath.startsWith(source + '/')) return;
|
||||
|
||||
// Check if target path already exists
|
||||
if (packages.includes(newPath)) return;
|
||||
|
||||
const updatedPackages = packages.map((p) => {
|
||||
if (p === source) return newPath;
|
||||
if (p.startsWith(source + '/')) return p.replace(source, newPath);
|
||||
// Use more precise replacement to avoid substring issues
|
||||
if (p.startsWith(source + '/')) {
|
||||
return newPath + p.substring(source.length);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === source) return { ...s, package: newPath };
|
||||
if (s.package.startsWith(source + '/')) return { ...s, package: s.package.replace(source, newPath) };
|
||||
// Use more precise replacement to avoid substring issues
|
||||
if (s.package.startsWith(source + '/')) {
|
||||
return { ...s, package: newPath + s.package.substring(source.length) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
if (selectedPackage === source) setSelectedPackage(newPath);
|
||||
};
|
||||
|
||||
const openRenameDialog = (path: string) => {
|
||||
const name = path.split('/').pop() || '';
|
||||
setRenamingPackagePath(path);
|
||||
setRenamePackageName(name);
|
||||
setRenameError('');
|
||||
setIsRenameDialogOpen(true);
|
||||
};
|
||||
|
||||
const renamePackage = () => {
|
||||
if (!renamingPackagePath) return;
|
||||
|
||||
const newName = renamePackageName.trim();
|
||||
|
||||
// Validate: empty name
|
||||
if (!newName) {
|
||||
setRenameError(t('snippets.renameDialog.error.empty'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: same rules as createPackage - only allow letters, numbers, hyphens, underscores
|
||||
// Since we're renaming a single segment (no slashes allowed), use the segment-level pattern
|
||||
if (!/^[\w-]+$/.test(newName)) {
|
||||
setRenameError(t('snippets.renameDialog.error.invalidChars'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build new path
|
||||
const parts = renamingPackagePath.split('/');
|
||||
parts[parts.length - 1] = newName;
|
||||
const newPath = parts.join('/');
|
||||
|
||||
// Validate: same name
|
||||
if (newPath === renamingPackagePath) {
|
||||
setIsRenameDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: duplicate (case-insensitive)
|
||||
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
|
||||
if (existingPackage) {
|
||||
setRenameError(t('snippets.renameDialog.error.duplicate'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Update all packages with this path or nested under it
|
||||
const updatedPackages = packages.map((p) => {
|
||||
if (p === renamingPackagePath) return newPath;
|
||||
if (p.startsWith(renamingPackagePath + '/')) {
|
||||
return newPath + p.substring(renamingPackagePath.length);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
// Update all snippets with this package or nested under it
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === renamingPackagePath) return { ...s, package: newPath };
|
||||
if (s.package.startsWith(renamingPackagePath + '/')) {
|
||||
return { ...s, package: newPath + s.package.substring(renamingPackagePath.length) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
|
||||
// Update selected package if it was renamed
|
||||
if (selectedPackage === renamingPackagePath) {
|
||||
setSelectedPackage(newPath);
|
||||
} else if (selectedPackage?.startsWith(renamingPackagePath + '/')) {
|
||||
setSelectedPackage(newPath + selectedPackage.substring(renamingPackagePath.length));
|
||||
}
|
||||
|
||||
// Update editingSnippet.package if it's in the renamed package (fixes stale state when editing)
|
||||
if (editingSnippet.package) {
|
||||
if (editingSnippet.package === renamingPackagePath) {
|
||||
setEditingSnippet(prev => ({ ...prev, package: newPath }));
|
||||
} else if (editingSnippet.package.startsWith(renamingPackagePath + '/')) {
|
||||
setEditingSnippet(prev => ({
|
||||
...prev,
|
||||
package: newPath + prev.package!.substring(renamingPackagePath.length)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
setIsRenameDialogOpen(false);
|
||||
};
|
||||
|
||||
const moveSnippet = (id: string, pkg: string | null) => {
|
||||
const sn = snippets.find((s) => s.id === id);
|
||||
if (!sn) return;
|
||||
@@ -246,11 +438,36 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
// Package options for Combobox
|
||||
const packageOptions: ComboboxOption[] = useMemo(() => {
|
||||
return packages.map(p => ({
|
||||
value: p,
|
||||
label: p.includes('/') ? p.split('/').pop()! : p,
|
||||
sublabel: p.includes('/') ? p : undefined,
|
||||
}));
|
||||
// Generate all possible parent paths for each package
|
||||
const allPaths = new Set<string>();
|
||||
|
||||
packages.forEach(pkg => {
|
||||
// Add the full package path
|
||||
allPaths.add(pkg);
|
||||
|
||||
// Add all parent paths
|
||||
const parts = pkg.split('/').filter(Boolean);
|
||||
const isAbsolute = pkg.startsWith('/');
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const parentPath = (isAbsolute ? '/' : '') + parts.slice(0, i).join('/');
|
||||
allPaths.add(parentPath);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(allPaths)
|
||||
.sort((a, b) => {
|
||||
// Sort by depth first (shorter paths first), then alphabetically
|
||||
const depthA = (a.match(/\//g) || []).length;
|
||||
const depthB = (b.match(/\//g) || []).length;
|
||||
if (depthA !== depthB) return depthA - depthB;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map(p => ({
|
||||
value: p,
|
||||
label: p.includes('/') ? p.split('/').pop()! : p,
|
||||
sublabel: p.includes('/') ? p : undefined,
|
||||
}));
|
||||
}, [packages]);
|
||||
|
||||
// Shell history lazy loading
|
||||
@@ -354,7 +571,13 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<Combobox
|
||||
options={packageOptions}
|
||||
value={editingSnippet.package || selectedPackage || ''}
|
||||
onValueChange={(val) => setEditingSnippet({ ...editingSnippet, package: val })}
|
||||
onValueChange={(val) => {
|
||||
setEditingSnippet({ ...editingSnippet, package: val });
|
||||
// If selecting an implicit parent path, persist it to packages
|
||||
if (val && !packages.includes(val)) {
|
||||
onPackagesChange([...packages, val]);
|
||||
}
|
||||
}}
|
||||
placeholder={t('snippets.field.packagePlaceholder')}
|
||||
allowCreate={true}
|
||||
onCreateNew={(val) => {
|
||||
@@ -624,6 +847,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => setSelectedPackage(pkg.path)}>{t('action.open')}</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openRenameDialog(pkg.path)}>{t('common.rename')}</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => deletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -729,6 +953,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
value={newPackageName}
|
||||
onChange={(e) => setNewPackageName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
|
||||
pattern="^/?([\w-]+(/[\w-]+)*)?/?$"
|
||||
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
|
||||
</div>
|
||||
@@ -742,6 +968,40 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rename Package Dialog */}
|
||||
{isRenameDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<Card className="w-full max-w-sm p-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{t('snippets.renameDialog.title')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('snippets.renameDialog.currentPath', { path: renamingPackagePath })}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('field.name')}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t('snippets.renameDialog.placeholder')}
|
||||
value={renamePackageName}
|
||||
onChange={(e) => {
|
||||
setRenamePackageName(e.target.value);
|
||||
setRenameError('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && renamePackage()}
|
||||
/>
|
||||
{renameError && (
|
||||
<p className="text-[11px] text-destructive">{renameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setIsRenameDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={renamePackage}>{t('common.rename')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Panel */}
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Key,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Network,
|
||||
Plug,
|
||||
Plus,
|
||||
Search,
|
||||
@@ -25,10 +26,11 @@ import {
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { sanitizeHost } from "../domain/host";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from "../infrastructure/config/storageKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
ConnectionLog,
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import HostDetailsPanel from "./HostDetailsPanel";
|
||||
import { HostTreeView } from "./HostTreeView";
|
||||
import KeychainManager from "./KeychainManager";
|
||||
import KnownHostsManager from "./KnownHostsManager";
|
||||
import PortForwarding from "./PortForwardingNew";
|
||||
@@ -166,6 +169,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [renameGroupError, setRenameGroupError] = useState<string | null>(null);
|
||||
const [isImportOpen, setIsImportOpen] = useState(false);
|
||||
const [isSerialModalOpen, setIsSerialModalOpen] = useState(false);
|
||||
const [isDeleteGroupOpen, setIsDeleteGroupOpen] = useState(false);
|
||||
const [deleteTargetPath, setDeleteTargetPath] = useState<string | null>(null);
|
||||
|
||||
// Handle external navigation requests
|
||||
useEffect(() => {
|
||||
@@ -180,12 +185,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
|
||||
"grid",
|
||||
);
|
||||
const treeExpandedState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
const [sortMode, setSortMode] = useState<SortMode>("az");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
||||
// Host panel state (local to hosts section)
|
||||
const [isHostPanelOpen, setIsHostPanelOpen] = useState(false);
|
||||
const [editingHost, setEditingHost] = useState<Host | null>(null);
|
||||
const [newHostGroupPath, setNewHostGroupPath] = useState<string | null>(null);
|
||||
|
||||
// Quick connect state
|
||||
const [quickConnectTarget, setQuickConnectTarget] = useState<{
|
||||
@@ -296,6 +303,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
const handleNewHost = useCallback(() => {
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
setIsHostPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -540,6 +548,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return root;
|
||||
}, [hosts, customGroups]);
|
||||
|
||||
|
||||
const findGroupNode = (path: string | null): GroupNode | null => {
|
||||
if (!path)
|
||||
return {
|
||||
@@ -606,6 +615,79 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return filtered;
|
||||
}, [hosts, selectedGroupPath, search, selectedTags, sortMode]);
|
||||
|
||||
// For tree view: apply search, tag filter, and sorting, but not group filtering
|
||||
const treeViewHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(h) =>
|
||||
h.label.toLowerCase().includes(s) ||
|
||||
h.hostname.toLowerCase().includes(s) ||
|
||||
h.tags.some((t) => t.toLowerCase().includes(s)),
|
||||
);
|
||||
}
|
||||
// Apply tag filter
|
||||
if (selectedTags.length > 0) {
|
||||
filtered = filtered.filter((h) =>
|
||||
selectedTags.some((t) => h.tags?.includes(t)),
|
||||
);
|
||||
}
|
||||
// Apply sorting
|
||||
filtered = [...filtered].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case "az":
|
||||
return a.label.localeCompare(b.label);
|
||||
case "za":
|
||||
return b.label.localeCompare(a.label);
|
||||
case "newest":
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case "oldest":
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}, [hosts, search, selectedTags, sortMode]);
|
||||
|
||||
// Create a separate group tree for tree view that uses filtered hosts
|
||||
const buildTreeViewGroupTree = useMemo<Record<string, GroupNode>>(() => {
|
||||
const root: Record<string, GroupNode> = {};
|
||||
const insertPath = (path: string, host?: Host) => {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
let currentLevel = root;
|
||||
let currentPath = "";
|
||||
parts.forEach((part, index) => {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
if (!currentLevel[part]) {
|
||||
currentLevel[part] = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
children: {},
|
||||
hosts: [],
|
||||
};
|
||||
}
|
||||
if (host && index === parts.length - 1)
|
||||
currentLevel[part].hosts.push(host);
|
||||
currentLevel = currentLevel[part].children;
|
||||
});
|
||||
};
|
||||
customGroups.forEach((path) => insertPath(path));
|
||||
// Use filtered hosts (treeViewHosts) instead of all hosts to respect search/tag filters
|
||||
treeViewHosts.forEach((host) => {
|
||||
if (host.group && host.group.trim() !== "") {
|
||||
insertPath(host.group, host);
|
||||
}
|
||||
});
|
||||
return root;
|
||||
}, [treeViewHosts, customGroups]);
|
||||
|
||||
// Create tree view specific group tree that excludes ungrouped hosts
|
||||
const treeViewGroupTree = useMemo<GroupNode[]>(() => {
|
||||
return (Object.values(buildTreeViewGroupTree) as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [buildTreeViewGroupTree]);
|
||||
|
||||
// Compute all unique tags across all hosts
|
||||
const allTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
@@ -986,8 +1068,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 app-no-drag">
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
) : viewMode === "list" ? (
|
||||
<List size={16} />
|
||||
) : (
|
||||
<Network size={16} />
|
||||
)}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
@@ -1007,6 +1091,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<List size={14} /> {t("vault.view.list")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "tree" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("tree")}
|
||||
>
|
||||
<Network size={14} /> {t("vault.view.tree")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
<TagFilterDropdown
|
||||
@@ -1111,43 +1202,45 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
{currentSection === "hosts" && (
|
||||
<>
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<button
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => setSelectedGroupPath(null)}
|
||||
>
|
||||
{t("vault.hosts.allHosts")}
|
||||
</button>
|
||||
{selectedGroupPath &&
|
||||
selectedGroupPath
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((part, idx, arr) => {
|
||||
const crumbPath = arr.slice(0, idx + 1).join("/");
|
||||
const isLast = idx === arr.length - 1;
|
||||
return (
|
||||
<span
|
||||
key={crumbPath}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground">›</span>
|
||||
<button
|
||||
className={cn(
|
||||
isLast
|
||||
? "text-foreground font-semibold"
|
||||
: "text-primary hover:underline",
|
||||
)}
|
||||
onClick={() =>
|
||||
setSelectedGroupPath(crumbPath)
|
||||
}
|
||||
{viewMode !== "tree" && (
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<button
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => setSelectedGroupPath(null)}
|
||||
>
|
||||
{t("vault.hosts.allHosts")}
|
||||
</button>
|
||||
{selectedGroupPath &&
|
||||
selectedGroupPath
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((part, idx, arr) => {
|
||||
const crumbPath = arr.slice(0, idx + 1).join("/");
|
||||
const isLast = idx === arr.length - 1;
|
||||
return (
|
||||
<span
|
||||
key={crumbPath}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{displayedGroups.length > 0 && (
|
||||
<span className="text-muted-foreground">›</span>
|
||||
<button
|
||||
className={cn(
|
||||
isLast
|
||||
? "text-foreground font-semibold"
|
||||
: "text-primary hover:underline",
|
||||
)}
|
||||
onClick={() =>
|
||||
setSelectedGroupPath(crumbPath)
|
||||
}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{viewMode !== "tree" && displayedGroups.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
@@ -1159,26 +1252,27 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
displayedGroups.length === 0 ? "hidden" : "",
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, selectedGroupPath);
|
||||
if (groupPath && selectedGroupPath !== null)
|
||||
moveGroup(groupPath, selectedGroupPath);
|
||||
}}
|
||||
>
|
||||
{viewMode !== "tree" && (
|
||||
<div
|
||||
className={cn(
|
||||
displayedGroups.length === 0 ? "hidden" : "",
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, selectedGroupPath);
|
||||
if (groupPath && selectedGroupPath !== null)
|
||||
moveGroup(groupPath, selectedGroupPath);
|
||||
}}
|
||||
>
|
||||
{displayedGroups.map((node) => (
|
||||
<ContextMenu key={node.path}>
|
||||
<ContextMenuTrigger asChild>
|
||||
@@ -1257,6 +1351,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
@@ -1266,120 +1361,161 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("vault.hosts.header.entries", { count: displayedHosts.length })}
|
||||
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : displayedHosts.length })}
|
||||
</span>
|
||||
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
|
||||
{t("vault.hosts.header.live", { count: sessions.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
{displayedHosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const distroBadge = {
|
||||
text: (safeHost.os || "L")[0].toUpperCase(),
|
||||
label: safeHost.distro || safeHost.os || "Linux",
|
||||
};
|
||||
return (
|
||||
<ContextMenu key={host.id}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onClick={() => handleHostConnect(safeHost)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<DistroAvatar
|
||||
host={safeHost}
|
||||
fallback={distroBadge.text}
|
||||
/>
|
||||
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
|
||||
<div className="text-sm font-semibold truncate leading-5">
|
||||
{safeHost.label}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "list" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
</>
|
||||
|
||||
{viewMode === "tree" ? (
|
||||
<HostTreeView
|
||||
groupTree={treeViewGroupTree}
|
||||
hosts={treeViewHosts} // Use filtered and sorted hosts for tree view
|
||||
sortMode={sortMode}
|
||||
expandedPaths={treeExpandedState.expandedPaths}
|
||||
onTogglePath={treeExpandedState.togglePath}
|
||||
onExpandAll={treeExpandedState.expandAll}
|
||||
onCollapseAll={treeExpandedState.collapseAll}
|
||||
onConnect={handleHostConnect}
|
||||
onEditHost={handleEditHost}
|
||||
onDuplicateHost={handleDuplicateHost}
|
||||
onDeleteHost={(host) => onDeleteHost(host.id)}
|
||||
onCopyCredentials={handleCopyCredentials}
|
||||
onNewHost={(groupPath) => {
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(groupPath || null);
|
||||
setIsHostPanelOpen(true);
|
||||
}}
|
||||
onNewGroup={(parentPath) => {
|
||||
setTargetParentPath(parentPath || null);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
onEditGroup={(groupPath) => {
|
||||
setRenameTargetPath(groupPath);
|
||||
const groupName = groupPath.split('/').pop() || '';
|
||||
setRenameGroupName(groupName);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
onDeleteGroup={(groupPath) => {
|
||||
setDeleteTargetPath(groupPath);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
{displayedHosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const distroBadge = {
|
||||
text: (safeHost.os || "L")[0].toUpperCase(),
|
||||
label: safeHost.distro || safeHost.os || "Linux",
|
||||
};
|
||||
return (
|
||||
<ContextMenu key={host.id}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onClick={() => handleHostConnect(safeHost)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<DistroAvatar
|
||||
host={safeHost}
|
||||
fallback={distroBadge.text}
|
||||
/>
|
||||
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
|
||||
<div className="text-sm font-semibold truncate leading-5">
|
||||
{safeHost.label}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "list" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleHostConnect(host)}
|
||||
>
|
||||
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleEditHost(host)}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleDuplicateHost(host)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleCopyCredentials(host)}
|
||||
>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteHost(host.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
{displayedHosts.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleHostConnect(host)}
|
||||
>
|
||||
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleEditHost(host)}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleDuplicateHost(host)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleCopyCredentials(host)}
|
||||
>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteHost(host.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
{displayedHosts.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your hosts
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Save hosts to quickly connect to your servers, VMs,
|
||||
and containers.
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your hosts
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Save hosts to quickly connect to your servers, VMs,
|
||||
and containers.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
@@ -1506,7 +1642,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
allTags={allTags}
|
||||
allHosts={hosts}
|
||||
defaultGroup={editingHost ? undefined : selectedGroupPath}
|
||||
defaultGroup={editingHost ? undefined : (newHostGroupPath || selectedGroupPath)}
|
||||
onSave={(host) => {
|
||||
// Check if host already exists in the list (for updates vs. new/duplicate)
|
||||
const hostExists = hosts.some((h) => h.id === host.id);
|
||||
@@ -1517,10 +1653,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}}
|
||||
onCreateGroup={(groupPath) => {
|
||||
onUpdateCustomGroups(
|
||||
@@ -1647,6 +1785,49 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isDeleteGroupOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeleteGroupOpen(open);
|
||||
if (!open) {
|
||||
setDeleteTargetPath(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("vault.groups.deleteDialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("vault.groups.deleteDialog.desc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{deleteTargetPath && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("vault.groups.pathLabel")}:{" "}
|
||||
<span className="font-mono">{deleteTargetPath}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setIsDeleteGroupOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (deleteTargetPath) {
|
||||
deleteGroupPath(deleteTargetPath);
|
||||
}
|
||||
setIsDeleteGroupOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ImportVaultDialog
|
||||
open={isImportOpen}
|
||||
onOpenChange={setIsImportOpen}
|
||||
|
||||
@@ -6,19 +6,23 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { execSync } = require("node:child_process");
|
||||
const { exec } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Check if a file is hidden on Windows using the attrib command
|
||||
* Returns true if the file has the hidden attribute set
|
||||
* Uses async exec to avoid blocking the main process
|
||||
*/
|
||||
function isWindowsHiddenFile(filePath) {
|
||||
async function isWindowsHiddenFile(filePath) {
|
||||
if (process.platform !== "win32") return false;
|
||||
try {
|
||||
const output = execSync(`attrib "${filePath}"`, { encoding: "utf8" });
|
||||
const { stdout } = await execAsync(`attrib "${filePath}"`);
|
||||
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
|
||||
// The attributes appear in the first ~10 characters before the path
|
||||
const attrPart = output.substring(0, output.indexOf(filePath)).toUpperCase();
|
||||
const attrPart = stdout.substring(0, stdout.indexOf(filePath)).toUpperCase();
|
||||
return attrPart.includes("H");
|
||||
} catch (err) {
|
||||
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
|
||||
@@ -67,7 +71,7 @@ async function listLocalDir(event, payload) {
|
||||
}
|
||||
|
||||
// Check for Windows hidden attribute
|
||||
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
|
||||
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
|
||||
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
@@ -86,7 +90,7 @@ async function listLocalDir(event, payload) {
|
||||
const lstat = await fs.promises.lstat(fullPath);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Broken symlink
|
||||
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
|
||||
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
|
||||
result[i] = {
|
||||
name: brokenEntry.name,
|
||||
type: "symlink",
|
||||
|
||||
141
electron/bridges/passphraseHandler.cjs
Normal file
141
electron/bridges/passphraseHandler.cjs
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Passphrase Handler - Handles passphrase requests for encrypted SSH keys
|
||||
* This module provides a mechanism to request passphrase input from the user
|
||||
* when encountering encrypted default SSH keys in ~/.ssh
|
||||
*/
|
||||
|
||||
// Passphrase request pending map
|
||||
// Map of requestId -> { resolveCallback, rejectCallback, webContentsId, keyPath, createdAt, timeoutId }
|
||||
const passphraseRequests = new Map();
|
||||
|
||||
// TTL for abandoned requests (2 minutes)
|
||||
const REQUEST_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Generate a unique request ID for passphrase requests
|
||||
*/
|
||||
function generateRequestId(prefix = 'pp') {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request passphrase from user via IPC
|
||||
* @param {Object} sender - Electron webContents sender
|
||||
* @param {string} keyPath - Path to the encrypted key
|
||||
* @param {string} keyName - Name of the key (e.g., id_rsa)
|
||||
* @param {string} [hostname] - Optional hostname for context
|
||||
* @returns {Promise<{ passphrase?: string, cancelled?: boolean, skipped?: boolean } | null>}
|
||||
*/
|
||||
function requestPassphrase(sender, keyPath, keyName, hostname) {
|
||||
return new Promise((resolve) => {
|
||||
if (!sender || sender.isDestroyed()) {
|
||||
console.warn('[Passphrase] Sender is destroyed, cannot request passphrase');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = generateRequestId();
|
||||
|
||||
// Set up TTL timeout to clean up abandoned requests
|
||||
const timeoutId = setTimeout(() => {
|
||||
const pending = passphraseRequests.get(requestId);
|
||||
if (pending) {
|
||||
console.warn(`[Passphrase] Request ${requestId} timed out after ${REQUEST_TTL_MS / 1000}s`);
|
||||
passphraseRequests.delete(requestId);
|
||||
|
||||
// Notify renderer to close the modal
|
||||
try {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send('netcatty:passphrase-timeout', { requestId });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Passphrase] Failed to send timeout notification:', err.message);
|
||||
}
|
||||
|
||||
resolve(null);
|
||||
}
|
||||
}, REQUEST_TTL_MS);
|
||||
|
||||
passphraseRequests.set(requestId, {
|
||||
resolveCallback: resolve,
|
||||
webContentsId: sender.id,
|
||||
keyPath,
|
||||
keyName,
|
||||
createdAt: Date.now(),
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
console.log(`[Passphrase] Requesting passphrase for ${keyName} (${requestId})`);
|
||||
|
||||
try {
|
||||
sender.send('netcatty:passphrase-request', {
|
||||
requestId,
|
||||
keyPath,
|
||||
keyName,
|
||||
hostname,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Passphrase] Failed to send passphrase request:', err);
|
||||
passphraseRequests.delete(requestId);
|
||||
clearTimeout(timeoutId);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle passphrase response from renderer
|
||||
*/
|
||||
function handleResponse(_event, payload) {
|
||||
const { requestId, passphrase, cancelled, skipped } = payload;
|
||||
const pending = passphraseRequests.get(requestId);
|
||||
|
||||
if (!pending) {
|
||||
console.warn(`[Passphrase] No pending request for ${requestId}`);
|
||||
return { success: false, error: 'Request not found' };
|
||||
}
|
||||
|
||||
// Clear the TTL timeout
|
||||
if (pending.timeoutId) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
}
|
||||
|
||||
passphraseRequests.delete(requestId);
|
||||
|
||||
if (cancelled) {
|
||||
// User clicked Cancel - stop the entire passphrase flow
|
||||
console.log(`[Passphrase] Request ${requestId} cancelled by user`);
|
||||
pending.resolveCallback({ cancelled: true });
|
||||
} else if (skipped) {
|
||||
// User clicked Skip - skip this key but continue with others
|
||||
console.log(`[Passphrase] Request ${requestId} skipped by user`);
|
||||
pending.resolveCallback({ skipped: true });
|
||||
} else {
|
||||
console.log(`[Passphrase] Received passphrase for ${requestId}`);
|
||||
pending.resolveCallback({ passphrase: passphrase || null });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handler for passphrase responses
|
||||
*/
|
||||
function registerHandler(ipcMain) {
|
||||
ipcMain.handle('netcatty:passphrase:respond', handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending requests (for debugging)
|
||||
*/
|
||||
function getRequests() {
|
||||
return passphraseRequests;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateRequestId,
|
||||
requestPassphrase,
|
||||
handleResponse,
|
||||
registerHandler,
|
||||
getRequests,
|
||||
};
|
||||
@@ -6,6 +6,11 @@
|
||||
const net = require("node:net");
|
||||
const { Client: SSHClient } = require("ssh2");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// Active port forwarding tunnels
|
||||
const portForwardingTunnels = new Map();
|
||||
@@ -38,6 +43,7 @@ async function startPortForward(event, payload) {
|
||||
username,
|
||||
password,
|
||||
privateKey,
|
||||
passphrase,
|
||||
} = payload;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -63,59 +69,31 @@ async function startPortForward(event, payload) {
|
||||
if (privateKey) {
|
||||
connectOpts.privateKey = privateKey;
|
||||
}
|
||||
if (passphrase) {
|
||||
connectOpts.passphrase = passphrase;
|
||||
}
|
||||
if (password) {
|
||||
connectOpts.password = password;
|
||||
}
|
||||
|
||||
// Build auth handler with keyboard-interactive support
|
||||
const authMethods = [];
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey,
|
||||
password,
|
||||
passphrase,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[PortForward]",
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`[PortForward] ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`[PortForward] No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`[PortForward] Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, tunnelId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId: tunnelId,
|
||||
name: name || "",
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: hostname,
|
||||
savedPassword: password || null,
|
||||
});
|
||||
});
|
||||
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: tunnelId,
|
||||
hostname,
|
||||
password,
|
||||
logPrefix: "[PortForward]",
|
||||
}));
|
||||
|
||||
|
||||
conn.on('ready', () => {
|
||||
|
||||
@@ -23,6 +23,12 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// SFTP clients storage - shared reference passed from main
|
||||
let sftpClients = null;
|
||||
@@ -258,7 +264,8 @@ function init(deps) {
|
||||
/**
|
||||
* Connect through a chain of jump hosts for SFTP
|
||||
*/
|
||||
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort) {
|
||||
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId) {
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
let currentSocket = null;
|
||||
|
||||
@@ -282,7 +289,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
host: jump.hostname,
|
||||
port: jump.port || 22,
|
||||
username: jump.username || 'root',
|
||||
readyTimeout: 20000,
|
||||
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
|
||||
keepaliveInterval: 10000,
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
@@ -318,11 +325,18 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
|
||||
if (authAgent) {
|
||||
const order = ["agent"];
|
||||
if (connOpts.password) order.push("password");
|
||||
connOpts.authHandler = order;
|
||||
}
|
||||
// Build auth handler using shared helper
|
||||
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey: connOpts.privateKey,
|
||||
password: connOpts.password,
|
||||
passphrase: connOpts.passphrase,
|
||||
agent: connOpts.agent,
|
||||
username: connOpts.username,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
@@ -351,6 +365,14 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} timeout`);
|
||||
reject(new Error(`Connection timeout to ${hopLabel}`));
|
||||
});
|
||||
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
|
||||
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: connId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`,
|
||||
}));
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
|
||||
@@ -648,7 +670,8 @@ async function openSftp(event, options) {
|
||||
options,
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
options.port || 22,
|
||||
connId
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
@@ -700,78 +723,29 @@ async function openSftp(event, options) {
|
||||
|
||||
if (options.password) connectOpts.password = options.password;
|
||||
|
||||
if (authAgent) {
|
||||
const order = ["agent"];
|
||||
if (connectOpts.password) order.push("password");
|
||||
connectOpts.authHandler = order;
|
||||
} else if (options.privateKey && connectOpts.password) {
|
||||
// Prefer key auth when both key and password are present (password still needed for sudo)
|
||||
connectOpts.authHandler = ["publickey", "password"];
|
||||
}
|
||||
|
||||
// Add keyboard-interactive authentication support
|
||||
// ssh2-sftp-client exposes the underlying ssh2 Client through its `on` method
|
||||
const kiHandler = (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`[SFTP] ${options.hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`[SFTP] No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('sftp');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`[SFTP] Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, event.sender.id, connId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(event.sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId: connId,
|
||||
name: name || "",
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: options.hostname,
|
||||
savedPassword: options.password || null,
|
||||
});
|
||||
};
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey: connectOpts.privateKey,
|
||||
password: connectOpts.password,
|
||||
passphrase: connectOpts.passphrase,
|
||||
agent: connectOpts.agent,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[SFTP]",
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
// Create keyboard-interactive handler using shared helper
|
||||
const kiHandler = createKeyboardInteractiveHandler({
|
||||
sender: event.sender,
|
||||
sessionId: connId,
|
||||
hostname: options.hostname,
|
||||
password: options.password,
|
||||
logPrefix: "[SFTP]",
|
||||
});
|
||||
|
||||
// Add keyboard-interactive listener BEFORE connecting
|
||||
client.on("keyboard-interactive", kiHandler);
|
||||
|
||||
// Enable keyboard-interactive authentication in authHandler
|
||||
if (connectOpts.authHandler) {
|
||||
// Add keyboard-interactive after the existing methods
|
||||
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
|
||||
connectOpts.authHandler.push("keyboard-interactive");
|
||||
}
|
||||
} else {
|
||||
// Create authHandler with keyboard-interactive support
|
||||
const authMethods = [];
|
||||
if (connectOpts.privateKey) authMethods.push("publickey");
|
||||
if (connectOpts.password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
}
|
||||
|
||||
// Increase timeout to allow for keyboard-interactive auth
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
|
||||
|
||||
533
electron/bridges/sshAuthHelper.cjs
Normal file
533
electron/bridges/sshAuthHelper.cjs
Normal file
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* SSH Authentication Helper - Shared authentication logic for SSH connections
|
||||
* Used by sshBridge, sftpBridge, and portForwardingBridge
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
* @param {string} keyContent - The content of the private key file
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
if (!keyContent || typeof keyContent !== "string") return false;
|
||||
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
|
||||
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for DEK-Info header (legacy PEM encryption indicator)
|
||||
if (keyContent.includes("DEK-Info:")) return true;
|
||||
|
||||
// Check for OpenSSH format keys
|
||||
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||
try {
|
||||
// Extract the base64 content between the markers
|
||||
const base64Match = keyContent.match(
|
||||
/-----BEGIN OPENSSH PRIVATE KEY-----\s*([\s\S]*?)\s*-----END OPENSSH PRIVATE KEY-----/
|
||||
);
|
||||
if (base64Match) {
|
||||
const base64Content = base64Match[1].replace(/\s/g, "");
|
||||
const keyBuffer = Buffer.from(base64Content, "base64");
|
||||
|
||||
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
|
||||
// If ciphername is "none", the key is not encrypted
|
||||
const authMagic = "openssh-key-v1\0";
|
||||
if (keyBuffer.toString("ascii", 0, authMagic.length) === authMagic) {
|
||||
// After magic, read ciphername (length-prefixed string)
|
||||
let offset = authMagic.length;
|
||||
const cipherNameLen = keyBuffer.readUInt32BE(offset);
|
||||
offset += 4;
|
||||
const cipherName = keyBuffer.toString("ascii", offset, offset + cipherNameLen);
|
||||
return cipherName !== "none";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, assume it might be encrypted to be safe
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase
|
||||
* @returns {{ privateKey: string, keyPath: string, keyName: string } | null}
|
||||
*/
|
||||
function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
const privateKey = fs.readFileSync(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL default SSH private keys from user's ~/.ssh directory
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
|
||||
* @returns {Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>}
|
||||
*/
|
||||
function findAllDefaultPrivateKeys(options = {}) {
|
||||
const { includeEncrypted = false } = options;
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const keys = [];
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
const privateKey = fs.readFileSync(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
continue; // Skip encrypted keys when not including them
|
||||
}
|
||||
keys.push({
|
||||
privateKey,
|
||||
keyPath,
|
||||
keyName: name,
|
||||
...(includeEncrypted ? { isEncrypted: encrypted } : {})
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path based on platform
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getSshAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
return "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
}
|
||||
return process.env.SSH_AUTH_SOCK || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authentication handler with default key fallback support
|
||||
* @param {Object} options
|
||||
* @param {string} [options.privateKey] - Explicitly configured private key
|
||||
* @param {string} [options.password] - Password for authentication
|
||||
* @param {string} [options.passphrase] - Passphrase for encrypted private key
|
||||
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
|
||||
* @param {string} options.username - SSH username
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
|
||||
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
|
||||
*/
|
||||
function buildAuthHandler(options) {
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [] } = options;
|
||||
|
||||
// Determine what type of explicit auth the user configured
|
||||
const hasExplicitKey = !!privateKey;
|
||||
const hasExplicitPassword = !!password;
|
||||
const hasExplicitAgent = !!agent;
|
||||
const hasExplicitAuth = hasExplicitKey || hasExplicitPassword || hasExplicitAgent;
|
||||
|
||||
// Determine if this is a password-only or key-only connection
|
||||
const isPasswordOnly = hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent;
|
||||
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
|
||||
|
||||
const sshAgentSocket = getSshAgentSocket();
|
||||
const defaultKeys = findAllDefaultPrivateKeys();
|
||||
|
||||
// Only use system ssh-agent BEFORE user's auth when:
|
||||
// - User explicitly configured agent, OR
|
||||
// - No explicit auth is configured (pure fallback mode)
|
||||
// When user configured key/password, system agent should only be used AFTER as fallback
|
||||
const useAgentFirst = hasExplicitAgent || !hasExplicitAuth;
|
||||
|
||||
// Determine effective agent
|
||||
const effectiveAgent = agent || (useAgentFirst ? sshAgentSocket : null);
|
||||
|
||||
// Determine effective privateKey (user-provided takes priority)
|
||||
const effectivePrivateKey = privateKey || (!hasExplicitAuth && defaultKeys.length > 0 ? defaultKeys[0].privateKey : null);
|
||||
|
||||
// Determine fallback keys (keys to try after user's primary auth fails)
|
||||
// - If user provided a key: all default keys are fallbacks
|
||||
// - If no explicit auth: first default key is primary, rest are fallbacks
|
||||
// - If password-only or agent-only: all default keys are fallbacks (tried after primary)
|
||||
const fallbackKeys = hasExplicitKey
|
||||
? defaultKeys
|
||||
: !hasExplicitAuth
|
||||
? defaultKeys.slice(1)
|
||||
: defaultKeys;
|
||||
|
||||
// Check if we need dynamic handler (have fallback options)
|
||||
const hasFallbackOptions = fallbackKeys.length > 0 ||
|
||||
(!hasExplicitAgent && sshAgentSocket) ||
|
||||
(isPasswordOnly && defaultKeys.length > 0);
|
||||
|
||||
// If only simple auth methods and no fallback keys needed, use array-based handler
|
||||
if (hasExplicitAuth && !hasFallbackOptions) {
|
||||
const authMethods = [];
|
||||
if (effectiveAgent) authMethods.push("agent");
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
|
||||
return {
|
||||
authHandler: authMethods,
|
||||
privateKey: effectivePrivateKey,
|
||||
agent: effectiveAgent,
|
||||
usedDefaultKeys: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Build comprehensive authMethods array with all auth options
|
||||
// Order depends on what user explicitly configured:
|
||||
// - Password-only: password -> agent -> default keys -> keyboard-interactive
|
||||
// - Key-only: user key -> password -> agent -> default keys -> keyboard-interactive
|
||||
// - Agent configured: agent -> user key -> password -> default keys -> keyboard-interactive
|
||||
// - No explicit auth: agent -> default keys -> keyboard-interactive
|
||||
const authMethods = [];
|
||||
|
||||
if (isPasswordOnly) {
|
||||
// Password-only: password first, then fallbacks
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
|
||||
// Add agent and default keys AFTER password as fallback
|
||||
if (sshAgentSocket) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
for (const keyInfo of defaultKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else if (isKeyOnly) {
|
||||
// Key-only: user key first, then password (if any), then agent/default keys as fallback
|
||||
|
||||
// 1. User-provided key first
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
passphrase: passphrase,
|
||||
id: "publickey-user"
|
||||
});
|
||||
|
||||
// 2. Password (if configured alongside key)
|
||||
if (password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
// 3. System agent as fallback (AFTER user's key)
|
||||
if (sshAgentSocket) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
// 4. Default keys as fallback
|
||||
for (const keyInfo of fallbackKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Agent configured or no explicit auth: agent -> user key -> password -> default keys
|
||||
|
||||
// 1. Agent (user-provided or system)
|
||||
if (effectiveAgent) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
// 2. User-provided key
|
||||
if (privateKey) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
passphrase: passphrase,
|
||||
id: "publickey-user"
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Password (if configured)
|
||||
if (password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
// 4. Default keys as fallback
|
||||
for (const keyInfo of fallbackKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
// 5. If no user key provided, add first default key at the beginning (after agent)
|
||||
if (!privateKey && defaultKeys.length > 0) {
|
||||
const insertIndex = effectiveAgent ? 1 : 0;
|
||||
authMethods.splice(insertIndex, 0, {
|
||||
type: "publickey",
|
||||
key: defaultKeys[0].privateKey,
|
||||
id: `publickey-default-${defaultKeys[0].keyName}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add unlocked encrypted default keys (user provided passphrases for these)
|
||||
for (const keyInfo of unlockedEncryptedKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
passphrase: keyInfo.passphrase,
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard-interactive as last resort
|
||||
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
|
||||
|
||||
console.log(`${logPrefix} Auth methods configured`, {
|
||||
isPasswordOnly,
|
||||
hasUserKey: !!privateKey,
|
||||
hasPassword: !!password,
|
||||
hasAgent: !!effectiveAgent,
|
||||
methodCount: authMethods.length,
|
||||
methods: authMethods.map(m => m.id),
|
||||
});
|
||||
|
||||
// Use dynamic authHandler to try all keys
|
||||
let authIndex = 0;
|
||||
const attemptedMethodIds = new Set();
|
||||
|
||||
const authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
|
||||
while (authIndex < authMethods.length) {
|
||||
const method = authMethods[authIndex];
|
||||
authIndex++;
|
||||
|
||||
if (attemptedMethodIds.has(method.id)) continue;
|
||||
attemptedMethodIds.add(method.id);
|
||||
|
||||
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
|
||||
console.log(`${logPrefix} Trying agent auth`);
|
||||
return callback("agent");
|
||||
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
|
||||
console.log(`${logPrefix} Trying publickey auth:`, method.id);
|
||||
const pubkeyAuth = {
|
||||
type: "publickey",
|
||||
username,
|
||||
key: method.key,
|
||||
};
|
||||
if (method.passphrase) {
|
||||
pubkeyAuth.passphrase = method.passphrase;
|
||||
}
|
||||
return callback(pubkeyAuth);
|
||||
} else if (method.type === "password" && availableMethods.includes("password")) {
|
||||
console.log(`${logPrefix} Trying password auth`);
|
||||
return callback({
|
||||
type: "password",
|
||||
username,
|
||||
password,
|
||||
});
|
||||
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
|
||||
return callback("keyboard-interactive");
|
||||
}
|
||||
}
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
// Determine the agent to return - if authMethods includes agent, we need to provide the socket
|
||||
// even if effectiveAgent is null (for fallback scenarios)
|
||||
const hasAgentInMethods = authMethods.some(m => m.type === "agent");
|
||||
const returnAgent = effectiveAgent || (hasAgentInMethods ? sshAgentSocket : null);
|
||||
|
||||
return {
|
||||
authHandler,
|
||||
privateKey: effectivePrivateKey,
|
||||
agent: returnAgent,
|
||||
usedDefaultKeys: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a keyboard-interactive event handler
|
||||
* @param {Object} options
|
||||
* @param {Object} options.sender - Electron webContents sender
|
||||
* @param {string} options.sessionId - Session/connection ID
|
||||
* @param {string} options.hostname - Host being connected to
|
||||
* @param {string} [options.password] - Saved password for fill button
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {Function} - Event handler for 'keyboard-interactive' event
|
||||
*/
|
||||
function createKeyboardInteractiveHandler(options) {
|
||||
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
|
||||
|
||||
return (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward prompts to user via IPC
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId,
|
||||
name: name || hostname,
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: hostname,
|
||||
savedPassword: password || null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply auth configuration to connection options
|
||||
* Convenience function that combines buildAuthHandler results with connOpts
|
||||
* @param {Object} connOpts - SSH connection options to modify
|
||||
* @param {Object} authConfig - Auth configuration from buildAuthHandler
|
||||
*/
|
||||
function applyAuthToConnOpts(connOpts, authConfig) {
|
||||
connOpts.authHandler = authConfig.authHandler;
|
||||
if (authConfig.privateKey) {
|
||||
connOpts.privateKey = authConfig.privateKey;
|
||||
}
|
||||
if (authConfig.agent) {
|
||||
connOpts.agent = authConfig.agent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request passphrases for encrypted default keys
|
||||
* Shows a modal for each encrypted key and collects passphrases
|
||||
* @param {Object} sender - Electron webContents sender
|
||||
* @param {string} [hostname] - Optional hostname for context
|
||||
* @returns {Promise<{ keys: Array<{ privateKey: string, keyPath: string, keyName: string, passphrase: string }>, cancelled: boolean }>}
|
||||
*/
|
||||
async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
const allKeys = findAllDefaultPrivateKeys({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
|
||||
|
||||
if (encryptedKeys.length === 0) {
|
||||
return { keys: [], cancelled: false };
|
||||
}
|
||||
|
||||
console.log(`[SSHAuth] Found ${encryptedKeys.length} encrypted default key(s), requesting passphrases`);
|
||||
|
||||
const unlockedKeys = [];
|
||||
let wasCancelled = false;
|
||||
|
||||
for (const keyInfo of encryptedKeys) {
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
keyInfo.keyPath,
|
||||
keyInfo.keyName,
|
||||
hostname
|
||||
);
|
||||
|
||||
// Handle different response types
|
||||
if (!result) {
|
||||
// Timeout or error - continue with next key
|
||||
console.log(`[SSHAuth] No response for ${keyInfo.keyName}, continuing...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.cancelled) {
|
||||
// User clicked Cancel - stop the entire flow
|
||||
console.log(`[SSHAuth] User cancelled passphrase flow at ${keyInfo.keyName}`);
|
||||
wasCancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.skipped) {
|
||||
// User clicked Skip - continue with next key
|
||||
console.log(`[SSHAuth] User skipped passphrase for ${keyInfo.keyName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.passphrase) {
|
||||
// User provided passphrase
|
||||
unlockedKeys.push({
|
||||
privateKey: keyInfo.privateKey,
|
||||
keyPath: keyInfo.keyPath,
|
||||
keyName: keyInfo.keyName,
|
||||
passphrase: result.passphrase,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { keys: unlockedKeys, cancelled: wasCancelled };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_KEY_NAMES,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
getSshAgentSocket,
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend,
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
};
|
||||
@@ -11,7 +11,16 @@ const { exec } = require("node:child_process");
|
||||
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
@@ -98,6 +107,36 @@ function findDefaultPrivateKey() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL default SSH private keys from user's ~/.ssh directory
|
||||
* Returns all non-encrypted keys for fallback authentication
|
||||
* @returns {Array<{ privateKey: string, keyPath: string, keyName: string }>}
|
||||
*/
|
||||
function findAllDefaultPrivateKeys() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const keys = [];
|
||||
log("Searching for ALL default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
const privateKey = fs.readFileSync(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (!encrypted) {
|
||||
keys.push({ privateKey, keyPath, keyName: name });
|
||||
log("Found default key for fallback", { keyPath, keyName: name });
|
||||
} else {
|
||||
log("Skipping encrypted key", { keyPath, keyName: name });
|
||||
}
|
||||
} catch (e) {
|
||||
log("Failed to read key", { keyPath, error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
log("Found default SSH keys", { count: keys.length, keyNames: keys.map(k => k.keyName) });
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Windows SSH Agent service is running
|
||||
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
|
||||
@@ -199,7 +238,7 @@ function init(deps) {
|
||||
/**
|
||||
* Connect through a chain of jump hosts
|
||||
*/
|
||||
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort) {
|
||||
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort, sessionId) {
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
let currentSocket = null;
|
||||
@@ -229,7 +268,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
host: jump.hostname,
|
||||
port: jump.port || 22,
|
||||
username: jump.username || 'root',
|
||||
readyTimeout: 20000, // Reduced from 60s for faster failure detection
|
||||
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
|
||||
// Use user-configured keepalive interval from options (in seconds -> convert to ms)
|
||||
// If 0 or not provided, use 10000ms as default
|
||||
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
|
||||
@@ -245,7 +284,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
},
|
||||
};
|
||||
|
||||
// Auth - support agent (certificate), key, and password fallback
|
||||
// Auth - support agent (certificate), key, password, and default key fallback
|
||||
const hasCertificate =
|
||||
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
|
||||
|
||||
@@ -269,11 +308,18 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
|
||||
if (authAgent) {
|
||||
const order = ["agent"];
|
||||
if (connOpts.password) order.push("password");
|
||||
connOpts.authHandler = order;
|
||||
}
|
||||
// Build auth handler using shared helper
|
||||
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey: connOpts.privateKey,
|
||||
password: connOpts.password,
|
||||
passphrase: connOpts.passphrase,
|
||||
agent: connOpts.agent,
|
||||
username: connOpts.username,
|
||||
logPrefix: `[Chain] Hop ${i + 1}`,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
@@ -304,6 +350,14 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} timeout`);
|
||||
reject(new Error(`Connection timeout to ${hopLabel}`));
|
||||
});
|
||||
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
|
||||
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
|
||||
}));
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
@@ -449,22 +503,47 @@ async function startSSHSession(event, options) {
|
||||
connectOpts.password = options.password;
|
||||
}
|
||||
|
||||
// Always try to find default SSH key for fallback authentication
|
||||
// Always try to find default SSH keys for fallback authentication
|
||||
// This allows fallback even when password auth fails
|
||||
let defaultKeyInfo = null;
|
||||
let allDefaultKeys = [];
|
||||
let usedDefaultKeyAsPrimary = false;
|
||||
const defaultKey = findDefaultPrivateKey();
|
||||
if (defaultKey) {
|
||||
defaultKeyInfo = defaultKey;
|
||||
log("Found default SSH key for fallback", { keyPath: defaultKey.keyPath, keyName: defaultKey.keyName });
|
||||
}
|
||||
// Also find ALL default keys for comprehensive fallback
|
||||
allDefaultKeys = findAllDefaultPrivateKeys();
|
||||
|
||||
// If no primary auth method configured, use default key as primary
|
||||
// Use unlocked encrypted keys if provided (from retry after auth failure)
|
||||
// These are passed via _unlockedEncryptedKeys from startSSHSessionWrapper
|
||||
const unlockedEncryptedKeys = options._unlockedEncryptedKeys || [];
|
||||
if (unlockedEncryptedKeys.length > 0) {
|
||||
log("Using unlocked encrypted keys from retry", {
|
||||
count: unlockedEncryptedKeys.length,
|
||||
keyNames: unlockedEncryptedKeys.map(k => k.keyName)
|
||||
});
|
||||
}
|
||||
|
||||
// If no primary auth method configured, try ssh-agent first, then ALL default keys
|
||||
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
|
||||
log("No auth method configured, using default SSH key as primary auth");
|
||||
if (defaultKeyInfo) {
|
||||
connectOpts.privateKey = defaultKeyInfo.privateKey;
|
||||
usedDefaultKeyAsPrimary = true; // Track that we promoted default key to primary
|
||||
// First, try to use ssh-agent if available (this is what regular SSH does)
|
||||
const sshAgentSocket = process.platform === "win32"
|
||||
? "\\\\.\\pipe\\openssh-ssh-agent"
|
||||
: process.env.SSH_AUTH_SOCK;
|
||||
|
||||
if (sshAgentSocket) {
|
||||
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
|
||||
connectOpts.agent = sshAgentSocket;
|
||||
}
|
||||
|
||||
// Mark that we need to try all default keys (handled in authMethods below)
|
||||
if (allDefaultKeys.length > 0) {
|
||||
log("Will try all default SSH keys as fallback", { count: allDefaultKeys.length, keyNames: allDefaultKeys.map(k => k.keyName) });
|
||||
// Set first key for connectOpts.privateKey (required for ssh2 to allow publickey auth)
|
||||
connectOpts.privateKey = allDefaultKeys[0].privateKey;
|
||||
usedDefaultKeyAsPrimary = true;
|
||||
} else {
|
||||
log("No default SSH key found in ~/.ssh directory");
|
||||
}
|
||||
@@ -515,34 +594,54 @@ async function startSSHSession(event, options) {
|
||||
const authMethods = [];
|
||||
|
||||
// First try user-configured key if available (explicit user choice)
|
||||
if (connectOpts.privateKey) {
|
||||
if (connectOpts.privateKey && !usedDefaultKeyAsPrimary) {
|
||||
authMethods.push({ type: "publickey", key: connectOpts.privateKey, passphrase: connectOpts.passphrase, id: "publickey-user" });
|
||||
}
|
||||
|
||||
// Then try password if available (explicit user choice)
|
||||
// Password before agent because agent may be auto-set via SSH_AUTH_SOCK
|
||||
// and on servers with low MaxAuthTries, agent attempt could exhaust tries
|
||||
if (connectOpts.password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
// Then try agent if configured (agentForwarding or SSH_AUTH_SOCK)
|
||||
// Agent after password since it may be auto-configured rather than explicit
|
||||
// Then try agent if configured (try agent before password since it's usually faster)
|
||||
if (connectOpts.agent) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
// Then try default SSH key as fallback (if not already used as primary)
|
||||
if (defaultKeyInfo && !options.privateKey && !usedDefaultKeyAsPrimary) {
|
||||
// Then try password if available (explicit user choice)
|
||||
if (connectOpts.password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
// Then try ALL default SSH keys as fallback (not just the first one!)
|
||||
// This is critical because different servers may have different keys in authorized_keys
|
||||
if (usedDefaultKeyAsPrimary && allDefaultKeys.length > 0) {
|
||||
for (const keyInfo of allDefaultKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
isDefault: true,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else if (defaultKeyInfo && !options.privateKey && !usedDefaultKeyAsPrimary) {
|
||||
// Single default key fallback (when user has configured other auth methods)
|
||||
authMethods.push({ type: "publickey", key: defaultKeyInfo.privateKey, isDefault: true, id: "publickey-default" });
|
||||
}
|
||||
|
||||
// Add unlocked encrypted default keys (user provided passphrases for these)
|
||||
for (const keyInfo of unlockedEncryptedKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
passphrase: keyInfo.passphrase,
|
||||
isDefault: true,
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
// Finally try keyboard-interactive
|
||||
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
|
||||
|
||||
log("Auth methods configured", {
|
||||
methods: authMethods.map(m => ({ type: m.type, id: m.id, isDefault: m.isDefault || false })),
|
||||
cachedMethod
|
||||
cachedMethod,
|
||||
usedDefaultKeyAsPrimary
|
||||
});
|
||||
|
||||
// Reorder methods based on cached successful method
|
||||
@@ -720,7 +819,8 @@ async function startSSHSession(event, options) {
|
||||
options,
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
options.port || 22,
|
||||
sessionId
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
@@ -959,17 +1059,30 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
} else if (typeof connectOpts.authHandler !== "function") {
|
||||
// Create authHandler with keyboard-interactive support
|
||||
// This path is taken when usedDefaultKeyAsPrimary=true (only keyboard-interactive in authMethods)
|
||||
// Using array format is more reliable - ssh2 uses connectOpts credentials directly
|
||||
const authMethods = [];
|
||||
// Try agent FIRST (this is what regular SSH does - it checks ssh-agent before key files)
|
||||
if (connectOpts.agent) authMethods.push("agent");
|
||||
if (connectOpts.privateKey) authMethods.push("publickey");
|
||||
if (connectOpts.password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
log("Using simple array authHandler", { authMethods, usedDefaultKeyAsPrimary });
|
||||
}
|
||||
// If authHandler is a function, it already handles keyboard-interactive
|
||||
|
||||
// Increase timeout to allow for keyboard-interactive auth
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
|
||||
// Enable debug logging for ssh2 to diagnose auth issues
|
||||
connectOpts.debug = (msg) => {
|
||||
// Only log auth-related messages to avoid noise
|
||||
if (msg.includes('Auth') || msg.includes('auth') || msg.includes('publickey') || msg.includes('keyboard')) {
|
||||
log("ssh2 debug", { msg });
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
|
||||
conn.connect(connectOpts);
|
||||
});
|
||||
@@ -1141,6 +1254,57 @@ async function startSSHSessionWrapper(event, options) {
|
||||
err.level === 'client-authentication';
|
||||
|
||||
if (isAuthError) {
|
||||
// Check if there are encrypted default keys we haven't tried yet
|
||||
// Only offer retry if no unlocked keys were provided in this attempt
|
||||
if (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0) {
|
||||
const allKeysWithEncrypted = findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
|
||||
|
||||
if (encryptedKeys.length > 0) {
|
||||
console.log('[SSH] Auth failed, found encrypted default keys. Requesting passphrases for retry...');
|
||||
|
||||
// Request passphrases from user
|
||||
const passphraseResult = await requestPassphrasesForEncryptedKeys(
|
||||
event.sender,
|
||||
options.hostname
|
||||
);
|
||||
|
||||
// If user cancelled, don't retry even if some keys were unlocked
|
||||
if (passphraseResult.cancelled) {
|
||||
console.log('[SSH] User cancelled passphrase flow, not retrying');
|
||||
} else if (passphraseResult.keys.length > 0) {
|
||||
console.log('[SSH] User unlocked keys, retrying connection...', {
|
||||
count: passphraseResult.keys.length,
|
||||
keyNames: passphraseResult.keys.map(k => k.keyName)
|
||||
});
|
||||
|
||||
// Retry connection with unlocked keys
|
||||
// Wrap in try-catch to ensure consistent error handling for retry failures
|
||||
try {
|
||||
return await startSSHSession(event, {
|
||||
...options,
|
||||
_unlockedEncryptedKeys: passphraseResult.keys,
|
||||
});
|
||||
} catch (retryErr) {
|
||||
// Re-wrap retry errors the same way as initial errors
|
||||
const isRetryAuthError = retryErr.message?.toLowerCase().includes('authentication') ||
|
||||
retryErr.message?.toLowerCase().includes('auth') ||
|
||||
retryErr.level === 'client-authentication';
|
||||
|
||||
if (isRetryAuthError) {
|
||||
const authError = new Error(retryErr.message);
|
||||
authError.level = 'client-authentication';
|
||||
authError.isAuthError = true;
|
||||
throw authError;
|
||||
}
|
||||
throw retryErr;
|
||||
}
|
||||
} else {
|
||||
console.log('[SSH] User did not unlock any keys, not retrying');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw with a clean error to avoid Electron printing full stack trace
|
||||
// The frontend will handle this as a normal auth failure for fallback
|
||||
const authError = new Error(err.message);
|
||||
@@ -1564,6 +1728,8 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
// Register the shared keyboard-interactive response handler
|
||||
keyboardInteractiveHandler.registerHandler(ipcMain);
|
||||
// Register the passphrase response handler
|
||||
passphraseHandler.registerHandler(ipcMain);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -1577,4 +1743,8 @@ module.exports = {
|
||||
generateKeyPair,
|
||||
checkWindowsSshAgent,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
isKeyEncrypted,
|
||||
findAllDefaultPrivateKeys,
|
||||
isKeyEncrypted,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ const authFailedListeners = new Map();
|
||||
const languageChangeListeners = new Set();
|
||||
const fullscreenChangeListeners = new Set();
|
||||
const keyboardInteractiveListeners = new Set();
|
||||
const passphraseListeners = new Set();
|
||||
const passphraseTimeoutListeners = new Set();
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
@@ -98,6 +100,28 @@ ipcRenderer.on("netcatty:keyboard-interactive", (_event, payload) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Passphrase request events for encrypted SSH keys
|
||||
ipcRenderer.on("netcatty:passphrase-request", (_event, payload) => {
|
||||
passphraseListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Passphrase request callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Passphrase timeout events (request expired)
|
||||
ipcRenderer.on("netcatty:passphrase-timeout", (_event, payload) => {
|
||||
passphraseTimeoutListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Passphrase timeout callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Transfer progress events
|
||||
ipcRenderer.on("netcatty:transfer:progress", (_event, payload) => {
|
||||
const cb = transferProgressListeners.get(payload.transferId);
|
||||
@@ -318,6 +342,29 @@ const api = {
|
||||
cancelled,
|
||||
});
|
||||
},
|
||||
// Passphrase request for encrypted SSH keys
|
||||
onPassphraseRequest: (cb) => {
|
||||
passphraseListeners.add(cb);
|
||||
return () => passphraseListeners.delete(cb);
|
||||
},
|
||||
respondPassphrase: async (requestId, passphrase, cancelled = false) => {
|
||||
return ipcRenderer.invoke("netcatty:passphrase:respond", {
|
||||
requestId,
|
||||
passphrase,
|
||||
cancelled,
|
||||
});
|
||||
},
|
||||
respondPassphraseSkip: async (requestId) => {
|
||||
return ipcRenderer.invoke("netcatty:passphrase:respond", {
|
||||
requestId,
|
||||
passphrase: '',
|
||||
skipped: true,
|
||||
});
|
||||
},
|
||||
onPassphraseTimeout: (cb) => {
|
||||
passphraseTimeoutListeners.add(cb);
|
||||
return () => passphraseTimeoutListeners.delete(cb);
|
||||
},
|
||||
openSftp: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
|
||||
return result.sftpId;
|
||||
|
||||
21
global.d.ts
vendored
21
global.d.ts
vendored
@@ -245,6 +245,27 @@ declare global {
|
||||
cancelled?: boolean
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
// Passphrase request for encrypted SSH keys
|
||||
onPassphraseRequest?(
|
||||
cb: (request: {
|
||||
requestId: string;
|
||||
keyPath: string;
|
||||
keyName: string;
|
||||
hostname?: string;
|
||||
}) => void
|
||||
): () => void;
|
||||
respondPassphrase?(
|
||||
requestId: string,
|
||||
passphrase: string,
|
||||
cancelled?: boolean
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
respondPassphraseSkip?(
|
||||
requestId: string
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
onPassphraseTimeout?(
|
||||
cb: (event: { requestId: string }) => void
|
||||
): () => void;
|
||||
|
||||
// SFTP operations
|
||||
openSftp(options: NetcattySSHOptions): Promise<string>;
|
||||
listSftp(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<RemoteFile[]>;
|
||||
|
||||
@@ -28,6 +28,7 @@ export const STORAGE_KEY_SHELL_HISTORY = 'netcatty_shell_history_v1';
|
||||
export const STORAGE_KEY_CONNECTION_LOGS = 'netcatty_connection_logs_v1';
|
||||
export const STORAGE_KEY_IDENTITIES = 'netcatty_identities_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_VIEW_MODE = 'netcatty_vault_hosts_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED = 'netcatty_vault_hosts_tree_expanded_v1';
|
||||
export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1007,7 +1007,6 @@
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -1654,6 +1653,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1675,6 +1675,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1691,6 +1692,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1705,6 +1707,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -5618,7 +5621,6 @@
|
||||
"integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
@@ -5648,7 +5650,6 @@
|
||||
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
@@ -5927,8 +5928,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
@@ -5960,7 +5960,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5993,7 +5992,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6401,7 +6399,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -7061,7 +7058,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -7301,7 +7299,6 @@
|
||||
"integrity": "sha512-ce4Ogns4VMeisIuCSK0C62umG0lFy012jd8LMZ6w/veHUeX4fqfDrGe+HTWALAEwK6JwKP+dhPvizhArSOsFbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.4.0",
|
||||
"builder-util": "26.3.4",
|
||||
@@ -7627,6 +7624,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7647,6 +7645,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -7871,7 +7870,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -10154,7 +10152,6 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -10780,7 +10777,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10839,6 +10835,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -10856,6 +10853,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -10956,7 +10954,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -10966,7 +10963,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -11895,6 +11891,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -11958,6 +11955,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -11972,6 +11970,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -12133,7 +12132,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12336,7 +12334,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12691,7 +12688,6 @@
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user