Skip to content

Commit fb4de2e

Browse files
Tomer Vakninclaude
andcommitted
Add 1Password integration, SFTP folder download, and UI fixes
- 1Password CLI integration: vault browser, op:// credential resolution, biometric auth - SFTP: 1Password credential support, recursive folder download - Fix tags persistence on host creation - Fix long hostnames pushing action buttons off-screen - Dynamic version display on startup screen - Add /bump command for release workflow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3ed9199 commit fb4de2e

33 files changed

Lines changed: 1891 additions & 82 deletions

src/SshManager.App/Infrastructure/DbMigrator.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,37 @@ await db.Database.ExecuteSqlRawAsync(
229229
logger.Information("Schema version updated to 1");
230230
} // end if (currentVersion < 1)
231231

232-
// Future migrations go here as: if (currentVersion < 2) { ... }
232+
if (currentVersion < 2)
233+
{
234+
// 1Password integration columns on Hosts table
235+
var hostsColumns2 = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
236+
await using (var cmd = connection.CreateCommand())
237+
{
238+
cmd.CommandText = "PRAGMA table_info(Hosts)";
239+
await using var reader = await cmd.ExecuteReaderAsync();
240+
while (await reader.ReadAsync())
241+
{
242+
hostsColumns2.Add(reader.GetString(1));
243+
}
244+
}
245+
246+
if (!hostsColumns2.Contains("OnePasswordReference"))
247+
{
248+
await db.Database.ExecuteSqlRawAsync(
249+
"ALTER TABLE Hosts ADD COLUMN OnePasswordReference TEXT DEFAULT NULL");
250+
logger.Information("Added missing column OnePasswordReference to Hosts table");
251+
}
252+
253+
if (!hostsColumns2.Contains("OnePasswordKeyReference"))
254+
{
255+
await db.Database.ExecuteSqlRawAsync(
256+
"ALTER TABLE Hosts ADD COLUMN OnePasswordKeyReference TEXT DEFAULT NULL");
257+
logger.Information("Added missing column OnePasswordKeyReference to Hosts table");
258+
}
259+
260+
await UpdateSchemaVersionAsync(connection, 2);
261+
logger.Information("Schema version updated to 2");
262+
}
233263
}
234264

235265
/// <summary>

src/SshManager.App/Infrastructure/SecurityServiceExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using SshManager.Security;
3+
using SshManager.Security.OnePassword;
34

45
namespace SshManager.App.Infrastructure;
56

@@ -16,6 +17,7 @@ public static IServiceCollection AddSecurityServices(this IServiceCollection ser
1617
services.AddSingleton<IPpkConverter, PpkConverter>();
1718
services.AddSingleton<IPassphraseEncryptionService, PassphraseEncryptionService>();
1819
services.AddSingleton<IKeyEncryptionService, KeyEncryptionService>();
20+
services.AddSingleton<IOnePasswordService, OnePasswordService>();
1921

2022
return services;
2123
}

src/SshManager.App/ViewModels/HostDialogViewModel.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using SshManager.Core.Models;
88
using SshManager.Data.Repositories;
99
using SshManager.Security;
10+
using SshManager.Security.OnePassword;
1011
using SshManager.Terminal.Services;
1112

