feat(ssh): enhance SSH configuration management and validation

- Added SSH section validation to the configuration update process, including validation for SSH profiles.
- Implemented functions to validate SSH profiles and the overall SSH section structure.
- Integrated SSH profile management into the ConnectionSection, allowing users to select saved profiles for quick connection setup.
- Updated the WorkspaceSection to manage SSH profiles with full CRUD functionality, ensuring persistence through the ConfigManager.
- Refactored API calls to use a unified `api` module for better maintainability.

This commit significantly improves the SSH management experience by providing robust validation and user-friendly profile handling.
This commit is contained in:
matt 2026-02-12 15:49:12 +09:00
parent b200561ac1
commit 575ced5d99
9 changed files with 213 additions and 131 deletions

View file

@ -10,6 +10,7 @@ import type {
HttpServerConfig,
NotificationConfig,
NotificationTrigger,
SshPersistConfig,
} from '../services';
type ConfigSection = keyof AppConfig;
@ -30,6 +31,7 @@ export type ConfigUpdateValidationResult =
| ValidationSuccess<'general'>
| ValidationSuccess<'display'>
| ValidationSuccess<'httpServer'>
| ValidationSuccess<'ssh'>
| ValidationFailure;
const VALID_SECTIONS = new Set<ConfigSection>([
@ -37,6 +39,7 @@ const VALID_SECTIONS = new Set<ConfigSection>([
'general',
'display',
'httpServer',
'ssh',
]);
const MAX_SNOOZE_MINUTES = 24 * 60;
@ -321,6 +324,70 @@ function validateHttpServerSection(
};
}
function isValidSshProfile(profile: unknown): boolean {
if (!isPlainObject(profile)) return false;
if (typeof profile.id !== 'string' || profile.id.trim().length === 0) return false;
if (typeof profile.name !== 'string') return false;
if (typeof profile.host !== 'string') return false;
if (typeof profile.port !== 'number') return false;
if (typeof profile.username !== 'string') return false;
const validMethods = ['password', 'privateKey', 'agent', 'auto'];
if (!validMethods.includes(profile.authMethod as string)) return false;
return true;
}
function validateSshSection(data: unknown): ValidationSuccess<'ssh'> | ValidationFailure {
if (!isPlainObject(data)) {
return { valid: false, error: 'ssh update must be an object' };
}
const allowedKeys: (keyof SshPersistConfig)[] = [
'lastConnection',
'autoReconnect',
'profiles',
'lastActiveContextId',
];
const result: Partial<SshPersistConfig> = {};
for (const [key, value] of Object.entries(data)) {
if (!allowedKeys.includes(key as keyof SshPersistConfig)) {
return { valid: false, error: `ssh.${key} is not a valid setting` };
}
switch (key as keyof SshPersistConfig) {
case 'autoReconnect':
if (typeof value !== 'boolean') {
return { valid: false, error: 'ssh.autoReconnect must be a boolean' };
}
result.autoReconnect = value;
break;
case 'lastActiveContextId':
if (typeof value !== 'string') {
return { valid: false, error: 'ssh.lastActiveContextId must be a string' };
}
result.lastActiveContextId = value;
break;
case 'lastConnection':
if (value !== null && !isPlainObject(value)) {
return { valid: false, error: 'ssh.lastConnection must be an object or null' };
}
result.lastConnection = value as SshPersistConfig['lastConnection'];
break;
case 'profiles':
if (!Array.isArray(value) || !value.every(isValidSshProfile)) {
return { valid: false, error: 'ssh.profiles must be a valid profile array' };
}
result.profiles = value as SshPersistConfig['profiles'];
break;
default:
return { valid: false, error: `Unsupported ssh key: ${key}` };
}
}
return { valid: true, section: 'ssh', data: result };
}
export function validateConfigUpdatePayload(
section: unknown,
data: unknown
@ -328,7 +395,7 @@ export function validateConfigUpdatePayload(
if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) {
return {
valid: false,
error: 'Section must be one of: notifications, general, display, httpServer',
error: 'Section must be one of: notifications, general, display, httpServer, ssh',
};
}
@ -341,6 +408,8 @@ export function validateConfigUpdatePayload(
return validateDisplaySection(data);
case 'httpServer':
return validateHttpServerSection(data);
case 'ssh':
return validateSshSection(data);
default:
return { valid: false, error: 'Invalid section' };
}

View file

@ -4,6 +4,7 @@ import { ContextSwitchOverlay } from './components/common/ContextSwitchOverlay';
import { ErrorBoundary } from './components/common/ErrorBoundary';
import { TabbedLayout } from './components/layout/TabbedLayout';
import { useTheme } from './hooks/useTheme';
import { api } from './api';
import { initializeNotificationListeners, useStore } from './store';
export const App = (): React.JSX.Element => {
@ -26,8 +27,8 @@ export const App = (): React.JSX.Element => {
// Refresh available contexts when SSH connection state changes
useEffect(() => {
if (!window.electronAPI.ssh?.onStatus) return;
const cleanup = window.electronAPI.ssh.onStatus(() => {
if (!api.ssh?.onStatus) return;
const cleanup = api.ssh.onStatus(() => {
void useStore.getState().fetchAvailableContexts();
});
return cleanup;

View file

@ -102,6 +102,10 @@ export class HttpAPIClient implements ElectronAPI {
private async parseJson<T>(res: Response): Promise<T> {
const text = await res.text();
if (!res.ok) {
const parsed = JSON.parse(text) as { error?: string };
throw new Error(parsed.error ?? `HTTP ${res.status}`);
}
return JSON.parse(text, HttpAPIClient.reviveDates) as T;
}

View file

@ -13,6 +13,8 @@ interface SettingsSelectProps<T extends string | number> {
readonly onChange: (value: T) => void;
readonly disabled?: boolean;
readonly dropUp?: boolean;
/** When true, trigger spans full width and dropdown aligns left */
readonly fullWidth?: boolean;
}
export const SettingsSelect = <T extends string | number>({
@ -21,6 +23,7 @@ export const SettingsSelect = <T extends string | number>({
onChange,
disabled = false,
dropUp = false,
fullWidth = false,
}: SettingsSelectProps<T>): React.JSX.Element => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
@ -54,7 +57,7 @@ export const SettingsSelect = <T extends string | number>({
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
className={`flex h-8 min-w-[140px] items-center justify-between gap-2 rounded-md border bg-transparent px-2 text-sm transition-all duration-150 focus:outline-none focus:ring-1 focus:ring-zinc-700 ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${isOpen ? 'ring-1 ring-zinc-700' : ''} `}
className={`flex h-8 items-center justify-between gap-2 rounded-md border bg-transparent px-2 text-sm transition-all duration-150 focus:outline-none focus:ring-1 focus:ring-zinc-700 ${fullWidth ? 'w-full' : 'min-w-[140px]'} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'} ${isOpen ? 'ring-1 ring-zinc-700' : ''} `}
style={{
color: 'var(--color-text-secondary)',
borderColor: isOpen ? 'var(--color-border)' : 'var(--color-border-subtle)',
@ -70,7 +73,7 @@ export const SettingsSelect = <T extends string | number>({
{/* Dropdown Menu */}
{isOpen && (
<div
className={`absolute right-0 z-50 min-w-max overflow-hidden rounded-md border py-1 shadow-xl shadow-black/20 ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}
className={`absolute z-50 min-w-max overflow-hidden rounded-md border py-1 shadow-xl shadow-black/20 ${fullWidth ? 'inset-x-0' : 'right-0'} ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}
style={{
backgroundColor: 'var(--color-surface-raised)',
borderColor: 'var(--color-border-subtle)',

View file

@ -8,15 +8,29 @@
* - Testing and connecting to remote hosts
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { Loader2, Monitor, Wifi, WifiOff } from 'lucide-react';
import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react';
import { SettingRow } from '../components/SettingRow';
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
import { SettingsSelect } from '../components/SettingsSelect';
import type { SshAuthMethod, SshConfigHostEntry, SshConnectionConfig } from '@shared/types';
import type {
SshAuthMethod,
SshConfigHostEntry,
SshConnectionConfig,
SshConnectionProfile,
} from '@shared/types';
const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
{ value: 'auto', label: 'Auto (from SSH Config)' },
{ value: 'agent', label: 'SSH Agent' },
{ value: 'privateKey', label: 'Private Key' },
{ value: 'password', label: 'Password' },
];
export const ConnectionSection = (): React.JSX.Element => {
const connectionState = useStore((s) => s.connectionState);
@ -45,11 +59,25 @@ export const ConnectionSection = (): React.JSX.Element => {
const hostInputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Fetch SSH config hosts and load last connection on mount
// Saved profiles
const [savedProfiles, setSavedProfiles] = useState<SshConnectionProfile[]>([]);
const loadProfiles = useCallback(async () => {
try {
const config = await api.config.get();
const loaded = config.ssh;
setSavedProfiles(loaded?.profiles ?? []);
} catch {
// ignore
}
}, []);
// Fetch SSH config hosts, saved profiles, and load last connection on mount
useEffect(() => {
void fetchSshConfigHosts();
void loadLastConnection();
}, [fetchSshConfigHosts, loadLastConnection]);
void loadProfiles();
}, [fetchSshConfigHosts, loadLastConnection, loadProfiles]);
// Pre-fill form from saved connection config when it arrives (one-time on mount).
// setState in effect is intentional: lastSshConfig loads async from IPC, so we can't
@ -104,6 +132,16 @@ export const ConnectionSection = (): React.JSX.Element => {
setTestResult(null);
};
const handleSelectProfile = (profile: SshConnectionProfile): void => {
setHost(profile.host);
setPort(String(profile.port));
setUsername(profile.username);
setAuthMethod(profile.authMethod);
if (profile.privateKeyPath) setPrivateKeyPath(profile.privateKeyPath);
setPassword('');
setTestResult(null);
};
const buildConfig = (): SshConnectionConfig => ({
host,
port: parseInt(port, 10) || 22,
@ -196,6 +234,35 @@ export const ConnectionSection = (): React.JSX.Element => {
</SettingRow>
)}
{/* Saved Profiles */}
{!isConnected && savedProfiles.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>
Saved Profiles
</h3>
<div className="flex flex-wrap gap-2">
{savedProfiles.map((profile) => (
<button
key={profile.id}
type="button"
onClick={() => handleSelectProfile(profile)}
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-surface-raised"
style={{
borderColor: 'var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<Server className="size-3.5" style={{ color: 'var(--color-text-muted)' }} />
<span>{profile.name}</span>
<span className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
{profile.username}@{profile.host}
</span>
</button>
))}
</div>
</div>
)}
{/* SSH Connection Form */}
{!isConnected && (
<div className="space-y-4">
@ -292,17 +359,12 @@ export const ConnectionSection = (): React.JSX.Element => {
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
Authentication
</label>
<select
<SettingsSelect
value={authMethod}
onChange={(e) => setAuthMethod(e.target.value as SshAuthMethod)}
className="w-full rounded-md border px-3 py-1.5 text-sm"
style={inputStyle}
>
<option value="auto">Auto (from SSH Config)</option>
<option value="agent">SSH Agent</option>
<option value="privateKey">Private Key</option>
<option value="password">Password</option>
</select>
options={authMethodOptions}
onChange={setAuthMethod}
fullWidth
/>
</div>
{authMethod === 'privateKey' && (

View file

@ -10,12 +10,14 @@
* Profile changes persist via ConfigManager and trigger context list refresh.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { ChevronDown, Edit2, Loader2, Plus, Save, Server, Trash2, X } from 'lucide-react';
import { Edit2, Loader2, Plus, Save, Server, Trash2, X } from 'lucide-react';
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
import { SettingsSelect } from '../components/SettingsSelect';
import type { SshAuthMethod, SshConnectionProfile } from '@shared/types';
@ -26,7 +28,7 @@ const inputStyle = {
color: 'var(--color-text)',
};
const authMethodOptions: { value: SshAuthMethod; label: string }[] = [
const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
{ value: 'auto', label: 'Auto (from SSH Config)' },
{ value: 'agent', label: 'SSH Agent' },
{ value: 'privateKey', label: 'Private Key' },
@ -42,101 +44,6 @@ const defaultForm = {
privateKeyPath: '',
};
const AuthMethodSelect = ({
value,
onChange,
}: {
value: SshAuthMethod;
onChange: (v: SshAuthMethod) => void;
}): React.JSX.Element => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent): void => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
const handleEscape = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setIsOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
const selectedLabel = authMethodOptions.find((o) => o.value === value)?.label ?? value;
return (
<div>
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
Authentication
</label>
<div ref={containerRef} className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={inputClass}
style={{
...inputStyle,
textAlign: 'left',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span>{selectedLabel}</span>
<ChevronDown
className={`size-3.5 transition-transform duration-150 ${isOpen ? 'rotate-180' : ''}`}
style={{ color: 'var(--color-text-muted)' }}
/>
</button>
{isOpen && (
<div
className="absolute inset-x-0 top-full z-50 mt-1 overflow-hidden rounded-md border shadow-lg"
style={{
backgroundColor: 'var(--color-surface-overlay)',
borderColor: 'var(--color-border-emphasis)',
}}
>
{authMethodOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value);
setIsOpen(false);
}}
className="flex w-full items-center px-3 py-2 text-left text-sm transition-colors"
style={{
color: opt.value === value ? 'var(--color-text)' : 'var(--color-text-secondary)',
backgroundColor:
opt.value === value ? 'var(--color-surface-raised)' : 'transparent',
}}
onMouseEnter={(e) => {
if (opt.value !== value)
e.currentTarget.style.backgroundColor = 'var(--color-surface-raised)';
}}
onMouseLeave={(e) => {
if (opt.value !== value) e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{opt.label}
</button>
))}
</div>
)}
</div>
</div>
);
};
export const WorkspaceSection = (): React.JSX.Element => {
const [profiles, setProfiles] = useState<SshConnectionProfile[]>([]);
const [loading, setLoading] = useState(true);
@ -162,9 +69,9 @@ export const WorkspaceSection = (): React.JSX.Element => {
const loadProfiles = useCallback(async () => {
try {
const config = await window.electronAPI.config.get();
const config = await api.config.get();
// AppConfig type doesn't include ssh field, but ConfigManager returns it at runtime
const loaded = (config as unknown as { ssh?: { profiles?: SshConnectionProfile[] } }).ssh;
const loaded = config.ssh;
setProfiles(loaded?.profiles ?? []);
} catch (error) {
console.error('[WorkspaceSection] Failed to load profiles:', error);
@ -203,7 +110,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
privateKeyPath: formAuthMethod === 'privateKey' ? formPrivateKeyPath.trim() : undefined,
};
await window.electronAPI.config.update('ssh', { profiles: [...profiles, newProfile] });
await api.config.update('ssh', { profiles: [...profiles, newProfile] });
await loadProfiles();
resetForm();
setShowAddForm(false);
@ -225,7 +132,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
: p
);
await window.electronAPI.config.update('ssh', { profiles: updatedProfiles });
await api.config.update('ssh', { profiles: updatedProfiles });
await loadProfiles();
setEditingId(null);
resetForm();
@ -240,7 +147,7 @@ export const WorkspaceSection = (): React.JSX.Element => {
if (!confirmed) return;
const filtered = profiles.filter((p) => p.id !== id);
await window.electronAPI.config.update('ssh', { profiles: filtered });
await api.config.update('ssh', { profiles: filtered });
await loadProfiles();
void useStore.getState().fetchAvailableContexts();
};
@ -314,7 +221,17 @@ export const WorkspaceSection = (): React.JSX.Element => {
</div>
</div>
<AuthMethodSelect value={formAuthMethod} onChange={setFormAuthMethod} />
<div>
<label className="mb-1 block text-xs" style={{ color: 'var(--color-text-muted)' }}>
Authentication
</label>
<SettingsSelect
value={formAuthMethod}
options={authMethodOptions}
onChange={setFormAuthMethod}
fullWidth
/>
</div>
{formAuthMethod === 'privateKey' && (
<div>

View file

@ -304,8 +304,8 @@ export function initializeNotificationListeners(): () => void {
}
// Listen for context changes from main process (e.g., SSH disconnect)
if (window.electronAPI.context?.onChanged) {
const cleanup = window.electronAPI.context.onChanged((_event: unknown, data: unknown) => {
if (api.context?.onChanged) {
const cleanup = api.context.onChanged((_event: unknown, data: unknown) => {
const { id } = data as { id: string };
const currentContextId = useStore.getState().activeContextId;
if (id !== currentContextId) {

View file

@ -5,6 +5,7 @@
* between local and SSH contexts, with IndexedDB persistence and TTL.
*/
import { api } from '@renderer/api';
import { contextStorage } from '@renderer/services/contextStorage';
import { getFullResetState } from '../utils/stateResetHelpers';
@ -243,7 +244,7 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
}
// Fetch active context from main process
const activeContextId = await window.electronAPI.context.getActive();
const activeContextId = await api.context.getActive();
set({
contextSnapshotsReady: true,
@ -261,7 +262,7 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
// Fetch list of available contexts (local + SSH)
fetchAvailableContexts: async () => {
try {
const result = await window.electronAPI.context.list();
const result = await api.context.list();
set({ availableContexts: result });
} catch (error) {
console.error('[contextSlice] Failed to fetch available contexts:', error);
@ -290,12 +291,12 @@ export const createContextSlice: StateCreator<AppState, [], [], ContextSlice> =
await contextStorage.saveSnapshot(state.activeContextId, currentSnapshot);
// Step 2: Switch main process context
await window.electronAPI.context.switch(targetContextId);
await api.context.switch(targetContextId);
// Step 3: Fetch fresh data from target context
const [freshProjects, freshRepoGroups] = await Promise.all([
window.electronAPI.getProjects(),
window.electronAPI.getRepositoryGroups(),
api.getProjects(),
api.getRepositoryGroups(),
]);
// Step 4: Attempt to restore snapshot for target context

View file

@ -275,6 +275,31 @@ export interface AppConfig {
/** Pinned sessions per project. Key is projectId, value is array of pinned sessions */
pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>;
};
/** SSH connection settings */
ssh?: {
/** Last used connection details */
lastConnection: {
host: string;
port: number;
username: string;
authMethod: 'password' | 'privateKey' | 'agent' | 'auto';
privateKeyPath?: string;
} | null;
/** Whether to auto-reconnect on launch */
autoReconnect: boolean;
/** Saved SSH connection profiles */
profiles: {
id: string;
name: string;
host: string;
port: number;
username: string;
authMethod: 'password' | 'privateKey' | 'agent' | 'auto';
privateKeyPath?: string;
}[];
/** Last active context ID */
lastActiveContextId: string;
};
/** HTTP sidecar server settings for iframe embedding */
httpServer?: {
/** Whether the HTTP server is enabled */