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

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

View File

@@ -0,0 +1,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; }
}
}

View File

@@ -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; }
}

View File

@@ -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)

View 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);
}

View File

@@ -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(

View File

@@ -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"; _logger.LogInformation("Xibo connection test succeeded for {InstanceUrl}", instanceUrl);
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);
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);
return new XiboTestResult return new XiboTestResult
{ {
IsValid = false, IsValid = true,
Message = response.StatusCode switch Message = "Connected successfully.",
{ HttpStatus = 200
System.Net.HttpStatusCode.Unauthorized => "Invalid Xibo credentials.", };
System.Net.HttpStatusCode.Forbidden => "User lacks API permissions.", }
System.Net.HttpStatusCode.ServiceUnavailable => "Xibo instance not ready.", catch (XiboAuthException ex)
_ => $"Unexpected response: {(int)response.StatusCode}" {
}, return new XiboTestResult
HttpStatus = (int)response.StatusCode {
IsValid = false,
Message = ex.Message,
HttpStatus = ex.HttpStatus
}; };
} }
catch (TaskCanceledException) catch (TaskCanceledException)
@@ -80,11 +73,261 @@ 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
{ {
public bool IsValid { get; set; } public bool IsValid { get; set; }
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;
}

View File

@@ -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>();

View File

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

View File

@@ -30,6 +30,9 @@ public partial class InstancesViewModel : ObservableObject
[ObservableProperty] private ObservableCollection<SshHost> _availableHosts = new(); [ObservableProperty] private 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; }
}
} }

View File

@@ -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()
{ {

View 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>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace OTSSignsOrchestrator.Desktop.Views;
public partial class InstanceDetailsWindow : Window
{
public InstanceDetailsWindow()
{
InitializeComponent();
}
}

View File

@@ -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" />

View File

@@ -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();
} }
} }

View File

@@ -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 &amp; 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

Binary file not shown.

BIN
otssigns-desktop.db-wal Normal file

Binary file not shown.