1213
namespace SshManager.App.ViewModels;
@@ -138,6 +139,7 @@ public HostDialogViewModel(
138139
ISerialConnectionService serialConnectionService,
139140
IAgentDiagnosticsService? agentDiagnosticsService = null,
140141
IKerberosAuthService? kerberosAuthService = null,
142+
IOnePasswordService? onePasswordService = null,
141143
IHostProfileRepository? hostProfileRepo = null,
142144
IProxyJumpProfileRepository? proxyJumpRepo = null,
143145
IPortForwardingProfileRepository? portForwardingRepo = null,
@@ -161,6 +163,7 @@ public HostDialogViewModel(
161163
secretProtector,
162164
agentDiagnosticsService,
163165
kerberosAuthService,
166+
onePasswordService,
164167
hostProfileRepo,
165168
proxyJumpRepo,
166169
portForwardingRepo,

src/SshManager.App/ViewModels/HostEdit/SshConnectionSettingsViewModel.cs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using SshManager.Core.Models;
88
using SshManager.Data.Repositories;
99
using SshManager.Security;
10+
using SshManager.Security.OnePassword;
1011
using SshManager.Terminal.Services;
1112

1213
namespace SshManager.App.ViewModels.HostEdit;
@@ -22,6 +23,7 @@ public partial class SshConnectionSettingsViewModel : ObservableObject
2223
private readonly ISecretProtector _secretProtector;
2324
private readonly IAgentDiagnosticsService? _agentDiagnosticsService;
2425
private readonly IKerberosAuthService? _kerberosAuthService;
26+
private readonly IOnePasswordService? _onePasswordService;
2527
private readonly IHostProfileRepository? _hostProfileRepo;
2628
private readonly IProxyJumpProfileRepository? _proxyJumpRepo;
2729
private readonly IPortForwardingProfileRepository? _portForwardingRepo;
@@ -110,6 +112,25 @@ public partial class SshConnectionSettingsViewModel : ObservableObject
110112

111113
#endregion
112114

115+
#region 1Password Properties
116+
117+
[ObservableProperty]
118+
private string _onePasswordReference = "";
119+
120+
[ObservableProperty]
121+
private string _onePasswordKeyReference = "";
122+
123+
[ObservableProperty]
124+
private bool _isOnePasswordAvailable;
125+
126+
[ObservableProperty]
127+
private string _onePasswordStatusText = "Checking...";
128+
129+
[ObservableProperty]
130+
private bool _isCheckingOnePassword;
131+
132+
#endregion
133+
113134
#region Kerberos Properties
114135

115136
[ObservableProperty]
@@ -151,6 +172,11 @@ public partial class SshConnectionSettingsViewModel : ObservableObject
151172
/// </summary>
152173
public bool ShowKerberosSettings => AuthType == AuthType.Kerberos;
153174

175+
/// <summary>
176+
/// Gets whether to show 1Password settings.
177+
/// </summary>
178+
public bool ShowOnePasswordSettings => AuthType == AuthType.OnePassword;
179+
154180
/// <summary>
155181
/// Gets the display text for port forwarding status.
156182
/// </summary>
@@ -206,6 +232,7 @@ public SshConnectionSettingsViewModel(
206232
ISecretProtector secretProtector,
207233
IAgentDiagnosticsService? agentDiagnosticsService = null,
208234
IKerberosAuthService? kerberosAuthService = null,
235+
IOnePasswordService? onePasswordService = null,
209236
IHostProfileRepository? hostProfileRepo = null,
210237
IProxyJumpProfileRepository? proxyJumpRepo = null,
211238
IPortForwardingProfileRepository? portForwardingRepo = null,
@@ -215,6 +242,7 @@ public SshConnectionSettingsViewModel(
215242
_secretProtector = secretProtector;
216243
_agentDiagnosticsService = agentDiagnosticsService;
217244
_kerberosAuthService = kerberosAuthService;
245+
_onePasswordService = onePasswordService;
218246
_hostProfileRepo = hostProfileRepo;
219247
_proxyJumpRepo = proxyJumpRepo;
220248
_portForwardingRepo = portForwardingRepo;
@@ -259,6 +287,10 @@ public void LoadFromHost(HostEntry host)
259287
KerberosServicePrincipal = host.KerberosServicePrincipal;
260288
KerberosDelegateCredentials = host.KerberosDelegateCredentials;
261289

290+
// 1Password settings
291+
OnePasswordReference = host.OnePasswordReference ?? "";
292+
OnePasswordKeyReference = host.OnePasswordKeyReference ?? "";
293+
262294
// Keep-alive settings
263295
if (host.KeepAliveIntervalSeconds.HasValue)
264296
{
@@ -320,6 +352,22 @@ public void PopulateHost(HostEntry host)
320352
host.KerberosDelegateCredentials = false;
321353
}
322354

355+
// 1Password settings
356+
if (AuthType == AuthType.OnePassword)
357+
{
358+
host.OnePasswordReference = string.IsNullOrWhiteSpace(OnePasswordReference)
359+
? null
360+
: OnePasswordReference.Trim();
361+
host.OnePasswordKeyReference = string.IsNullOrWhiteSpace(OnePasswordKeyReference)
362+
? null
363+
: OnePasswordKeyReference.Trim();
364+
}
365+
else
366+
{
367+
host.OnePasswordReference = null;
368+
host.OnePasswordKeyReference = null;
369+
}
370+
323371
// Host/Proxy profiles
324372
host.HostProfileId = SelectedHostProfile?.Id;
325373
host.HostProfile = SelectedHostProfile;
@@ -375,6 +423,11 @@ public async Task LoadAsync(CancellationToken ct = default)
375423
{
376424
_ = RefreshKerberosStatusAsync();
377425
}
426+
427+
if (AuthType == AuthType.OnePassword)
428+
{
429+
_ = RefreshOnePasswordStatusAsync();
430+
}
378431
}
379432

380433
/// <summary>
@@ -615,6 +668,58 @@ private async Task RefreshKerberosStatusAsync()
615668
}
616669
}
617670

671+
/// <summary>
672+
/// Refreshes 1Password CLI status information.
673+
/// </summary>
674+
[RelayCommand]
675+
private async Task RefreshOnePasswordStatusAsync()
676+
{
677+
if (_onePasswordService == null)
678+
{
679+
OnePasswordStatusText = "1Password service not available";
680+
IsOnePasswordAvailable = false;
681+
return;
682+
}
683+
684+
IsCheckingOnePassword = true;
685+
686+
try
687+
{
688+
var status = await _onePasswordService.GetStatusAsync();
689+
690+
IsOnePasswordAvailable = status.IsInstalled && status.IsAuthenticated;
691+
692+
if (status.IsAuthenticated)
693+
{
694+
OnePasswordStatusText = !string.IsNullOrEmpty(status.AccountEmail)
695+
? $"Connected as {status.AccountEmail}"
696+
: "Authenticated";
697+
}
698+
else if (status.IsInstalled)
699+
{
700+
var hint = !string.IsNullOrEmpty(status.ErrorMessage) ? status.ErrorMessage
701+
: "Enable CLI integration in 1Password: Settings > Developer";
702+
OnePasswordStatusText = $"Not authenticated — {hint}";
703+
}
704+
else
705+
{
706+
OnePasswordStatusText = "1Password CLI not installed";
707+
}
708+
709+
_logger.LogDebug("1Password status refreshed: {StatusText}", OnePasswordStatusText);
710+
}
711+
catch (Exception ex)
712+
{
713+
OnePasswordStatusText = "Error checking 1Password status";
714+
IsOnePasswordAvailable = false;
715+
_logger.LogWarning(ex, "Failed to refresh 1Password status");
716+
}
717+
finally
718+
{
719+
IsCheckingOnePassword = false;
720+
}
721+
}
722+
618723
#endregion
619724

620725
#region Property Changed Handlers
@@ -625,6 +730,7 @@ partial void OnAuthTypeChanged(AuthType value)
625730
OnPropertyChanged(nameof(ShowPassword));
626731
OnPropertyChanged(nameof(ShowAgentStatus));
627732
OnPropertyChanged(nameof(ShowKerberosSettings));
733+
OnPropertyChanged(nameof(ShowOnePasswordSettings));
628734

629735
// Refresh agent status when switching to SSH Agent auth
630736
if (value == AuthType.SshAgent)
@@ -637,6 +743,12 @@ partial void OnAuthTypeChanged(AuthType value)
637743
{
638744
_ = RefreshKerberosStatusAsync();
639745
}
746+
747+
// Refresh 1Password status when switching to OnePassword auth
748+
if (value == AuthType.OnePassword)
749+
{
750+
_ = RefreshOnePasswordStatusAsync();
751+
}
640752
}
641753

