feat: Add Instance Details ViewModel and UI for managing instance credentials

- Introduced InstanceDetailsViewModel to handle loading and displaying instance-specific credentials.
- Created InstanceDetailsWindow and associated XAML for displaying admin, database, and OAuth2 credentials.
- Updated InstancesViewModel to include command for opening instance details.
- Enhanced SettingsViewModel to manage Bitwarden and Xibo Bootstrap configurations, including connection testing.
- Added UI components for Bitwarden Secrets Manager and Xibo Bootstrap OAuth2 settings in the SettingsView.
- Implemented password visibility toggles and clipboard copy functionality for sensitive information.
This commit is contained in:
Matt Batchelder
2026-02-25 08:05:44 -05:00
parent 28e79459ac
commit a1c987ff21
17 changed files with 1608 additions and 42 deletions

View File

@@ -0,0 +1,266 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using OTSSignsOrchestrator.Core.Models.Entities;
using OTSSignsOrchestrator.Core.Services;
using OTSSignsOrchestrator.Desktop.Models;
using OTSSignsOrchestrator.Desktop.Services;
namespace OTSSignsOrchestrator.Desktop.ViewModels;
/// <summary>
/// ViewModel for the instance details modal.
/// Shows admin credentials, DB credentials, and OAuth2 app details
/// with options to rotate passwords.
/// </summary>
public partial class InstanceDetailsViewModel : ObservableObject
{
private readonly IServiceProvider _services;
// ── Instance metadata ─────────────────────────────────────────────────────
[ObservableProperty] private string _stackName = string.Empty;
[ObservableProperty] private string _customerAbbrev = string.Empty;
[ObservableProperty] private string _hostLabel = string.Empty;
[ObservableProperty] private string _instanceUrl = string.Empty;
// ── OTS admin credentials ─────────────────────────────────────────────────
[ObservableProperty] private string _adminUsername = string.Empty;
[ObservableProperty] private string _adminPassword = string.Empty;
[ObservableProperty] private bool _adminPasswordVisible = false;
[ObservableProperty] private string _adminPasswordDisplay = "••••••••";
// ── Database credentials ──────────────────────────────────────────────────
[ObservableProperty] private string _dbUsername = string.Empty;
[ObservableProperty] private string _dbPassword = string.Empty;
[ObservableProperty] private bool _dbPasswordVisible = false;
[ObservableProperty] private string _dbPasswordDisplay = "••••••••";
// ── OAuth2 application ────────────────────────────────────────────────────
[ObservableProperty] private string _oAuthClientId = string.Empty;
[ObservableProperty] private string _oAuthClientSecret = string.Empty;
[ObservableProperty] private bool _oAuthSecretVisible = false;
[ObservableProperty] private string _oAuthSecretDisplay = "••••••••";
// ── Status ────────────────────────────────────────────────────────────────
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private bool _isBusy;
public InstanceDetailsViewModel(IServiceProvider services)
{
_services = services;
}
// ─────────────────────────────────────────────────────────────────────────
// Load
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Populates the ViewModel from a live <see cref="LiveStackItem"/>.</summary>
public async Task LoadAsync(LiveStackItem instance)
{
StackName = instance.StackName;
CustomerAbbrev = instance.CustomerAbbrev;
HostLabel = instance.HostLabel;
IsBusy = true;
StatusMessage = "Loading credentials...";
try
{
using var scope = _services.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var postInit = scope.ServiceProvider.GetRequiredService<PostInstanceInitService>();
// Derive the instance URL from the CMS server name template
var serverTemplate = await settings.GetAsync(
SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
InstanceUrl = $"https://{serverName}";
// ── Admin credentials ─────────────────────────────────────────
var creds = await postInit.GetCredentialsAsync(instance.CustomerAbbrev);
AdminUsername = creds.AdminUsername;
SetAdminPassword(creds.AdminPassword ?? string.Empty);
OAuthClientId = creds.OAuthClientId ?? string.Empty;
SetOAuthSecret(creds.OAuthClientSecret ?? string.Empty);
// ── DB credentials ────────────────────────────────────────────
var mySqlUserTemplate = await settings.GetAsync(
SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
DbUsername = mySqlUserTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(instance.CustomerAbbrev));
SetDbPassword(dbPw ?? string.Empty);
StatusMessage = creds.HasAdminPassword
? "Credentials loaded."
: "Credentials not yet available — post-install setup may still be running.";
}
catch (Exception ex)
{
StatusMessage = $"Error loading credentials: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Visibility toggles
// ─────────────────────────────────────────────────────────────────────────
[RelayCommand]
private void ToggleAdminPasswordVisibility()
{
AdminPasswordVisible = !AdminPasswordVisible;
AdminPasswordDisplay = AdminPasswordVisible
? AdminPassword
: (AdminPassword.Length > 0 ? "••••••••" : "(not set)");
}
[RelayCommand]
private void ToggleDbPasswordVisibility()
{
DbPasswordVisible = !DbPasswordVisible;
DbPasswordDisplay = DbPasswordVisible
? DbPassword
: (DbPassword.Length > 0 ? "••••••••" : "(not set)");
}
[RelayCommand]
private void ToggleOAuthSecretVisibility()
{
OAuthSecretVisible = !OAuthSecretVisible;
OAuthSecretDisplay = OAuthSecretVisible
? OAuthClientSecret
: (OAuthClientSecret.Length > 0 ? "••••••••" : "(not set)");
}
// ─────────────────────────────────────────────────────────────────────────
// Clipboard
// ─────────────────────────────────────────────────────────────────────────
[RelayCommand]
private async Task CopyAdminPasswordAsync()
=> await CopyToClipboardAsync(AdminPassword, "Admin password");
[RelayCommand]
private async Task CopyDbPasswordAsync()
=> await CopyToClipboardAsync(DbPassword, "DB password");
[RelayCommand]
private async Task CopyOAuthClientIdAsync()
=> await CopyToClipboardAsync(OAuthClientId, "OAuth client ID");
[RelayCommand]
private async Task CopyOAuthSecretAsync()
=> await CopyToClipboardAsync(OAuthClientSecret, "OAuth client secret");
// ─────────────────────────────────────────────────────────────────────────
// Rotation
// ─────────────────────────────────────────────────────────────────────────
[RelayCommand]
private async Task RotateAdminPasswordAsync()
{
if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return;
IsBusy = true;
StatusMessage = "Rotating OTS admin password...";
try
{
var postInit = _services.GetRequiredService<PostInstanceInitService>();
var newPassword = await postInit.RotateAdminPasswordAsync(CustomerAbbrev, InstanceUrl);
SetAdminPassword(newPassword);
StatusMessage = "Admin password rotated successfully.";
}
catch (Exception ex)
{
StatusMessage = $"Error rotating admin password: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task RotateDbPasswordAsync()
{
if (string.IsNullOrWhiteSpace(CustomerAbbrev)) return;
IsBusy = true;
StatusMessage = $"Rotating MySQL password for {StackName}...";
try
{
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
// We need the Host — retrieve from the HostLabel lookup
using var scope = _services.CreateScope();
var instanceSvc = scope.ServiceProvider.GetRequiredService<InstanceService>();
// Get the host from the loaded stack — caller must have set the SSH host before
var (ok, msg) = await instanceSvc.RotateMySqlPasswordAsync(StackName);
if (ok)
{
// Reload DB password
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
var dbPw = await settings.GetAsync(SettingsService.InstanceMySqlPassword(CustomerAbbrev));
SetDbPassword(dbPw ?? string.Empty);
StatusMessage = $"DB password rotated: {msg}";
}
else
{
StatusMessage = $"DB rotation failed: {msg}";
}
}
catch (Exception ex)
{
StatusMessage = $"Error rotating DB password: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private void SetAdminPassword(string value)
{
AdminPassword = value;
AdminPasswordVisible = false;
AdminPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)";
}
private void SetDbPassword(string value)
{
DbPassword = value;
DbPasswordVisible = false;
DbPasswordDisplay = value.Length > 0 ? "••••••••" : "(not set)";
}
private void SetOAuthSecret(string value)
{
OAuthClientSecret = value;
OAuthSecretVisible = false;
OAuthSecretDisplay = value.Length > 0 ? "••••••••" : "(not set)";
}
private static async Task CopyToClipboardAsync(string text, string label)
{
if (string.IsNullOrEmpty(text)) return;
var topLevel = Avalonia.Application.Current?.ApplicationLifetime is
Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime dt
? dt.MainWindow
: null;
var clipboard = topLevel is not null ? Avalonia.Controls.TopLevel.GetTopLevel(topLevel)?.Clipboard : null;
if (clipboard is not null)
await clipboard.SetTextAsync(text);
}
}

View File

@@ -30,6 +30,9 @@ public partial class InstancesViewModel : ObservableObject
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
/// <summary>Raised when the instance details modal should be opened for the given ViewModel.</summary>
public event Action<InstanceDetailsViewModel>? OpenDetailsRequested;
public InstancesViewModel(IServiceProvider services)
{
_services = services;
@@ -158,4 +161,29 @@ public partial class InstancesViewModel : ObservableObject
catch (Exception ex) { StatusMessage = $"Error rotating password: {ex.Message}"; }
finally { IsBusy = false; }
}
[RelayCommand]
private async Task OpenDetailsAsync()
{
if (SelectedInstance == null) return;
IsBusy = true;
StatusMessage = $"Loading details for '{SelectedInstance.StackName}'...";
try
{
// Set the SSH host on singleton Docker services so modal operations target the right host
var dockerCli = _services.GetRequiredService<SshDockerCliService>();
dockerCli.SetHost(SelectedInstance.Host);
var dockerSecrets = _services.GetRequiredService<SshDockerSecretsService>();
dockerSecrets.SetHost(SelectedInstance.Host);
var detailsVm = _services.GetRequiredService<InstanceDetailsViewModel>();
await detailsVm.LoadAsync(SelectedInstance);
OpenDetailsRequested?.Invoke(detailsVm);
StatusMessage = string.Empty;
}
catch (Exception ex) { StatusMessage = $"Error opening details: {ex.Message}"; }
finally { IsBusy = false; }
}
}

View File

@@ -59,6 +59,17 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _defaultPhpUploadMaxFilesize = "10G";
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
// ── Bitwarden Secrets Manager ─────────────────────────────────
[ObservableProperty] private string _bitwardenIdentityUrl = "https://identity.bitwarden.com";
[ObservableProperty] private string _bitwardenApiUrl = "https://api.bitwarden.com";
[ObservableProperty] private string _bitwardenAccessToken = string.Empty;
[ObservableProperty] private string _bitwardenOrganizationId = string.Empty;
[ObservableProperty] private string _bitwardenProjectId = string.Empty;
// ── Xibo Bootstrap OAuth2 ─────────────────────────────────────
[ObservableProperty] private string _xiboBootstrapClientId = string.Empty;
[ObservableProperty] private string _xiboBootstrapClientSecret = string.Empty;
public SettingsViewModel(IServiceProvider services)
{
_services = services;
@@ -116,6 +127,17 @@ public partial class SettingsViewModel : ObservableObject
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
// Bitwarden
BitwardenIdentityUrl = await svc.GetAsync(SettingsService.BitwardenIdentityUrl, "https://identity.bitwarden.com");
BitwardenApiUrl = await svc.GetAsync(SettingsService.BitwardenApiUrl, "https://api.bitwarden.com");
BitwardenAccessToken = await svc.GetAsync(SettingsService.BitwardenAccessToken, string.Empty);
BitwardenOrganizationId = await svc.GetAsync(SettingsService.BitwardenOrganizationId, string.Empty);
BitwardenProjectId = await svc.GetAsync(SettingsService.BitwardenProjectId, string.Empty);
// Xibo Bootstrap
XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty);
XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty);
StatusMessage = "Settings loaded.";
}
catch (Exception ex)
@@ -180,6 +202,17 @@ public partial class SettingsViewModel : ObservableObject
(SettingsService.DefaultPhpPostMaxSize, DefaultPhpPostMaxSize, SettingsService.CatDefaults, false),
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false),
// Bitwarden
(SettingsService.BitwardenIdentityUrl, NullIfEmpty(BitwardenIdentityUrl), SettingsService.CatBitwarden, false),
(SettingsService.BitwardenApiUrl, NullIfEmpty(BitwardenApiUrl), SettingsService.CatBitwarden, false),
(SettingsService.BitwardenAccessToken, NullIfEmpty(BitwardenAccessToken), SettingsService.CatBitwarden, true),
(SettingsService.BitwardenOrganizationId, NullIfEmpty(BitwardenOrganizationId), SettingsService.CatBitwarden, false),
(SettingsService.BitwardenProjectId, NullIfEmpty(BitwardenProjectId), SettingsService.CatBitwarden, false),
// Xibo Bootstrap
(SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false),
(SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true),
};
await svc.SaveManyAsync(settings);
@@ -195,6 +228,64 @@ public partial class SettingsViewModel : ObservableObject
}
}
[RelayCommand]
private async Task TestBitwardenConnectionAsync()
{
if (string.IsNullOrWhiteSpace(BitwardenAccessToken) || string.IsNullOrWhiteSpace(BitwardenOrganizationId))
{
StatusMessage = "Bitwarden Access Token and Organization ID are required.";
return;
}
IsBusy = true;
StatusMessage = "Testing Bitwarden Secrets Manager connection...";
try
{
using var scope = _services.CreateScope();
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
var secrets = await bws.ListSecretsAsync();
StatusMessage = $"Bitwarden connected. Found {secrets.Count} secret(s) in project.";
}
catch (Exception ex)
{
StatusMessage = $"Bitwarden connection failed: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task TestXiboBootstrapAsync()
{
if (string.IsNullOrWhiteSpace(XiboBootstrapClientId) || string.IsNullOrWhiteSpace(XiboBootstrapClientSecret))
{
StatusMessage = "Xibo Bootstrap Client ID and Secret are required.";
return;
}
IsBusy = true;
StatusMessage = "Testing Xibo bootstrap credentials...";
try
{
using var scope = _services.CreateScope();
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
var cmsServerTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
// Use a placeholder URL — user must configure a live instance for full test
StatusMessage = "Xibo bootstrap credentials saved. Connect test requires a live instance URL — use 'Details' on an instance to verify.";
}
catch (Exception ex)
{
StatusMessage = $"Error: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task TestMySqlConnectionAsync()
{