Compare commits

...

25 Commits

Author SHA1 Message Date
陈大猫
e74f65729c Merge pull request #143 from binaricat/feat/snippet-package-rename
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat(snippets): add rename functionality for packages
2026-01-28 20:23:29 +08:00
bincxz
97f53ed87f fix(snippets): sync editingSnippet state when renaming packages
Update editingSnippet.package when the package being edited is renamed
or is nested under a renamed package. This prevents the stale package
path from being persisted when the user saves their edits after a rename.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-28 20:12:00 +08:00
bincxz
ec4512eb06 feat(snippets): add rename functionality for packages
Add context menu option to rename snippet packages with a modal dialog.
Includes validation for empty names, duplicate names (case-insensitive),
and invalid characters (only letters, numbers, hyphens, underscores allowed).

When a package is renamed, all nested packages and snippets are updated
to reflect the new path.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-28 19:52:54 +08:00
陈大猫
93c1f1b427 Merge pull request #142 from RiceWays/fix/code-package-grouping
fix: Fix multiple bugs in code package creation and display
2026-01-28 19:26:50 +08:00
bincxz
58ccd4bfb9 fix(snippets): normalize trailing slashes in package paths
Strip trailing slashes before saving package paths to ensure consistent
path handling across the UI. This prevents issues where 'foo/' would not
match 'foo' in the package browser.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-28 19:16:02 +08:00
bincxz
2fb82e1cb7 Update SnippetsManager component 2026-01-28 18:32:22 +08:00
bincxz
159589a09f fix: persist implicit parent paths when selected in package dropdown
Address Codex review: when selecting a parent path from the package
dropdown that was generated from existing child packages (e.g., /foo
derived from /foo/bar), the path is now added to the packages array.
This prevents orphaned snippets when the child package is deleted.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 18:18:59 +08:00
Rice
04e1ed569d fix(snippets): preserve absolute path prefix in breadcrumb navigation
- Detect if selected package path is absolute (starts with '/')
- Reconstruct breadcrumb paths with leading slash when applicable
- Prevent loss of absolute path context when navigating package hierarchy
- Ensures consistent path handling between package selection and breadcrumb display
2026-01-28 17:32:54 +08:00
Rice
38fb5e8dd4 fix(snippets): normalize snippet path construction to prevent double slashes
- Strip leading slash from snippet names when creating paths inside packages
- Preserve leading slash for snippets created at root level
- Prevent double slashes in constructed package paths (e.g., "package//snippet")
- Improve path handling consistency between root and nested snippet creation
2026-01-28 17:22:50 +08:00
Rice
6f2b27206a fix(snippets): improve package name validation regex pattern
- Update validation regex to allow hyphens anywhere in package names
- Simplify regex pattern from `^\/?\w+([\w/-]*\w+)*\/?$` to `^\/?([\w-]+(\/[\w-]+)*)\/?$`
- Update HTML input pattern attribute to match validation logic
- Improve comment clarity to reflect hyphen handling in package names
- Ensures consistent validation between JavaScript regex and HTML5 pattern attribute
2026-01-28 17:11:01 +08:00
Rice
f6eb693fac refactor(snippets): improve package path handling and filtering logic
- Separate absolute paths (starting with /) from relative paths for clearer processing
- Process relative and absolute paths independently with distinct handling logic
- Add type annotations to filter callbacks for better type safety
- Simplify path matching logic by removing redundant checks for both slash variants
- Display absolute paths with "/" prefix to distinguish them from relative paths
- Improve code readability by extracting path processing into separate sections
- Maintain backward compatibility while fixing edge cases in package hierarchy
2026-01-28 16:58:42 +08:00
Rice
32935e4e87 fix: Fix multiple bugs in code package creation and display
## Overview
This PR addresses multiple critical bugs in code package creation and display functionality, and includes validation enhancements and performance optimizations to improve overall stability and user experience.

## Fixed Bugs
- Fixed issue where package paths starting with a slash (e.g., /name/xx/xx) failed to display
- Fixed package count showing only direct code snippets instead of including nested package content
- Fixed path conflict bug in movePackage() caused by improper string replacement
- Fixed dropdown selector displaying only full paths (missing parent path options)
- Added package name validation to block invalid characters and duplicate package names
- Optimized package deletion performance by only saving actually modified code snippets
- Added support for creating packages in /100/200/300 format, with dropdown selector showing all hierarchical paths

## Key Improvements
* displayedPackages: Correctly handle slash-leading paths and accurately calculate nested package counts
* createPackage: Added regex validation and duplicate check, support paths starting with a slash
* movePackage: Replaced replace() with substring() to avoid substring-based path conflicts
* packageOptions: Automatically generate all parent path options, sorted by depth and alphabetical order
* deletePackage: Improved performance by only persisting actually modified code snippets (instead of full dataset)
2026-01-28 16:49:45 +08:00
陈大猫
f55c21fc0e Merge pull request #140 from RiceWays/feature/vault-tree-view-mode
feat: Add tree view mode for host list with sorting and persistence
2026-01-28 16:07:06 +08:00
bincxz
26d03ace3f fix: improve tree view UX and address code review feedback
- Display folders above ungrouped hosts in tree view
- Change host connection from double-click to single-click
- Sanitize host before connecting to handle whitespace in hostname
- Guard optional host.tags to prevent crash on legacy data
- Show telnet-specific credentials (telnetUsername/telnetPort) for telnet hosts
- Remove unused groupTree variable and prefix unused moveHostToGroup param

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 15:40:45 +08:00
Rice
d85709d42d fix: apply search and tag filters to grouped hosts in tree view
- Use filtered treeViewHosts instead of all hosts when building tree view group tree
- Ensure grouped hosts respect search queries and tag filters
- Reorder useMemo dependencies to fix circular dependency issue
- Now tree view filtering behavior is consistent with grid and list views