642754
partial void OnPortForwardingProfileCountChanged(int value)
@@ -709,6 +821,23 @@ public List<string> Validate()
709821
errors.Add($"Private key file not found: {keyPath}");
710822
}
711823
break;
824+
825+
case AuthType.OnePassword:
826+
var pwdRef = OnePasswordReference?.Trim() ?? "";
827+
var keyRef = OnePasswordKeyReference?.Trim() ?? "";
828+
if (string.IsNullOrWhiteSpace(pwdRef) && string.IsNullOrWhiteSpace(keyRef))
829+
{
830+
errors.Add("At least one 1Password reference (password or SSH key) is required");
831+
}
832+
if (!string.IsNullOrWhiteSpace(pwdRef) && !pwdRef.StartsWith("op://", StringComparison.OrdinalIgnoreCase))
833+
{
834+
errors.Add("Password reference must start with 'op://'");
835+
}
836+
if (!string.IsNullOrWhiteSpace(keyRef) && !keyRef.StartsWith("op://", StringComparison.OrdinalIgnoreCase))
837+
{
838+
errors.Add("SSH key reference must start with 'op://'");
839+
}
840+
break;
712841
}
713842

714843
return errors;

src/SshManager.App/ViewModels/HostManagementViewModel.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
using SshManager.Data.Repositories;
1313
using SshManager.Data.Services;
1414
using SshManager.Security;
15+
using SshManager.Security.OnePassword;
1516
using SshManager.Terminal.Services;
1617
using SshManager.App.Views.Dialogs;
1718
using SshManager.App.Behaviors;
@@ -36,6 +37,7 @@ public partial class HostManagementViewModel : ObservableObject, IDisposable
3637
private readonly ISerialConnectionService _serialConnectionService;
3738
private readonly IAgentDiagnosticsService? _agentDiagnosticsService;
3839
private readonly IKerberosAuthService? _kerberosAuthService;
40+
private readonly IOnePasswordService? _onePasswordService;
3941
private readonly IHostValidationService _validationService;
4042
private readonly IHostCacheService _hostCacheService;
4143
private readonly ILogger<HostManagementViewModel> _logger;
@@ -90,6 +92,7 @@ public HostManagementViewModel(
9092
IHostCacheService hostCacheService,
9193
IAgentDiagnosticsService? agentDiagnosticsService = null,
9294
IKerberosAuthService? kerberosAuthService = null,
95+
IOnePasswordService? onePasswordService = null,
9396
ILogger<HostManagementViewModel>? logger = null)
9497
{
9598
_hostRepo = hostRepo;
@@ -105,6 +108,7 @@ public HostManagementViewModel(
105108
_hostCacheService = hostCacheService;
106109
_agentDiagnosticsService = agentDiagnosticsService;
107110
_kerberosAuthService = kerberosAuthService;
111+
_onePasswordService = onePasswordService;
108112
_logger = logger ?? NullLogger<HostManagementViewModel>.Instance;
109113

110114
// Subscribe to initial collection changes
@@ -328,6 +332,7 @@ private async Task AddHostAsync()
328332
_serialConnectionService,
329333
_agentDiagnosticsService,
330334
_kerberosAuthService,
335+
_onePasswordService,
331336
_hostProfileRepo,
332337
_proxyJumpRepo,
333338
_portForwardingRepo,
@@ -362,6 +367,10 @@ private async Task AddHostAsync()
362367
var host = viewModel.GetHost();
363368
await _hostRepo.AddAsync(host);
364369

370+
// Save tags via the proper many-to-many method
371+
var tagIds = viewModel.Metadata.GetSelectedTagIds().ToList();
372+
await _hostRepo.SetHostTagsAsync(host.Id, tagIds);
373+
365374
// Save environment variables
366375
var envVars = viewModel.GetEnvironmentVariables().ToList();
367376
if (envVars.Count > 0)
@@ -422,6 +431,7 @@ private async Task EditHostAsync(HostEntry? host)
422431
_serialConnectionService,
423432
_agentDiagnosticsService,
424433
_kerberosAuthService,
434+
_onePasswordService,
425435
_hostProfileRepo,
426436
_proxyJumpRepo,
427437
_portForwardingRepo,
@@ -446,6 +456,10 @@ private async Task EditHostAsync(HostEntry? host)
446456
var updatedHost = viewModel.GetHost();
447457
await _hostRepo.UpdateAsync(updatedHost);
448458

459+
// Save tags via the proper many-to-many method
460+
var tagIds = viewModel.Metadata.GetSelectedTagIds().ToList();
461+
await _hostRepo.SetHostTagsAsync(updatedHost.Id, tagIds);
462+
449463
// Save environment variables
450464
var envVars = viewModel.GetEnvironmentVariables().ToList();
451465
await _envVarRepo.SetForHostAsync(updatedHost.Id, envVars);

0 commit comments

Comments
 (0)