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:
@@ -114,6 +114,8 @@ public class App : Application
|
||||
// HTTP
|
||||
services.AddHttpClient();
|
||||
services.AddHttpClient("XiboApi");
|
||||
services.AddHttpClient("XiboHealth");
|
||||
services.AddHttpClient("Bitwarden");
|
||||
|
||||
// SSH services (singletons — maintain connections)
|
||||
services.AddSingleton<SshConnectionService>();
|
||||
@@ -131,11 +133,14 @@ public class App : Application
|
||||
services.AddTransient<ComposeValidationService>();
|
||||
services.AddTransient<XiboApiService>();
|
||||
services.AddTransient<InstanceService>();
|
||||
services.AddTransient<IBitwardenSecretService, BitwardenSecretService>();
|
||||
services.AddSingleton<PostInstanceInitService>();
|
||||
|
||||
// ViewModels
|
||||
services.AddTransient<MainWindowViewModel>();
|
||||
services.AddTransient<HostsViewModel>();
|
||||
services.AddTransient<InstancesViewModel>();
|
||||
services.AddTransient<InstanceDetailsViewModel>();
|
||||
services.AddTransient<CreateInstanceViewModel>();
|
||||
services.AddTransient<SecretsViewModel>();
|
||||
services.AddTransient<SettingsViewModel>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
146
OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml
Normal file
146
OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml
Normal file
@@ -0,0 +1,146 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:OTSSignsOrchestrator.Desktop.ViewModels"
|
||||
x:Class="OTSSignsOrchestrator.Desktop.Views.InstanceDetailsWindow"
|
||||
x:DataType="vm:InstanceDetailsViewModel"
|
||||
Title="Instance Details"
|
||||
Width="620" Height="740"
|
||||
MinWidth="520" MinHeight="600"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="True">
|
||||
|
||||
<DockPanel Margin="24">
|
||||
|
||||
<!-- Header -->
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,16">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="{Binding StackName}" FontSize="22" FontWeight="Bold"
|
||||
Foreground="{StaticResource AccentBrush}" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding HostLabel, StringFormat='Host: {0}'}"
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,2,0,0" />
|
||||
<TextBlock Text="{Binding InstanceUrl}"
|
||||
FontSize="11" Foreground="{StaticResource TextMutedBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Status bar -->
|
||||
<TextBlock DockPanel.Dock="Bottom" Text="{Binding StatusMessage}" Classes="status"
|
||||
Margin="0,12,0,0" TextWrapping="Wrap" />
|
||||
|
||||
<!-- Main scrollable content -->
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="16">
|
||||
|
||||
<!-- ═══ OTS Admin Account ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#F97316" />
|
||||
<TextBlock Text="OTS Admin Account" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#F97316" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="Username" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding AdminUsername}" IsReadOnly="True" />
|
||||
<Button Grid.Column="1" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyAdminPasswordCommand}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Password" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding AdminPasswordDisplay}" IsReadOnly="True"
|
||||
FontFamily="Consolas,monospace" />
|
||||
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
|
||||
IsVisible="{Binding !AdminPasswordVisible}"
|
||||
Command="{Binding ToggleAdminPasswordVisibilityCommand}" />
|
||||
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
|
||||
IsVisible="{Binding AdminPasswordVisible}"
|
||||
Command="{Binding ToggleAdminPasswordVisibilityCommand}" />
|
||||
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyAdminPasswordCommand}" />
|
||||
</Grid>
|
||||
|
||||
<Button Content="Rotate Admin Password"
|
||||
Command="{Binding RotateAdminPasswordCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Classes="accent"
|
||||
Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Database Credentials ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#4ADE80" />
|
||||
<TextBlock Text="Database Credentials" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#4ADE80" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Text="MySQL Username" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<Grid ColumnDefinitions="*">
|
||||
<TextBox Text="{Binding DbUsername}" IsReadOnly="True" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="MySQL Password" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding DbPasswordDisplay}" IsReadOnly="True"
|
||||
FontFamily="Consolas,monospace" />
|
||||
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
|
||||
IsVisible="{Binding !DbPasswordVisible}"
|
||||
Command="{Binding ToggleDbPasswordVisibilityCommand}" />
|
||||
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
|
||||
IsVisible="{Binding DbPasswordVisible}"
|
||||
Command="{Binding ToggleDbPasswordVisibilityCommand}" />
|
||||
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyDbPasswordCommand}" />
|
||||
</Grid>
|
||||
|
||||
<Button Content="Rotate DB Password"
|
||||
Command="{Binding RotateDbPasswordCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Xibo OAuth2 Application ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#60A5FA" />
|
||||
<TextBlock Text="OTS OAuth2 Application" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#60A5FA" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Client credentials used by the OTS orchestrator for Xibo API access."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Text="Client ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding OAuthClientId}" IsReadOnly="True"
|
||||
FontFamily="Consolas,monospace" />
|
||||
<Button Grid.Column="1" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyOAuthClientIdCommand}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Client Secret" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" Margin="0,4,0,0" />
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
<TextBox Grid.Column="0" Text="{Binding OAuthSecretDisplay}" IsReadOnly="True"
|
||||
FontFamily="Consolas,monospace" />
|
||||
<Button Grid.Column="1" Content="Show" Margin="4,0,0,0"
|
||||
IsVisible="{Binding !OAuthSecretVisible}"
|
||||
Command="{Binding ToggleOAuthSecretVisibilityCommand}" />
|
||||
<Button Grid.Column="2" Content="Hide" Margin="4,0,0,0"
|
||||
IsVisible="{Binding OAuthSecretVisible}"
|
||||
Command="{Binding ToggleOAuthSecretVisibilityCommand}" />
|
||||
<Button Grid.Column="3" Content="Copy" Margin="4,0,0,0"
|
||||
Command="{Binding CopyOAuthSecretCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class InstanceDetailsWindow : Window
|
||||
{
|
||||
public InstanceDetailsWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,9 @@
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Refresh" Command="{Binding LoadInstancesCommand}" />
|
||||
<Button Content="Details" Classes="accent" Command="{Binding OpenDetailsCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="View credentials and manage this instance." />
|
||||
<Button Content="Inspect" Command="{Binding InspectInstanceCommand}" />
|
||||
<Button Content="Delete" Classes="danger" Command="{Binding DeleteInstanceCommand}" />
|
||||
<Border Width="1" Background="{StaticResource BorderSubtleBrush}" Margin="4,2" />
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
using Avalonia.Controls;
|
||||
using OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||
|
||||
public partial class InstancesView : UserControl
|
||||
{
|
||||
private InstancesViewModel? _vm;
|
||||
|
||||
public InstancesView()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_vm is not null)
|
||||
_vm.OpenDetailsRequested -= OnOpenDetailsRequested;
|
||||
|
||||
_vm = DataContext as InstancesViewModel;
|
||||
|
||||
if (_vm is not null)
|
||||
_vm.OpenDetailsRequested += OnOpenDetailsRequested;
|
||||
}
|
||||
|
||||
private async void OnOpenDetailsRequested(InstanceDetailsViewModel detailsVm)
|
||||
{
|
||||
var window = new InstanceDetailsWindow { DataContext = detailsVm };
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is not null)
|
||||
await window.ShowDialog(owner);
|
||||
else
|
||||
window.Show();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,77 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Bitwarden Secrets Manager ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#818CF8" />
|
||||
<TextBlock Text="Bitwarden Secrets Manager" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#818CF8" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Stores per-instance admin passwords and OAuth2 secrets. Uses a machine account access token."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<Grid ColumnDefinitions="1*,12,1*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Text="Identity URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenIdentityUrl}"
|
||||
Watermark="https://identity.bitwarden.com" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="2" Spacing="4">
|
||||
<TextBlock Text="API URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenApiUrl}"
|
||||
Watermark="https://api.bitwarden.com" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock Text="Machine Account Access Token" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenAccessToken}" PasswordChar="●"
|
||||
Watermark="0.xxxxxxxx.yyyyyyy:zzzzzz" />
|
||||
|
||||
<TextBlock Text="Organization ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenOrganizationId}"
|
||||
Watermark="00000000-0000-0000-0000-000000000000" />
|
||||
|
||||
<TextBlock Text="Project ID (optional — secrets are organized into this project)" FontSize="12"
|
||||
Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding BitwardenProjectId}"
|
||||
Watermark="00000000-0000-0000-0000-000000000000" />
|
||||
|
||||
<Button Content="Test Bitwarden Connection"
|
||||
Command="{Binding TestBitwardenConnectionCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Xibo Bootstrap OAuth2 ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#F97316" />
|
||||
<TextBlock Text="Xibo Bootstrap OAuth2" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#F97316" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="A pre-configured Xibo OAuth2 client_credentials application used for post-install setup (creating admin users, registering OTS app, setting theme). Create once in the Xibo admin panel of any instance."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Text="Bootstrap Client ID" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding XiboBootstrapClientId}"
|
||||
Watermark="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" />
|
||||
|
||||
<TextBlock Text="Bootstrap Client Secret" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding XiboBootstrapClientSecret}" PasswordChar="●" />
|
||||
|
||||
<Button Content="Save & Verify"
|
||||
Command="{Binding TestXiboBootstrapCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Margin="0,6,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
|
||||
Reference in New Issue
Block a user