diff --git a/OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs b/OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs
new file mode 100644
index 0000000..d179698
--- /dev/null
+++ b/OTSSignsOrchestrator.Core/Services/BitwardenSecretService.cs
@@ -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;
+
+///
+/// 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
+///
+public class BitwardenSecretService : IBitwardenSecretService
+{
+ private readonly IHttpClientFactory _http;
+ private readonly SettingsService _settings;
+ private readonly ILogger _logger;
+
+ // Cached bearer token (refreshed per service lifetime — transient registration is assumed)
+ private string? _bearerToken;
+
+ public BitwardenSecretService(
+ IHttpClientFactory http,
+ SettingsService settings,
+ ILogger logger)
+ {
+ _http = http;
+ _settings = settings;
+ _logger = logger;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // IBitwardenSecretService
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public async Task 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 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(),
+ 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()
+ ?? 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 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()
+ ?? 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(),
+ 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> 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();
+
+ return result?.Data?.Select(s => new BitwardenSecretSummary
+ {
+ Id = s.Id,
+ Key = s.Key,
+ CreationDate = s.CreationDate
+ }).ToList() ?? new List();
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Auth
+ // ─────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// 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}".
+ ///
+ private async Task 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.." — 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..");
+
+ 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("grant_type", "client_credentials"),
+ new KeyValuePair("client_id", $"machine.{tokenId}"),
+ new KeyValuePair("client_secret", clientSecret),
+ new KeyValuePair("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()
+ ?? throw new InvalidOperationException("Empty token response from Bitwarden.");
+
+ _bearerToken = token.AccessToken;
+ _logger.LogInformation("Bitwarden bearer token acquired.");
+ return _bearerToken;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────────────────
+
+ private async Task GetIdentityUrlAsync()
+ => (await _settings.GetAsync(SettingsService.BitwardenIdentityUrl, "https://identity.bitwarden.com")).TrimEnd('/');
+
+ private async Task 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? Data { get; set; }
+ }
+}
diff --git a/OTSSignsOrchestrator.Core/Services/IBitwardenSecretService.cs b/OTSSignsOrchestrator.Core/Services/IBitwardenSecretService.cs
new file mode 100644
index 0000000..62652d1
--- /dev/null
+++ b/OTSSignsOrchestrator.Core/Services/IBitwardenSecretService.cs
@@ -0,0 +1,49 @@
+namespace OTSSignsOrchestrator.Core.Services;
+
+///
+/// Abstraction for storing and retrieving secrets via Bitwarden Secrets Manager.
+///
+public interface IBitwardenSecretService
+{
+ ///
+ /// Returns true if Bitwarden is configured (access token + org ID are set).
+ ///
+ Task IsConfiguredAsync();
+
+ ///
+ /// Creates a new secret in the configured Bitwarden project.
+ ///
+ /// The ID of the created secret.
+ Task CreateSecretAsync(string key, string value, string note = "");
+
+ ///
+ /// Retrieves a secret by its Bitwarden ID.
+ ///
+ Task GetSecretAsync(string secretId);
+
+ ///
+ /// Updates the value of an existing secret in place.
+ ///
+ Task UpdateSecretAsync(string secretId, string key, string value, string note = "");
+
+ ///
+ /// Lists all secrets in the configured project.
+ ///
+ Task> 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; }
+}
diff --git a/OTSSignsOrchestrator.Core/Services/InstanceService.cs b/OTSSignsOrchestrator.Core/Services/InstanceService.cs
index e8b436f..7750540 100644
--- a/OTSSignsOrchestrator.Core/Services/InstanceService.cs
+++ b/OTSSignsOrchestrator.Core/Services/InstanceService.cs
@@ -32,6 +32,7 @@ public class InstanceService
private readonly IDockerSecretsService _secrets;
private readonly XiboApiService _xibo;
private readonly SettingsService _settings;
+ private readonly PostInstanceInitService _postInit;
private readonly DockerOptions _dockerOptions;
private readonly ILogger _logger;
@@ -44,6 +45,7 @@ public class InstanceService
IDockerSecretsService secrets,
XiboApiService xibo,
SettingsService settings,
+ PostInstanceInitService postInit,
IOptions dockerOptions,
ILogger logger)
{
@@ -55,6 +57,7 @@ public class InstanceService
_secrets = secrets;
_xibo = xibo;
_settings = settings;
+ _postInit = postInit;
_dockerOptions = dockerOptions.Value;
_logger = logger;
}
@@ -255,8 +258,13 @@ public class InstanceService
_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.Message = "Instance deployed successfully.";
+ deployResult.Message = "Instance deployed successfully. Post-install setup is running in background.";
return deployResult;
}
catch (Exception ex)
diff --git a/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs
new file mode 100644
index 0000000..ca596c7
--- /dev/null
+++ b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs
@@ -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;
+
+///
+/// Runs once after a Xibo CMS stack is deployed to complete post-install setup:
+///
+/// - Waits for the Xibo web service to become available.
+/// - Creates the OTS admin user with a random password.
+/// - Registers a dedicated client_credentials OAuth2 application for OTS.
+/// - Activates the otssigns theme.
+/// - Stores all generated credentials in Bitwarden Secrets Manager.
+///
+///
+/// Invoked as a background fire-and-forget task from .
+/// Progress is tracked in the operation log; failures are logged but do not roll back the deployment.
+///
+public class PostInstanceInitService
+{
+ private readonly IServiceProvider _services;
+ private readonly ILogger _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 logger)
+ {
+ _services = services;
+ _logger = logger;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Entry point
+ // ─────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Executes the post-instance initialisation sequence.
+ /// Intended to be called as a background task after stack deployment succeeds.
+ ///
+ 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();
+ var bws = scope.ServiceProvider.GetRequiredService();
+ var settings = scope.ServiceProvider.GetRequiredService();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ // ── 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)
+ // ─────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Generates a new OTS admin password, updates it in Xibo, and rotates the Bitwarden secret.
+ ///
+ public async Task RotateAdminPasswordAsync(string abbrev, string instanceUrl)
+ {
+ _logger.LogInformation("[PostInit] Rotating admin password for {Abbrev}", abbrev);
+
+ using var scope = _services.CreateScope();
+ var xibo = scope.ServiceProvider.GetRequiredService();
+ var bws = scope.ServiceProvider.GetRequiredService();
+ var settings = scope.ServiceProvider.GetRequiredService();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ // 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;
+ }
+
+ ///
+ /// Returns the stored admin password for an instance (from Bitwarden or local AppSettings).
+ ///
+ public async Task GetCredentialsAsync(string abbrev)
+ {
+ using var scope = _services.CreateScope();
+ var bws = scope.ServiceProvider.GetRequiredService();
+ var settings = scope.ServiceProvider.GetRequiredService();
+
+ 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 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("grant_type", "client_credentials"),
+ new KeyValuePair("client_id", clientId),
+ new KeyValuePair("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.");
+ }
+}
+
+/// Snapshot of provisioned credentials for a CMS instance.
+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);
+}
diff --git a/OTSSignsOrchestrator.Core/Services/SettingsService.cs b/OTSSignsOrchestrator.Core/Services/SettingsService.cs
index a55a8a8..8103db2 100644
--- a/OTSSignsOrchestrator.Core/Services/SettingsService.cs
+++ b/OTSSignsOrchestrator.Core/Services/SettingsService.cs
@@ -68,12 +68,31 @@ public class SettingsService
public const string DefaultPhpUploadMaxFilesize = "Defaults.PhpUploadMaxFilesize";
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)
///
/// Builds a per-instance settings key for the MySQL password.
/// Stored encrypted via DataProtection so it can be retrieved on update/redeploy.
///
- public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
+ public static string InstanceMySqlPassword(string abbrev) => $"Instance.{abbrev}.MySqlPassword";
+ /// Bitwarden secret ID for the instance's OTS admin password.
+ public static string InstanceAdminPasswordSecretId(string abbrev) => $"Instance.{abbrev}.AdminPasswordBwsId";
+ /// Bitwarden secret ID for the instance's Xibo OAuth2 client secret.
+ public static string InstanceOAuthSecretId(string abbrev) => $"Instance.{abbrev}.OAuthSecretBwsId";
+ /// Xibo OAuth2 client_id generated for this instance's OTS application.
+ public static string InstanceOAuthClientId(string abbrev) => $"Instance.{abbrev}.OAuthClientId";
public const string CatInstance = "Instance";
public SettingsService(
diff --git a/OTSSignsOrchestrator.Core/Services/XiboApiService.cs b/OTSSignsOrchestrator.Core/Services/XiboApiService.cs
index da7e70f..569c131 100644
--- a/OTSSignsOrchestrator.Core/Services/XiboApiService.cs
+++ b/OTSSignsOrchestrator.Core/Services/XiboApiService.cs
@@ -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.Options;
using OTSSignsOrchestrator.Core.Configuration;
@@ -5,7 +9,13 @@ using OTSSignsOrchestrator.Core.Configuration;
namespace OTSSignsOrchestrator.Core.Services;
///
-/// 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.
///
public class XiboApiService
{
@@ -23,7 +33,11 @@ public class XiboApiService
_logger = logger;
}
- public async Task TestConnectionAsync(string instanceUrl, string username, string password)
+ // ─────────────────────────────────────────────────────────────────────────
+ // Connection test
+ // ─────────────────────────────────────────────────────────────────────────
+
+ public async Task TestConnectionAsync(string instanceUrl, string clientId, string clientSecret)
{
_logger.LogInformation("Testing Xibo connection to {InstanceUrl}", instanceUrl);
@@ -32,43 +46,22 @@ public class XiboApiService
try
{
- var baseUrl = instanceUrl.TrimEnd('/');
- var tokenUrl = $"{baseUrl}/api/authorize/access_token";
-
- var formContent = new FormUrlEncodedContent(new[]
- {
- new KeyValuePair("grant_type", "client_credentials"),
- new KeyValuePair("client_id", username),
- new KeyValuePair("client_secret", password)
- });
-
- var response = await client.PostAsync(tokenUrl, formContent);
-
- if (response.IsSuccessStatusCode)
- {
- _logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
- return new XiboTestResult
- {
- IsValid = true,
- Message = "Connected successfully.",
- HttpStatus = (int)response.StatusCode
- };
- }
-
- _logger.LogWarning("Xibo connection test failed: {InstanceUrl} | status={StatusCode}",
- instanceUrl, (int)response.StatusCode);
-
+ var token = await GetTokenAsync(instanceUrl, clientId, clientSecret, client);
+ _logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
return new XiboTestResult
{
- IsValid = false,
- Message = 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}"
- },
- HttpStatus = (int)response.StatusCode
+ IsValid = true,
+ Message = "Connected successfully.",
+ HttpStatus = 200
+ };
+ }
+ catch (XiboAuthException ex)
+ {
+ return new XiboTestResult
+ {
+ IsValid = false,
+ Message = ex.Message,
+ HttpStatus = ex.HttpStatus
};
}
catch (TaskCanceledException)
@@ -80,11 +73,261 @@ public class XiboApiService
return new XiboTestResult { IsValid = false, Message = $"Cannot reach Xibo instance: {ex.Message}" };
}
}
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Health / readiness
+ // ─────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Polls until Xibo returns a 200 from its
+ /// /about endpoint or elapses.
+ ///
+ public async Task 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
+ // ─────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Creates a new super-admin user in the Xibo instance and returns its numeric ID.
+ ///
+ public async Task 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("userName", newUsername),
+ new KeyValuePair("email", email),
+ new KeyValuePair("userTypeId", "1"), // Super Admin
+ new KeyValuePair("homePageId", "1"),
+ new KeyValuePair("libraryQuota", "0"),
+ new KeyValuePair("groupId", "1"),
+ new KeyValuePair("newUserPassword", newPassword),
+ new KeyValuePair("retypeNewUserPassword", newPassword),
+ new KeyValuePair("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;
+ }
+
+ ///
+ /// Changes the password of an existing Xibo user.
+ ///
+ 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("newUserPassword", newPassword),
+ new KeyValuePair("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
+ // ─────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Registers a new client_credentials OAuth2 application in Xibo and returns
+ /// the generated client_id and client_secret.
+ ///
+ 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("name", appName),
+ new KeyValuePair("clientId", Guid.NewGuid().ToString("N")),
+ new KeyValuePair("confidential", "1"),
+ new KeyValuePair("authCode", "0"),
+ new KeyValuePair("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
+ // ─────────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Sets the active CMS theme by writing the THEME_FOLDER setting.
+ ///
+ 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("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 GetTokenAsync(
+ string baseUrl,
+ string clientId,
+ string clientSecret,
+ HttpClient client)
+ {
+ var tokenUrl = $"{baseUrl}/api/authorize/access_token";
+ var form = new FormUrlEncodedContent(new[]
+ {
+ new KeyValuePair("grant_type", "client_credentials"),
+ new KeyValuePair("client_id", clientId),
+ new KeyValuePair("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 bool IsValid { get; set; }
- public string Message { get; set; } = string.Empty;
- public int HttpStatus { get; set; }
+ public bool IsValid { get; set; }
+ public string Message { get; set; } = string.Empty;
+ public int HttpStatus { get; set; }
}
+
+public class XiboAuthException : Exception
+{
+ public int HttpStatus { get; }
+ public XiboAuthException(string message, int httpStatus) : base(message)
+ => HttpStatus = httpStatus;
+}
+
diff --git a/OTSSignsOrchestrator.Desktop/App.axaml.cs b/OTSSignsOrchestrator.Desktop/App.axaml.cs
index a9565ba..7f848a7 100644
--- a/OTSSignsOrchestrator.Desktop/App.axaml.cs
+++ b/OTSSignsOrchestrator.Desktop/App.axaml.cs
@@ -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();
@@ -131,11 +133,14 @@ public class App : Application
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
+ services.AddSingleton();
// ViewModels
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddTransient();
services.AddTransient();
services.AddTransient();
services.AddTransient();
diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs
new file mode 100644
index 0000000..e8e3c8c
--- /dev/null
+++ b/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs
@@ -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;
+
+///
+/// ViewModel for the instance details modal.
+/// Shows admin credentials, DB credentials, and OAuth2 app details
+/// with options to rotate passwords.
+///
+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
+ // ─────────────────────────────────────────────────────────────────────────
+
+ /// Populates the ViewModel from a live .
+ 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();
+ var postInit = scope.ServiceProvider.GetRequiredService();
+
+ // 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();
+ 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();
+ var dockerSecrets = _services.GetRequiredService();
+
+ // We need the Host — retrieve from the HostLabel lookup
+ using var scope = _services.CreateScope();
+ var instanceSvc = scope.ServiceProvider.GetRequiredService();
+
+ // 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();
+ 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);
+ }
+}
diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs
index 5f3ea41..850732f 100644
--- a/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs
+++ b/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs
@@ -30,6 +30,9 @@ public partial class InstancesViewModel : ObservableObject
[ObservableProperty] private ObservableCollection _availableHosts = new();
[ObservableProperty] private SshHost? _selectedSshHost;
+ /// Raised when the instance details modal should be opened for the given ViewModel.
+ public event Action? 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();
+ dockerCli.SetHost(SelectedInstance.Host);
+ var dockerSecrets = _services.GetRequiredService();
+ dockerSecrets.SetHost(SelectedInstance.Host);
+
+ var detailsVm = _services.GetRequiredService();
+ await detailsVm.LoadAsync(SelectedInstance);
+
+ OpenDetailsRequested?.Invoke(detailsVm);
+ StatusMessage = string.Empty;
+ }
+ catch (Exception ex) { StatusMessage = $"Error opening details: {ex.Message}"; }
+ finally { IsBusy = false; }
+ }
}
diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs
index ec563be..ca277c6 100644
--- a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs
+++ b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs
@@ -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();
+ 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();
+ var svc = scope.ServiceProvider.GetRequiredService();
+ 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()
{
diff --git a/OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml b/OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml
new file mode 100644
index 0000000..d06ff76
--- /dev/null
+++ b/OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml.cs b/OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml.cs
new file mode 100644
index 0000000..15e38b1
--- /dev/null
+++ b/OTSSignsOrchestrator.Desktop/Views/InstanceDetailsWindow.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace OTSSignsOrchestrator.Desktop.Views;
+
+public partial class InstanceDetailsWindow : Window
+{
+ public InstanceDetailsWindow()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml b/OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml
index 28a5064..5cb5037 100644
--- a/OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml
+++ b/OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml
@@ -16,6 +16,9 @@
+
diff --git a/OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml.cs b/OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml.cs
index 1e8a682..e2d7300 100644
--- a/OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml.cs
+++ b/OTSSignsOrchestrator.Desktop/Views/InstancesView.axaml.cs
@@ -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();
}
}
+
diff --git a/OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml b/OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml
index 6425ebc..a3727b7 100644
--- a/OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml
+++ b/OTSSignsOrchestrator.Desktop/Views/SettingsView.axaml
@@ -231,6 +231,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/otssigns-desktop.db-shm b/otssigns-desktop.db-shm
new file mode 100644
index 0000000..fc1d98a
Binary files /dev/null and b/otssigns-desktop.db-shm differ
diff --git a/otssigns-desktop.db-wal b/otssigns-desktop.db-wal
new file mode 100644
index 0000000..82d0535
Binary files /dev/null and b/otssigns-desktop.db-wal differ