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:
255
OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs
Normal file
255
OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores and retrieves secrets from Bitwarden Secrets Manager (machine account API).
|
||||||
|
///
|
||||||
|
/// Configuration required in Settings:
|
||||||
|
/// Bitwarden.IdentityUrl – defaults to https://identity.bitwarden.com
|
||||||
|
/// Bitwarden.ApiUrl – defaults to https://api.bitwarden.com
|
||||||
|
/// Bitwarden.AccessToken – machine account access token (sensitive)
|
||||||
|
/// Bitwarden.OrganizationId – Bitwarden organisation that owns the project
|
||||||
|
/// Bitwarden.ProjectId – project where new secrets are created
|
||||||
|
/// </summary>
|
||||||
|
public class BitwardenSecretService : IBitwardenSecretService
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _http;
|
||||||
|
private readonly SettingsService _settings;
|
||||||
|
private readonly ILogger<BitwardenSecretService> _logger;
|
||||||
|
|
||||||
|
// Cached bearer token (refreshed per service lifetime — transient registration is assumed)
|
||||||
|
private string? _bearerToken;
|
||||||
|
|
||||||
|
public BitwardenSecretService(
|
||||||
|
IHttpClientFactory http,
|
||||||
|
SettingsService settings,
|
||||||
|
ILogger<BitwardenSecretService> logger)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_settings = settings;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// IBitwardenSecretService
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<bool> IsConfiguredAsync()
|
||||||
|
{
|
||||||
|
var token = await _settings.GetAsync(SettingsService.BitwardenAccessToken);
|
||||||
|
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId);
|
||||||
|
return !string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> CreateSecretAsync(string key, string value, string note = "")
|
||||||
|
{
|
||||||
|
var bearer = await GetBearerTokenAsync();
|
||||||
|
var projectId = await _settings.GetAsync(SettingsService.BitwardenProjectId);
|
||||||
|
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
|
||||||
|
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
||||||
|
var apiUrl = await GetApiUrlAsync();
|
||||||
|
|
||||||
|
var client = CreateClient(bearer);
|
||||||
|
var body = new
|
||||||
|
{
|
||||||
|
organizationId = orgId,
|
||||||
|
projectIds = projectId is not null ? new[] { projectId } : Array.Empty<string>(),
|
||||||
|
key = key,
|
||||||
|
value = value,
|
||||||
|
note = note
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.PostAsJsonAsync($"{apiUrl}/secrets", body);
|
||||||
|
await EnsureSuccessAsync(response, "create secret");
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<BwsSecretResponse>()
|
||||||
|
?? throw new InvalidOperationException("Empty response from Bitwarden API.");
|
||||||
|
|
||||||
|
_logger.LogInformation("Bitwarden secret created: key={Key}, id={Id}", key, result.Id);
|
||||||
|
return result.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BitwardenSecret> GetSecretAsync(string secretId)
|
||||||
|
{
|
||||||
|
var bearer = await GetBearerTokenAsync();
|
||||||
|
var apiUrl = await GetApiUrlAsync();
|
||||||
|
var client = CreateClient(bearer);
|
||||||
|
|
||||||
|
var response = await client.GetAsync($"{apiUrl}/secrets/{secretId}");
|
||||||
|
await EnsureSuccessAsync(response, "get secret");
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<BwsSecretResponse>()
|
||||||
|
?? throw new InvalidOperationException("Empty response from Bitwarden API.");
|
||||||
|
|
||||||
|
return new BitwardenSecret
|
||||||
|
{
|
||||||
|
Id = result.Id,
|
||||||
|
Key = result.Key,
|
||||||
|
Value = result.Value,
|
||||||
|
Note = result.Note ?? string.Empty,
|
||||||
|
CreationDate = result.CreationDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateSecretAsync(string secretId, string key, string value, string note = "")
|
||||||
|
{
|
||||||
|
var bearer = await GetBearerTokenAsync();
|
||||||
|
var projectId = await _settings.GetAsync(SettingsService.BitwardenProjectId);
|
||||||
|
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
|
||||||
|
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
||||||
|
var apiUrl = await GetApiUrlAsync();
|
||||||
|
|
||||||
|
var client = CreateClient(bearer);
|
||||||
|
var body = new
|
||||||
|
{
|
||||||
|
organizationId = orgId,
|
||||||
|
projectIds = projectId is not null ? new[] { projectId } : Array.Empty<string>(),
|
||||||
|
key = key,
|
||||||
|
value = value,
|
||||||
|
note = note
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.PutAsJsonAsync($"{apiUrl}/secrets/{secretId}", body);
|
||||||
|
await EnsureSuccessAsync(response, "update secret");
|
||||||
|
|
||||||
|
_logger.LogInformation("Bitwarden secret updated: key={Key}, id={Id}", key, secretId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<BitwardenSecretSummary>> ListSecretsAsync()
|
||||||
|
{
|
||||||
|
var bearer = await GetBearerTokenAsync();
|
||||||
|
var orgId = await _settings.GetAsync(SettingsService.BitwardenOrganizationId)
|
||||||
|
?? throw new InvalidOperationException("Bitwarden OrganizationId is not configured.");
|
||||||
|
var apiUrl = await GetApiUrlAsync();
|
||||||
|
var client = CreateClient(bearer);
|
||||||
|
|
||||||
|
var response = await client.GetAsync($"{apiUrl}/organizations/{orgId}/secrets");
|
||||||
|
await EnsureSuccessAsync(response, "list secrets");
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<BwsSecretsListResponse>();
|
||||||
|
|
||||||
|
return result?.Data?.Select(s => new BitwardenSecretSummary
|
||||||
|
{
|
||||||
|
Id = s.Id,
|
||||||
|
Key = s.Key,
|
||||||
|
CreationDate = s.CreationDate
|
||||||
|
}).ToList() ?? new List<BitwardenSecretSummary>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Auth
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exchanges the machine-account access token for a short-lived Bearer token.
|
||||||
|
/// The access token format is: 0.{tokenId}.{clientSecret}:{encKeyB64}
|
||||||
|
/// The client_id used is "machine.{tokenId}".
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> GetBearerTokenAsync()
|
||||||
|
{
|
||||||
|
if (_bearerToken is not null)
|
||||||
|
return _bearerToken;
|
||||||
|
|
||||||
|
var rawToken = await _settings.GetAsync(SettingsService.BitwardenAccessToken)
|
||||||
|
?? throw new InvalidOperationException("Bitwarden AccessToken is not configured in Settings.");
|
||||||
|
var identityUrl = await GetIdentityUrlAsync();
|
||||||
|
|
||||||
|
// Parse token: "0.<tokenId>.<clientSecretAndKey>" — split off the first two segments
|
||||||
|
var parts = rawToken.Split('.', 3);
|
||||||
|
if (parts.Length < 3)
|
||||||
|
throw new FormatException(
|
||||||
|
"Bitwarden access token has unexpected format. Expected: 0.<tokenId>.<rest>");
|
||||||
|
|
||||||
|
var tokenId = parts[1];
|
||||||
|
var clientSecret = parts[2]; // may contain ":base64key" suffix — include all of it
|
||||||
|
|
||||||
|
var client = _http.CreateClient("Bitwarden");
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||||
|
new KeyValuePair<string, string>("client_id", $"machine.{tokenId}"),
|
||||||
|
new KeyValuePair<string, string>("client_secret", clientSecret),
|
||||||
|
new KeyValuePair<string, string>("scope", "api.secrets"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync($"{identityUrl}/connect/token", form);
|
||||||
|
await EnsureSuccessAsync(response, "authenticate with Bitwarden identity");
|
||||||
|
|
||||||
|
var token = await response.Content.ReadFromJsonAsync<BwsTokenResponse>()
|
||||||
|
?? throw new InvalidOperationException("Empty token response from Bitwarden.");
|
||||||
|
|
||||||
|
_bearerToken = token.AccessToken;
|
||||||
|
_logger.LogInformation("Bitwarden bearer token acquired.");
|
||||||
|
return _bearerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<string> GetIdentityUrlAsync()
|
||||||
|
=> (await _settings.GetAsync(SettingsService.BitwardenIdentityUrl, "https://identity.bitwarden.com")).TrimEnd('/');
|
||||||
|
|
||||||
|
private async Task<string> GetApiUrlAsync()
|
||||||
|
=> (await _settings.GetAsync(SettingsService.BitwardenApiUrl, "https://api.bitwarden.com")).TrimEnd('/');
|
||||||
|
|
||||||
|
private static HttpClient CreateClient(string bearerToken)
|
||||||
|
{
|
||||||
|
var client = new HttpClient();
|
||||||
|
client.DefaultRequestHeaders.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", bearerToken);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Bitwarden API call '{operation}' failed: {(int)response.StatusCode} {response.ReasonPhrase} — {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Internal DTOs
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private sealed class BwsTokenResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("access_token")]
|
||||||
|
public string AccessToken { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BwsSecretResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")]
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("organizationId")]
|
||||||
|
public string OrganizationId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("key")]
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("value")]
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[JsonPropertyName("note")]
|
||||||
|
public string? Note { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("creationDate")]
|
||||||
|
public DateTime CreationDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BwsSecretsListResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("data")]
|
||||||
|
public List<BwsSecretResponse>? Data { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Abstraction for storing and retrieving secrets via Bitwarden Secrets Manager.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBitwardenSecretService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true if Bitwarden is configured (access token + org ID are set).
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> IsConfiguredAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new secret in the configured Bitwarden project.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The ID of the created secret.</returns>
|
||||||
|
Task<string> CreateSecretAsync(string key, string value, string note = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a secret by its Bitwarden ID.
|
||||||
|
/// </summary>
|
||||||
|
Task<BitwardenSecret> GetSecretAsync(string secretId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the value of an existing secret in place.
|
||||||
|
/// </summary>
|
||||||
|
Task UpdateSecretAsync(string secretId, string key, string value, string note = "");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists all secrets in the configured project.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<BitwardenSecretSummary>> ListSecretsAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BitwardenSecret
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
public string Note { get; set; } = string.Empty;
|
||||||
|
public DateTime CreationDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BitwardenSecretSummary
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
public DateTime CreationDate { get; set; }
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ public class InstanceService
|
|||||||
private readonly IDockerSecretsService _secrets;
|
private readonly IDockerSecretsService _secrets;
|
||||||
private readonly XiboApiService _xibo;
|
private readonly XiboApiService _xibo;
|
||||||
private readonly SettingsService _settings;
|
private readonly SettingsService _settings;
|
||||||
|
private readonly PostInstanceInitService _postInit;
|
||||||
private readonly DockerOptions _dockerOptions;
|
private readonly DockerOptions _dockerOptions;
|
||||||
private readonly ILogger<InstanceService> _logger;
|
private readonly ILogger<InstanceService> _logger;
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ public class InstanceService
|
|||||||
IDockerSecretsService secrets,
|
IDockerSecretsService secrets,
|
||||||
XiboApiService xibo,
|
XiboApiService xibo,
|
||||||
SettingsService settings,
|
SettingsService settings,
|
||||||
|
PostInstanceInitService postInit,
|
||||||
IOptions<DockerOptions> dockerOptions,
|
IOptions<DockerOptions> dockerOptions,
|
||||||
ILogger<InstanceService> logger)
|
ILogger<InstanceService> logger)
|
||||||
{
|
{
|
||||||
@@ -55,6 +57,7 @@ public class InstanceService
|
|||||||
_secrets = secrets;
|
_secrets = secrets;
|
||||||
_xibo = xibo;
|
_xibo = xibo;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
|
_postInit = postInit;
|
||||||
_dockerOptions = dockerOptions.Value;
|
_dockerOptions = dockerOptions.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -255,8 +258,13 @@ public class InstanceService
|
|||||||
|
|
||||||
_logger.LogInformation("Instance created: {StackName} | duration={DurationMs}ms", stackName, sw.ElapsedMilliseconds);
|
_logger.LogInformation("Instance created: {StackName} | duration={DurationMs}ms", stackName, sw.ElapsedMilliseconds);
|
||||||
|
|
||||||
|
// ── 8. Post-instance init (fire-and-forget background task) ──────
|
||||||
|
// Waits for Xibo to be ready then creates admin user, OAuth app, and sets theme.
|
||||||
|
var instanceUrl = $"https://{cmsServerName}";
|
||||||
|
_ = Task.Run(async () => await _postInit.RunAsync(abbrev, instanceUrl));
|
||||||
|
|
||||||
deployResult.ServiceCount = 4;
|
deployResult.ServiceCount = 4;
|
||||||
deployResult.Message = "Instance deployed successfully.";
|
deployResult.Message = "Instance deployed successfully. Post-install setup is running in background.";
|
||||||
return deployResult;
|
return deployResult;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
345
OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs
Normal file
345
OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using OTSSignsOrchestrator.Core.Data;
|
||||||
|
|
||||||
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs once after a Xibo CMS stack is deployed to complete post-install setup:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>Waits for the Xibo web service to become available.</item>
|
||||||
|
/// <item>Creates the OTS admin user with a random password.</item>
|
||||||
|
/// <item>Registers a dedicated client_credentials OAuth2 application for OTS.</item>
|
||||||
|
/// <item>Activates the <c>otssigns</c> theme.</item>
|
||||||
|
/// <item>Stores all generated credentials in Bitwarden Secrets Manager.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// Invoked as a background fire-and-forget task from <see cref="InstanceService.CreateInstanceAsync"/>.
|
||||||
|
/// Progress is tracked in the operation log; failures are logged but do not roll back the deployment.
|
||||||
|
/// </summary>
|
||||||
|
public class PostInstanceInitService
|
||||||
|
{
|
||||||
|
private readonly IServiceProvider _services;
|
||||||
|
private readonly ILogger<PostInstanceInitService> _logger;
|
||||||
|
|
||||||
|
// How long to wait for Xibo to become healthy before aborting post-init.
|
||||||
|
private static readonly TimeSpan XiboReadinessTimeout = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
public PostInstanceInitService(
|
||||||
|
IServiceProvider services,
|
||||||
|
ILogger<PostInstanceInitService> logger)
|
||||||
|
{
|
||||||
|
_services = services;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Entry point
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the post-instance initialisation sequence.
|
||||||
|
/// Intended to be called as a background task after stack deployment succeeds.
|
||||||
|
/// </summary>
|
||||||
|
public async Task RunAsync(string abbrev, string instanceUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[PostInit] Starting post-instance init for {Abbrev} ({Url})", abbrev, instanceUrl);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||||
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||||
|
|
||||||
|
// ── Validate Bitwarden is configured ────────────────────────────
|
||||||
|
if (!await bws.IsConfiguredAsync())
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"[PostInit] Bitwarden is not configured — credentials will NOT be stored in Bitwarden. " +
|
||||||
|
"Configure Settings → Bitwarden to enable secret storage.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Read bootstrap credentials ───────────────────────────────────
|
||||||
|
var bootstrapClientId = await settings.GetAsync(SettingsService.XiboBootstrapClientId);
|
||||||
|
var bootstrapClientSecret = await settings.GetAsync(SettingsService.XiboBootstrapClientSecret);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(bootstrapClientId) || string.IsNullOrWhiteSpace(bootstrapClientSecret))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"Xibo bootstrap OAuth2 credentials are not configured. " +
|
||||||
|
"Set Settings → Xibo Bootstrap Client ID / Secret to enable post-instance setup.");
|
||||||
|
|
||||||
|
// ── 1. Wait for Xibo readiness ────────────────────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Waiting for Xibo to become ready at {Url}...", instanceUrl);
|
||||||
|
var ready = await xibo.WaitForReadyAsync(instanceUrl, XiboReadinessTimeout, ct);
|
||||||
|
if (!ready)
|
||||||
|
throw new TimeoutException(
|
||||||
|
$"Xibo instance at {instanceUrl} did not become ready within {XiboReadinessTimeout.TotalMinutes} minutes.");
|
||||||
|
|
||||||
|
// ── 2. Generate credentials ───────────────────────────────────────
|
||||||
|
var adminUsername = $"ots-admin-{abbrev}";
|
||||||
|
var adminPassword = GeneratePassword(24);
|
||||||
|
var adminEmail = $"ots-admin-{abbrev}@ots-signs.com";
|
||||||
|
|
||||||
|
// ── 3. Create OTS admin user ──────────────────────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Creating OTS admin user '{Username}'", adminUsername);
|
||||||
|
int userId = await xibo.CreateAdminUserAsync(
|
||||||
|
instanceUrl, bootstrapClientId, bootstrapClientSecret,
|
||||||
|
adminUsername, adminPassword, adminEmail);
|
||||||
|
|
||||||
|
// ── 4. Register dedicated OAuth2 application ──────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Registering OTS OAuth2 application");
|
||||||
|
var (oauthClientId, oauthClientSecret) = await xibo.RegisterOAuthClientAsync(
|
||||||
|
instanceUrl, bootstrapClientId, bootstrapClientSecret,
|
||||||
|
appName: $"OTS Signs — {abbrev.ToUpperInvariant()}");
|
||||||
|
|
||||||
|
// ── 5. Set theme ──────────────────────────────────────────────────
|
||||||
|
_logger.LogInformation("[PostInit] Setting Xibo theme to 'otssigns'");
|
||||||
|
await xibo.SetThemeAsync(instanceUrl, bootstrapClientId, bootstrapClientSecret, "otssigns");
|
||||||
|
|
||||||
|
// ── 6. Store credentials in Bitwarden ─────────────────────────────
|
||||||
|
if (await bws.IsConfiguredAsync())
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[PostInit] Storing credentials in Bitwarden");
|
||||||
|
|
||||||
|
var adminSecretId = await bws.CreateSecretAsync(
|
||||||
|
key: $"{abbrev}/xibo-admin-password",
|
||||||
|
value: adminPassword,
|
||||||
|
note: $"Xibo CMS admin password for instance {abbrev}. Username: {adminUsername}");
|
||||||
|
|
||||||
|
var oauthSecretId = await bws.CreateSecretAsync(
|
||||||
|
key: $"{abbrev}/xibo-oauth-secret",
|
||||||
|
value: oauthClientSecret,
|
||||||
|
note: $"Xibo CMS OAuth2 client secret for OTS application. ClientId: {oauthClientId}");
|
||||||
|
|
||||||
|
// Persist Bitwarden secret IDs + OAuth client ID in AppSettings for later retrieval
|
||||||
|
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), adminSecretId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
await settings.SetAsync(SettingsService.InstanceOAuthSecretId(abbrev), oauthSecretId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), oauthClientId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No Bitwarden — fall back to encrypted AppSettings (less ideal for secrets)
|
||||||
|
_logger.LogWarning(
|
||||||
|
"[PostInit] Bitwarden not configured. Storing admin password locally (encrypted). " +
|
||||||
|
"OAuth client secret is discarded — re-register from the instance details modal.");
|
||||||
|
|
||||||
|
await settings.SetAsync(
|
||||||
|
$"Instance.{abbrev}.AdminPassword", adminPassword,
|
||||||
|
SettingsService.CatInstance, isSensitive: true);
|
||||||
|
await settings.SetAsync(SettingsService.InstanceOAuthClientId(abbrev), oauthClientId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"[PostInit] Post-instance init complete for {Abbrev}. Admin user: {Username}, OAuth ClientId: {ClientId}",
|
||||||
|
abbrev, adminUsername, oauthClientId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "[PostInit] Post-instance init failed for {Abbrev}: {Message}", abbrev, ex.Message);
|
||||||
|
// Do NOT rethrow — the stack is already deployed; we don't want to break the deployment result.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Credential rotation (called from the UI)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a new OTS admin password, updates it in Xibo, and rotates the Bitwarden secret.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> RotateAdminPasswordAsync(string abbrev, string instanceUrl)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("[PostInit] Rotating admin password for {Abbrev}", abbrev);
|
||||||
|
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var xibo = scope.ServiceProvider.GetRequiredService<XiboApiService>();
|
||||||
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<XiboContext>();
|
||||||
|
|
||||||
|
// Get bootstrap credentials to authenticate against Xibo API
|
||||||
|
var bootstrapClientId = await settings.GetAsync(SettingsService.XiboBootstrapClientId)
|
||||||
|
?? throw new InvalidOperationException("Xibo bootstrap client not configured.");
|
||||||
|
var bootstrapClientSecret = await settings.GetAsync(SettingsService.XiboBootstrapClientSecret)
|
||||||
|
?? throw new InvalidOperationException("Xibo bootstrap client secret not configured.");
|
||||||
|
|
||||||
|
// Xibo user ID: we store it as the numeric userId in the username convention
|
||||||
|
// We need to look it up from the admin username. For now derive from OTS convention.
|
||||||
|
var adminUsername = $"ots-admin-{abbrev}";
|
||||||
|
var newPassword = GeneratePassword(24);
|
||||||
|
|
||||||
|
// We need to look up the userId — use the Xibo API (list users, find by userName)
|
||||||
|
var userId = await GetXiboUserIdAsync(xibo, instanceUrl, bootstrapClientId, bootstrapClientSecret, adminUsername);
|
||||||
|
|
||||||
|
await xibo.RotateUserPasswordAsync(instanceUrl, bootstrapClientId, bootstrapClientSecret, userId, newPassword);
|
||||||
|
|
||||||
|
// Update Bitwarden secret if available
|
||||||
|
if (await bws.IsConfiguredAsync())
|
||||||
|
{
|
||||||
|
var secretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
|
||||||
|
if (!string.IsNullOrWhiteSpace(secretId))
|
||||||
|
{
|
||||||
|
await bws.UpdateSecretAsync(secretId,
|
||||||
|
key: $"{abbrev}/xibo-admin-password",
|
||||||
|
value: newPassword,
|
||||||
|
note: $"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Secret doesn't exist yet in Bitwarden — create it now
|
||||||
|
var newSecretId = await bws.CreateSecretAsync(
|
||||||
|
$"{abbrev}/xibo-admin-password", newPassword,
|
||||||
|
$"Rotated on {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC. Username: {adminUsername}");
|
||||||
|
await settings.SetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev), newSecretId,
|
||||||
|
SettingsService.CatInstance, isSensitive: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fallback: encrypted AppSettings
|
||||||
|
await settings.SetAsync($"Instance.{abbrev}.AdminPassword", newPassword,
|
||||||
|
SettingsService.CatInstance, isSensitive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
_logger.LogInformation("[PostInit] Admin password rotated for {Abbrev}", abbrev);
|
||||||
|
return newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the stored admin password for an instance (from Bitwarden or local AppSettings).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<InstanceCredentials> GetCredentialsAsync(string abbrev)
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var bws = scope.ServiceProvider.GetRequiredService<IBitwardenSecretService>();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||||
|
|
||||||
|
var adminUsername = $"ots-admin-{abbrev}";
|
||||||
|
var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev));
|
||||||
|
string? adminPassword = null;
|
||||||
|
string? oauthClientSecret = null;
|
||||||
|
|
||||||
|
if (await bws.IsConfiguredAsync())
|
||||||
|
{
|
||||||
|
var adminSecretId = await settings.GetAsync(SettingsService.InstanceAdminPasswordSecretId(abbrev));
|
||||||
|
if (!string.IsNullOrWhiteSpace(adminSecretId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var secret = await bws.GetSecretAsync(adminSecretId);
|
||||||
|
adminPassword = secret.Value;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not retrieve admin password from Bitwarden for {Abbrev}", abbrev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev));
|
||||||
|
if (!string.IsNullOrWhiteSpace(oauthSecretId))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var secret = await bws.GetSecretAsync(oauthSecretId);
|
||||||
|
oauthClientSecret = secret.Value;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not retrieve OAuth secret from Bitwarden for {Abbrev}", abbrev);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
adminPassword = await settings.GetAsync($"Instance.{abbrev}.AdminPassword");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InstanceCredentials
|
||||||
|
{
|
||||||
|
AdminUsername = adminUsername,
|
||||||
|
AdminPassword = adminPassword,
|
||||||
|
OAuthClientId = oauthClientId,
|
||||||
|
OAuthClientSecret = oauthClientSecret,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static string GeneratePassword(int length)
|
||||||
|
{
|
||||||
|
const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
|
||||||
|
return RandomNumberGenerator.GetString(chars, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<int> GetXiboUserIdAsync(
|
||||||
|
XiboApiService xibo,
|
||||||
|
string instanceUrl,
|
||||||
|
string clientId,
|
||||||
|
string clientSecret,
|
||||||
|
string targetUsername)
|
||||||
|
{
|
||||||
|
// This is a lightweight HTTP GET to fetch the user list and find by name.
|
||||||
|
// We use HttpClient directly since XiboApiService doesn't expose this as a standalone call.
|
||||||
|
using var http = new System.Net.Http.HttpClient();
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
// Get token first via the TestConnectionAsync pattern
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||||
|
new KeyValuePair<string, string>("client_id", clientId),
|
||||||
|
new KeyValuePair<string, string>("client_secret", clientSecret),
|
||||||
|
});
|
||||||
|
|
||||||
|
var tokenResp = await http.PostAsync($"{baseUrl}/api/authorize/access_token", form);
|
||||||
|
tokenResp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var tokenDoc = await System.Text.Json.JsonDocument.ParseAsync(
|
||||||
|
await tokenResp.Content.ReadAsStreamAsync());
|
||||||
|
var token = tokenDoc.RootElement.GetProperty("access_token").GetString()!;
|
||||||
|
|
||||||
|
http.DefaultRequestHeaders.Authorization =
|
||||||
|
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
var usersResp = await http.GetAsync($"{baseUrl}/api/user?userName={Uri.EscapeDataString(targetUsername)}");
|
||||||
|
usersResp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var usersDoc = await System.Text.Json.JsonDocument.ParseAsync(
|
||||||
|
await usersResp.Content.ReadAsStreamAsync());
|
||||||
|
foreach (var user in usersDoc.RootElement.EnumerateArray())
|
||||||
|
{
|
||||||
|
var name = user.GetProperty("userName").GetString();
|
||||||
|
if (string.Equals(name, targetUsername, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return user.GetProperty("userId").GetInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Xibo user '{targetUsername}' not found. Post-instance init may not have completed yet.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Snapshot of provisioned credentials for a CMS instance.</summary>
|
||||||
|
public class InstanceCredentials
|
||||||
|
{
|
||||||
|
public string AdminUsername { get; set; } = string.Empty;
|
||||||
|
public string? AdminPassword { get; set; }
|
||||||
|
public string? OAuthClientId { get; set; }
|
||||||
|
public string? OAuthClientSecret { get; set; }
|
||||||
|
|
||||||
|
public bool HasAdminPassword => !string.IsNullOrWhiteSpace(AdminPassword);
|
||||||
|
public bool HasOAuthCredentials => !string.IsNullOrWhiteSpace(OAuthClientId) &&
|
||||||
|
!string.IsNullOrWhiteSpace(OAuthClientSecret);
|
||||||
|
}
|
||||||
@@ -68,12 +68,31 @@ public class SettingsService
|
|||||||
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
|
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
|
||||||
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
|
public const string DefaultPhpMaxExecutionTime = "Defaults.PhpMaxExecutionTime";
|
||||||
|
|
||||||
|
// Bitwarden Secrets Manager
|
||||||
|
public const string CatBitwarden = "Bitwarden";
|
||||||
|
public const string BitwardenIdentityUrl = "Bitwarden.IdentityUrl";
|
||||||
|
public const string BitwardenApiUrl = "Bitwarden.ApiUrl";
|
||||||
|
public const string BitwardenAccessToken = "Bitwarden.AccessToken";
|
||||||
|
public const string BitwardenOrganizationId = "Bitwarden.OrganizationId";
|
||||||
|
public const string BitwardenProjectId = "Bitwarden.ProjectId";
|
||||||
|
|
||||||
|
// Xibo bootstrap OAuth2 credentials (one-time global setup for orchestrator API access)
|
||||||
|
public const string CatXibo = "Xibo";
|
||||||
|
public const string XiboBootstrapClientId = "Xibo.BootstrapClientId";
|
||||||
|
public const string XiboBootstrapClientSecret = "Xibo.BootstrapClientSecret";
|
||||||
|
|
||||||
// Instance-specific (keyed by abbreviation)
|
// Instance-specific (keyed by abbreviation)
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a per-instance settings key for the MySQL password.
|
/// Builds a per-instance settings key for the MySQL password.
|
||||||
/// Stored encrypted via DataProtection so it can be retrieved on update/redeploy.
|
/// Stored encrypted via DataProtection so it can be retrieved on update/redeploy.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
|
public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
|
||||||
|
/// <summary>Bitwarden secret ID for the instance's OTS admin password.</summary>
|
||||||
|
public static string InstanceAdminPasswordSecretId(string abbrev) => $"Instance.{abbrev}.AdminPasswordBwsId";
|
||||||
|
/// <summary>Bitwarden secret ID for the instance's Xibo OAuth2 client secret.</summary>
|
||||||
|
public static string InstanceOAuthSecretId(string abbrev) => $"Instance.{abbrev}.OAuthSecretBwsId";
|
||||||
|
/// <summary>Xibo OAuth2 client_id generated for this instance's OTS application.</summary>
|
||||||
|
public static string InstanceOAuthClientId(string abbrev) => $"Instance.{abbrev}.OAuthClientId";
|
||||||
public const string CatInstance = "Instance";
|
public const string CatInstance = "Instance";
|
||||||
|
|
||||||
public SettingsService(
|
public SettingsService(
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using OTSSignsOrchestrator.Core.Configuration;
|
using OTSSignsOrchestrator.Core.Configuration;
|
||||||
@@ -5,7 +9,13 @@ using OTSSignsOrchestrator.Core.Configuration;
|
|||||||
namespace OTSSignsOrchestrator.Core.Services;
|
namespace OTSSignsOrchestrator.Core.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests connectivity to deployed Xibo CMS instances using OAuth2.
|
/// Provides connectivity testing and administrative operations against deployed Xibo CMS instances.
|
||||||
|
///
|
||||||
|
/// Bootstrap flow:
|
||||||
|
/// 1. A Xibo OAuth2 application with client_credentials grant must be created once
|
||||||
|
/// (stored in Settings → Xibo.BootstrapClientId / Xibo.BootstrapClientSecret).
|
||||||
|
/// 2. After a new instance is deployed, PostInstanceInitService calls into this service
|
||||||
|
/// to create the OTS admin user, register a dedicated OAuth2 app, and set the theme.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class XiboApiService
|
public class XiboApiService
|
||||||
{
|
{
|
||||||
@@ -23,7 +33,11 @@ public class XiboApiService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<XiboTestResult> TestConnectionAsync(string instanceUrl, string username, string password)
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Connection test
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public async Task<XiboTestResult> TestConnectionAsync(string instanceUrl, string clientId, string clientSecret)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
|
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
|
||||||
|
|
||||||
@@ -32,43 +46,22 @@ public class XiboApiService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var baseUrl = instanceUrl.TrimEnd('/');
|
var token = await GetTokenAsync(instanceUrl, clientId, clientSecret, client);
|
||||||
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
|
|
||||||
|
|
||||||
var formContent = new FormUrlEncodedContent(new[]
|
|
||||||
{
|
|
||||||
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
|
||||||
new KeyValuePair<string, string>("client_id", username),
|
|
||||||
new KeyValuePair<string, string>("client_secret", password)
|
|
||||||
});
|
|
||||||
|
|
||||||
var response = await client.PostAsync(tokenUrl, formContent);
|
|
||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
|
_logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
|
||||||
return new XiboTestResult
|
return new XiboTestResult
|
||||||
{
|
{
|
||||||
IsValid = true,
|
IsValid = true,
|
||||||
Message = "Connected successfully.",
|
Message = "Connected successfully.",
|
||||||
HttpStatus = (int)response.StatusCode
|
HttpStatus = 200
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
catch (XiboAuthException ex)
|
||||||
_logger.LogWarning("Xibo connection test failed: {InstanceUrl} | status={StatusCode}",
|
{
|
||||||
instanceUrl, (int)response.StatusCode);
|
|
||||||
|
|
||||||
return new XiboTestResult
|
return new XiboTestResult
|
||||||
{
|
{
|
||||||
IsValid = false,
|
IsValid = false,
|
||||||
Message = response.StatusCode switch
|
Message = ex.Message,
|
||||||
{
|
HttpStatus = ex.HttpStatus
|
||||||
System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.",
|
|
||||||
System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.",
|
|
||||||
System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.",
|
|
||||||
_ => $"Unexpected response: {(int)response.StatusCode}"
|
|
||||||
},
|
|
||||||
HttpStatus = (int)response.StatusCode
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (TaskCanceledException)
|
catch (TaskCanceledException)
|
||||||
@@ -80,7 +73,249 @@ public class XiboApiService
|
|||||||
return new XiboTestResult { IsValid = false, Message = $"Cannot reach Xibo instance: {ex.Message}" };
|
return new XiboTestResult { IsValid = false, Message = $"Cannot reach Xibo instance: {ex.Message}" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Health / readiness
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Polls <paramref name="instanceUrl"/> until Xibo returns a 200 from its
|
||||||
|
/// <c>/about</c> endpoint or <paramref name="timeout"/> elapses.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> WaitForReadyAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
TimeSpan timeout,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboHealth");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
_logger.LogInformation("Waiting for Xibo instance to become ready: {Url}", baseUrl);
|
||||||
|
|
||||||
|
while (DateTime.UtcNow < deadline && !ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.GetAsync($"{baseUrl}/api/about", ct);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Xibo is ready: {Url}", baseUrl);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch { /* not yet available */ }
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(10), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("Xibo did not become ready within {Timeout}: {Url}", timeout, baseUrl);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Admin user
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new super-admin user in the Xibo instance and returns its numeric ID.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> CreateAdminUserAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
string bootstrapClientId,
|
||||||
|
string bootstrapClientSecret,
|
||||||
|
string newUsername,
|
||||||
|
string newPassword,
|
||||||
|
string email)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
|
||||||
|
SetBearer(client, token);
|
||||||
|
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("userName", newUsername),
|
||||||
|
new KeyValuePair<string, string>("email", email),
|
||||||
|
new KeyValuePair<string, string>("userTypeId", "1"), // Super Admin
|
||||||
|
new KeyValuePair<string, string>("homePageId", "1"),
|
||||||
|
new KeyValuePair<string, string>("libraryQuota", "0"),
|
||||||
|
new KeyValuePair<string, string>("groupId", "1"),
|
||||||
|
new KeyValuePair<string, string>("newUserPassword", newPassword),
|
||||||
|
new KeyValuePair<string, string>("retypeNewUserPassword", newPassword),
|
||||||
|
new KeyValuePair<string, string>("isPasswordChangeRequired", "0"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync($"{baseUrl}/api/user", form);
|
||||||
|
await EnsureSuccessAsync(response, "create Xibo admin user");
|
||||||
|
|
||||||
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
var userId = doc.RootElement.GetProperty("userId").GetInt32();
|
||||||
|
|
||||||
|
_logger.LogInformation("Xibo admin user created: username={Username}, userId={UserId}", newUsername, userId);
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Changes the password of an existing Xibo user.
|
||||||
|
/// </summary>
|
||||||
|
public async Task RotateUserPasswordAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
string bootstrapClientId,
|
||||||
|
string bootstrapClientSecret,
|
||||||
|
int userId,
|
||||||
|
string newPassword)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
|
||||||
|
SetBearer(client, token);
|
||||||
|
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("newUserPassword", newPassword),
|
||||||
|
new KeyValuePair<string, string>("retypeNewUserPassword", newPassword),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PutAsync($"{baseUrl}/api/user/{userId}", form);
|
||||||
|
await EnsureSuccessAsync(response, "rotate Xibo user password");
|
||||||
|
|
||||||
|
_logger.LogInformation("Xibo user password rotated: userId={UserId}", userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// OAuth2 application
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a new client_credentials OAuth2 application in Xibo and returns
|
||||||
|
/// the generated client_id and client_secret.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(string ClientId, string ClientSecret)> RegisterOAuthClientAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
string bootstrapClientId,
|
||||||
|
string bootstrapClientSecret,
|
||||||
|
string appName)
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
|
||||||
|
SetBearer(client, token);
|
||||||
|
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("name", appName),
|
||||||
|
new KeyValuePair<string, string>("clientId", Guid.NewGuid().ToString("N")),
|
||||||
|
new KeyValuePair<string, string>("confidential", "1"),
|
||||||
|
new KeyValuePair<string, string>("authCode", "0"),
|
||||||
|
new KeyValuePair<string, string>("clientCredentials", "1"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync($"{baseUrl}/api/application", form);
|
||||||
|
await EnsureSuccessAsync(response, "register Xibo OAuth2 application");
|
||||||
|
|
||||||
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var cid = root.GetProperty("key").GetString()
|
||||||
|
?? throw new InvalidOperationException("Xibo application 'key' missing in response.");
|
||||||
|
var secret = root.GetProperty("secret").GetString()
|
||||||
|
?? throw new InvalidOperationException("Xibo application 'secret' missing in response.");
|
||||||
|
|
||||||
|
_logger.LogInformation("Xibo OAuth2 application registered: name={Name}, clientId={ClientId}", appName, cid);
|
||||||
|
return (cid, secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Theme
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the active CMS theme by writing the THEME_FOLDER setting.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SetThemeAsync(
|
||||||
|
string instanceUrl,
|
||||||
|
string bootstrapClientId,
|
||||||
|
string bootstrapClientSecret,
|
||||||
|
string themeFolderName = "otssigns")
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("XiboApi");
|
||||||
|
var baseUrl = instanceUrl.TrimEnd('/');
|
||||||
|
|
||||||
|
var token = await GetTokenAsync(baseUrl, bootstrapClientId, bootstrapClientSecret, client);
|
||||||
|
SetBearer(client, token);
|
||||||
|
|
||||||
|
// Xibo stores settings as an array: settings[THEME_FOLDER]=otssigns
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("settings[THEME_FOLDER]", themeFolderName),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync($"{baseUrl}/api/admin/setting", form);
|
||||||
|
await EnsureSuccessAsync(response, "set Xibo theme");
|
||||||
|
|
||||||
|
_logger.LogInformation("Xibo theme set to: {Theme}", themeFolderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Helpers
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<string> GetTokenAsync(
|
||||||
|
string baseUrl,
|
||||||
|
string clientId,
|
||||||
|
string clientSecret,
|
||||||
|
HttpClient client)
|
||||||
|
{
|
||||||
|
var tokenUrl = $"{baseUrl}/api/authorize/access_token";
|
||||||
|
var form = new FormUrlEncodedContent(new[]
|
||||||
|
{
|
||||||
|
new KeyValuePair<string, string>("grant_type", "client_credentials"),
|
||||||
|
new KeyValuePair<string, string>("client_id", clientId),
|
||||||
|
new KeyValuePair<string, string>("client_secret", clientSecret),
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync(tokenUrl, form);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new XiboAuthException(
|
||||||
|
response.StatusCode switch
|
||||||
|
{
|
||||||
|
System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.",
|
||||||
|
System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.",
|
||||||
|
System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.",
|
||||||
|
_ => $"Unexpected response: {(int)response.StatusCode}"
|
||||||
|
},
|
||||||
|
(int)response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
|
||||||
|
var aToken = doc.RootElement.GetProperty("access_token").GetString()
|
||||||
|
?? throw new InvalidOperationException("access_token missing in Xibo token response.");
|
||||||
|
return aToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetBearer(HttpClient client, string token)
|
||||||
|
=> client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
|
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Xibo API call '{operation}' failed: {(int)response.StatusCode} — {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Result / exception types
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public class XiboTestResult
|
public class XiboTestResult
|
||||||
{
|
{
|
||||||
@@ -88,3 +323,11 @@ public class XiboTestResult
|
|||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
public int HttpStatus { get; set; }
|
public int HttpStatus { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class XiboAuthException : Exception
|
||||||
|
{
|
||||||
|
public int HttpStatus { get; }
|
||||||
|
public XiboAuthException(string message, int httpStatus) : base(message)
|
||||||
|
=> HttpStatus = httpStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ public class App : Application
|
|||||||
// HTTP
|
// HTTP
|
||||||
services.AddHttpClient();
|
services.AddHttpClient();
|
||||||
services.AddHttpClient("XiboApi");
|
services.AddHttpClient("XiboApi");
|
||||||
|
services.AddHttpClient("XiboHealth");
|
||||||
|
services.AddHttpClient("Bitwarden");
|
||||||
|
|
||||||
// SSH services (singletons — maintain connections)
|
// SSH services (singletons — maintain connections)
|
||||||
services.AddSingleton<SshConnectionService>();
|
services.AddSingleton<SshConnectionService>();
|
||||||
@@ -131,11 +133,14 @@ public class App : Application
|
|||||||
services.AddTransient<ComposeValidationService>();
|
services.AddTransient<ComposeValidationService>();
|
||||||
services.AddTransient<XiboApiService>();
|
services.AddTransient<XiboApiService>();
|
||||||
services.AddTransient<InstanceService>();
|
services.AddTransient<InstanceService>();
|
||||||
|
services.AddTransient<IBitwardenSecretService, BitwardenSecretService>();
|
||||||
|
services.AddSingleton<PostInstanceInitService>();
|
||||||
|
|
||||||
// ViewModels
|
// ViewModels
|
||||||
services.AddTransient<MainWindowViewModel>();
|
services.AddTransient<MainWindowViewModel>();
|
||||||
services.AddTransient<HostsViewModel>();
|
services.AddTransient<HostsViewModel>();
|
||||||
services.AddTransient<InstancesViewModel>();
|
services.AddTransient<InstancesViewModel>();
|
||||||
|
services.AddTransient<InstanceDetailsViewModel>();
|
||||||
services.AddTransient<CreateInstanceViewModel>();
|
services.AddTransient<CreateInstanceViewModel>();
|
||||||
services.AddTransient<SecretsViewModel>();
|
services.AddTransient<SecretsViewModel>();
|
||||||
services.AddTransient<SettingsViewModel>();
|
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 ObservableCollection<SshHost> _availableHosts = new();
|
||||||
[ObservableProperty] private SshHost? _selectedSshHost;
|
[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)
|
public InstancesViewModel(IServiceProvider services)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
@@ -158,4 +161,29 @@ public partial class InstancesViewModel : ObservableObject
|
|||||||
catch (Exception ex) { StatusMessage = $"Error rotating password: {ex.Message}"; }
|
catch (Exception ex) { StatusMessage = $"Error rotating password: {ex.Message}"; }
|
||||||
finally { IsBusy = false; }
|
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 _defaultPhpUploadMaxFilesize = "10G";
|
||||||
[ObservableProperty] private string _defaultPhpMaxExecutionTime = "600";
|
[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)
|
public SettingsViewModel(IServiceProvider services)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
@@ -116,6 +127,17 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||||
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
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.";
|
StatusMessage = "Settings loaded.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -180,6 +202,17 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
(SettingsService.DefaultPhpPostMaxSize, DefaultPhpPostMaxSize, SettingsService.CatDefaults, false),
|
(SettingsService.DefaultPhpPostMaxSize, DefaultPhpPostMaxSize, SettingsService.CatDefaults, false),
|
||||||
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
|
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
|
||||||
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, 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);
|
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]
|
[RelayCommand]
|
||||||
private async Task TestMySqlConnectionAsync()
|
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">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Content="Refresh" Command="{Binding LoadInstancesCommand}" />
|
<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="Inspect" Command="{Binding InspectInstanceCommand}" />
|
||||||
<Button Content="Delete" Classes="danger" Command="{Binding DeleteInstanceCommand}" />
|
<Button Content="Delete" Classes="danger" Command="{Binding DeleteInstanceCommand}" />
|
||||||
<Border Width="1" Background="{StaticResource BorderSubtleBrush}" Margin="4,2" />
|
<Border Width="1" Background="{StaticResource BorderSubtleBrush}" Margin="4,2" />
|
||||||
|
|||||||
@@ -1,11 +1,37 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using OTSSignsOrchestrator.Desktop.ViewModels;
|
||||||
|
|
||||||
namespace OTSSignsOrchestrator.Desktop.Views;
|
namespace OTSSignsOrchestrator.Desktop.Views;
|
||||||
|
|
||||||
public partial class InstancesView : UserControl
|
public partial class InstancesView : UserControl
|
||||||
{
|
{
|
||||||
|
private InstancesViewModel? _vm;
|
||||||
|
|
||||||
public InstancesView()
|
public InstancesView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
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>
|
</StackPanel>
|
||||||
</Border>
|
</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>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
BIN
otssigns-desktop.db-shm
Normal file
BIN
otssigns-desktop.db-shm
Normal file
Binary file not shown.
BIN
otssigns-desktop.db-wal
Normal file
BIN
otssigns-desktop.db-wal
Normal file
Binary file not shown.
Reference in New Issue
Block a user