Fixes issue where grouped hosts would still appear even when they didn't
match active search or tag filters, breaking the expected filtering UX.
2026-01-28 12:31:43 +08:00
Rice
5470e19ae0 chore: remove obsolete TODO comment 2026-01-28 11:16:31 +08:00
Rice
cd2c18b77c feat: add tree view mode for host list with sorting and persistence
- Add tree view mode alongside existing grid and list views
- Implement hierarchical display of hosts organized by groups
- Add expand/collapse all controls with Chinese translations
- Support all sorting modes (A-Z, Z-A, newest, oldest) in tree view
- Persist expand/collapse state across view switches and app restarts
- Hide Groups section in tree view to avoid duplication
- Display ungrouped hosts at root level instead of "General" group
- Add missing delete group dialog with proper translations
- Maintain full functionality: search, filtering, drag-drop, context menus

Technical changes:
- Create HostTreeView component with TreeNode recursive structure
- Add useTreeExpandedState hook for persistent state management
- Extend ViewMode type to include "tree" option
- Add sortMode prop to enable dynamic sorting in tree structure
- Separate group tree logic for tree view vs other view modes
- Add comprehensive English and Chinese translations
2026-01-28 11:13:17 +08:00
陈大猫
7355e29b89 Merge pull request #137 from Nightsuki/fix/ssh-jump-host-default-key-auth
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: add default SSH key fallback for jump host connections
2026-01-27 17:07:50 +08:00
bincxz
64686cc237 fix: pass unlocked encrypted keys to jump host auth handler
When auth failure triggers the passphrase flow and user unlocks
encrypted default keys, the retry connection now correctly passes
these unlocked keys to connectThroughChain/connectThroughChainForSftp.

Previously, options._unlockedEncryptedKeys was only used for the
final target host, so jump hosts requiring encrypted default keys
would still fail even after successful passphrase entry.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-27 16:54:54 +08:00
bincxz
d65440ace7 feat: add passphrase modal for encrypted SSH key authentication
- Add PassphraseModal component for interactive passphrase input
- Add passphraseHandler bridge to manage passphrase requests/responses
- Add sshAuthHelper for centralized SSH key decryption with passphrase support
- Update sshBridge, sftpBridge, and portForwardingBridge to use new auth helper
- Add passphrase-related IPC channels in preload and type definitions
- Add i18n translations for passphrase modal UI (en/zh-CN)

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-27 16:35:03 +08:00
Nightsuki
2dbeddd9aa fix: add default SSH key fallback for jump host connections
Previously, jump host connections (connectThroughChain) did not try
default SSH keys from ~/.ssh/ when no explicit auth was configured.
This caused authentication failures when using jump hosts without
manually specifying SSH keys.

Changes:
- Add ssh-agent support for jump host connections
- Try all default SSH keys (id_ed25519, id_ecdsa, id_rsa) for jump hosts
- Use dynamic authHandler to try each key in sequence
- Match the same fallback behavior as direct connections
2026-01-27 11:55:18 +08:00
陈大猫
4758345448 Merge pull request #136 from Nightsuki/fix/ssh-default-key-fallback-all-keys
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: try all default SSH keys for fallback authentication
2026-01-26 23:33:38 +08:00
Nightsuki
4d3fa93083 fix: try all default SSH keys for fallback authentication
Previously, when no explicit auth method was configured, the code would
only try the first available key (id_ed25519) even if the server only
accepted a different key (id_rsa). This caused authentication failures
when users had multiple SSH keys but only some were authorized.

Changes:
- Add findAllDefaultPrivateKeys() to discover all available keys
- Try ssh-agent first (matching regular SSH behavior)
- Try ALL default keys (id_ed25519, id_ecdsa, id_rsa) in order
- Add debug logging for ssh2 auth flow diagnostics
- Improve auth method ordering: agent -> keys -> password -> keyboard
2026-01-26 23:11:26 +08:00
陈大猫
2746aae274 Merge pull request #135 from binaricat/fix/sftp-local-files-freeze
fix: use async exec for Windows hidden file check to prevent UI freeze
2026-01-26 19:39:22 +08:00
bincxz
a7b22b3580 fix: use async exec for Windows hidden file check to prevent UI freeze
The isWindowsHiddenFile function was using execSync which blocks the
main process. When listing directories with many files on Windows,
this caused the app to freeze and show "No response" until all attrib
commands completed.

Changed to async exec with promisify to allow non-blocking execution.

Fixes #134

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 19:36:44 +08:00
19 changed files with 2488 additions and 362 deletions

81
App.tsx
View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View 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
View 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>
);
};

View 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;

View File

@@ -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>

View File

@@ -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}

View File

@@ -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",

View 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,
};

View File

@@ -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', () => {

View File

@@ -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

View 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,
};

View File

@@ -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,
};

View File

@@ -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
View File

@@ -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[]>;

View File

@@ -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
View File

@@ -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"
}