Refactor SAML configuration deployment and enhance Authentik integration
- Removed SAML configuration deployment calls from PostInstanceInitService. - Updated DeploySamlConfigurationAsync to improve template fetching logic from Git and local directories. - Added Authentik flow and keypair models for better representation in the UI. - Enhanced SettingsViewModel to include Authentik settings with save and test functionality. - Updated UI to support Authentik configuration, including dropdowns for flows and keypairs. - Changed default CMS server name template to "app.ots-signs.com" across various files. - Improved password handling in SshDockerCliService for secure shell command execution. - Added new template file for settings-custom.php in the project structure.
This commit is contained in:
@@ -49,4 +49,11 @@
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\templates\settings-custom.php.template">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<Link>templates/settings-custom.php.template</Link>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -41,6 +41,70 @@ public class SshDockerCliService : IDockerCliService
|
||||
|
||||
public SshHost? CurrentHost => _currentHost;
|
||||
|
||||
private void EnsureHost()
|
||||
{
|
||||
if (_currentHost == null)
|
||||
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escape password for safe use in shell scripts with proper quoting.
|
||||
/// Uses printf-safe escaping to avoid newline injection and special character issues.
|
||||
/// </summary>
|
||||
private string EscapePasswordForShell(string password)
|
||||
{
|
||||
if (string.IsNullOrEmpty(password))
|
||||
{
|
||||
_logger.LogWarning("Password is null or empty");
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Original password length: {Length} characters", password.Length);
|
||||
|
||||
// Use printf-safe format: escape single quotes and other problematic characters
|
||||
// Replace ' with '\'' (close quote, escaped quote, open quote)
|
||||
var escaped = password.Replace("'", "'\\''");
|
||||
|
||||
_logger.LogDebug("Escaped password length: {Length} characters (added {Extra} chars for escaping)",
|
||||
escaped.Length, escaped.Length - password.Length);
|
||||
_logger.LogDebug("Password first char: '{FirstChar}', last char: '{LastChar}'",
|
||||
password.Length > 0 ? password[0].ToString() : "N/A",
|
||||
password.Length > 0 ? password[^1].ToString() : "N/A");
|
||||
|
||||
return escaped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test if the current host's password works with sudo by running a no-op sudo command.
|
||||
/// </summary>
|
||||
private async Task<(bool Success, string? Error)> TestSudoPasswordAsync()
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
if (string.IsNullOrEmpty(_currentHost!.Password))
|
||||
{
|
||||
return (false, "No password configured for SSH host");
|
||||
}
|
||||
|
||||
var escapedPassword = EscapePasswordForShell(_currentHost!.Password);
|
||||
var testCmd = $"printf '%s\\n' '{escapedPassword}' | sudo -S -v 2>&1";
|
||||
|
||||
_logger.LogInformation("Testing sudo password for host {Host} user {User}...",
|
||||
_currentHost!.Label, _currentHost!.Username);
|
||||
|
||||
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, testCmd, TimeSpan.FromSeconds(10));
|
||||
|
||||
if (exitCode == 0)
|
||||
{
|
||||
_logger.LogInformation("Sudo password test PASSED for {Host}", _currentHost!.Label);
|
||||
return (true, null);
|
||||
}
|
||||
|
||||
var error = (stderr ?? stdout ?? "unknown error").Trim();
|
||||
_logger.LogWarning("Sudo password test FAILED for {Host}: {Error}", _currentHost!.Label, error);
|
||||
return (false, error);
|
||||
}
|
||||
|
||||
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
|
||||
{
|
||||
EnsureHost();
|
||||
@@ -184,12 +248,22 @@ public class SshDockerCliService : IDockerCliService
|
||||
// Single SSH command: create temp dir, mount NFS, mkdir -p all folders, unmount, cleanup
|
||||
// Use addr= to pin the server IP — avoids "Server address does not match proto= option"
|
||||
// errors when the hostname resolves to IPv6 but proto=tcp implies IPv4.
|
||||
// Properly escape password for shell use (handle special characters like single quotes)
|
||||
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
|
||||
|
||||
if (string.IsNullOrEmpty(escapedPassword))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
|
||||
_currentHost!.Label);
|
||||
}
|
||||
|
||||
var script = $"""
|
||||
set -e
|
||||
MNT=$(mktemp -d)
|
||||
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||
sudo mkdir -p {mkdirTargets}
|
||||
sudo umount "$MNT"
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets}
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
|
||||
rmdir "$MNT"
|
||||
""";
|
||||
|
||||
@@ -229,12 +303,22 @@ public class SshDockerCliService : IDockerCliService
|
||||
var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList();
|
||||
var mkdirTargets = string.Join(" ", folderList);
|
||||
|
||||
// Properly escape password for shell use (handle special characters like single quotes)
|
||||
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
|
||||
|
||||
if (string.IsNullOrEmpty(escapedPassword))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
|
||||
_currentHost!.Label);
|
||||
}
|
||||
|
||||
var script = $"""
|
||||
set -e
|
||||
MNT=$(mktemp -d)
|
||||
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||
sudo mkdir -p {mkdirTargets}
|
||||
sudo umount "$MNT"
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets}
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
|
||||
rmdir "$MNT"
|
||||
""";
|
||||
|
||||
@@ -271,22 +355,44 @@ public class SshDockerCliService : IDockerCliService
|
||||
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
|
||||
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
|
||||
|
||||
// Ensure parent directory exists, then write content via heredoc
|
||||
// Ensure parent directory exists
|
||||
var targetPath = $"$MNT{subPath}/{relativePath.TrimStart('/')}";
|
||||
var parentDir = $"$(dirname \"{targetPath}\")";
|
||||
|
||||
// Escape content for heredoc (replace any literal EOF that might appear in content)
|
||||
var safeContent = content.Replace("'", "'\\''");
|
||||
// Properly escape password for shell use (handle special characters like single quotes)
|
||||
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
|
||||
|
||||
_logger.LogInformation("NFS WriteFile: Host={Host}, User={User}, HasPassword={HasPw}, PwLen={PwLen}",
|
||||
_currentHost!.Label, _currentHost!.Username,
|
||||
!string.IsNullOrEmpty(_currentHost!.Password), _currentHost!.Password?.Length ?? 0);
|
||||
|
||||
if (string.IsNullOrEmpty(escapedPassword))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
|
||||
_currentHost!.Label);
|
||||
return (false, "No password configured for SSH host");
|
||||
}
|
||||
|
||||
// Base64-encode the file content to avoid heredoc/stdin conflicts with sudo -S.
|
||||
// The heredoc approach fails because the shell's heredoc redirects stdin for the
|
||||
// entire pipeline, so sudo -S reads the PHP content instead of the password.
|
||||
var base64Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(content));
|
||||
|
||||
// Strategy: base64-decode content to a temp file (no sudo needed), then use
|
||||
// printf | sudo -S for each privileged command — matching the proven pattern
|
||||
// in EnsureNfsFoldersAsync. We avoid sudo -v timestamp caching because SSH
|
||||
// exec channels have no TTY and timestamps may not persist between commands.
|
||||
var script = $"""
|
||||
set -e
|
||||
TMPFILE=$(mktemp)
|
||||
echo '{base64Content}' | base64 -d > "$TMPFILE"
|
||||
MNT=$(mktemp -d)
|
||||
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||
sudo mkdir -p {parentDir}
|
||||
sudo tee "{targetPath}" > /dev/null << 'OTSSIGNS_EOF'
|
||||
{content}
|
||||
OTSSIGNS_EOF
|
||||
sudo umount "$MNT"
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {parentDir}
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S cp "$TMPFILE" "{targetPath}"
|
||||
rm -f "$TMPFILE"
|
||||
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
|
||||
rmdir "$MNT"
|
||||
""";
|
||||
|
||||
@@ -609,12 +715,6 @@ public class SshDockerCliService : IDockerCliService
|
||||
return serviceName.StartsWith(prefix) ? serviceName[prefix.Length..] : serviceName;
|
||||
}
|
||||
|
||||
private void EnsureHost()
|
||||
{
|
||||
if (_currentHost == null)
|
||||
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveStackVolumesAsync(string stackName)
|
||||
{
|
||||
EnsureHost();
|
||||
|
||||
@@ -169,7 +169,7 @@ public partial class CreateInstanceViewModel : ObservableObject
|
||||
var mySqlDbName = (await settings.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db")).Replace("{abbrev}", abbrev);
|
||||
var mySqlUser = (await settings.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com")).Replace("{abbrev}", abbrev);
|
||||
var cmsServerName = (await settings.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com")).Replace("{abbrev}", abbrev);
|
||||
var themePath = (await settings.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/ots-theme")).Replace("{abbrev}", abbrev);
|
||||
|
||||
var smtpServer = await settings.GetAsync(SettingsService.SmtpServer, string.Empty);
|
||||
|
||||
@@ -80,7 +80,7 @@ public partial class InstanceDetailsViewModel : ObservableObject
|
||||
|
||||
// Derive the instance URL from the CMS server name template
|
||||
var serverTemplate = await settings.GetAsync(
|
||||
SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
|
||||
SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
|
||||
var serverName = serverTemplate.Replace("{abbrev}", instance.CustomerAbbrev);
|
||||
InstanceUrl = $"https://{serverName}/{instance.CustomerAbbrev.Trim().ToLowerInvariant()}";
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OTSSignsOrchestrator.Core.Configuration;
|
||||
using OTSSignsOrchestrator.Core.Models.DTOs;
|
||||
using OTSSignsOrchestrator.Core.Services;
|
||||
|
||||
namespace OTSSignsOrchestrator.Desktop.ViewModels;
|
||||
@@ -55,7 +56,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
[ObservableProperty] private string _defaultNewtImage = "fosrl/newt";
|
||||
[ObservableProperty] private string _defaultMemcachedImage = "memcached:alpine";
|
||||
[ObservableProperty] private string _defaultQuickChartImage = "ianw/quickchart";
|
||||
[ObservableProperty] private string _defaultCmsServerNameTemplate = "{abbrev}.ots-signs.com";
|
||||
[ObservableProperty] private string _defaultCmsServerNameTemplate = "app.ots-signs.com";
|
||||
[ObservableProperty] private string _defaultThemeHostPath = "/cms/{abbrev}-cms-theme-custom";
|
||||
[ObservableProperty] private string _defaultMySqlDbTemplate = "{abbrev}_cms_db";
|
||||
[ObservableProperty] private string _defaultMySqlUserTemplate = "{abbrev}_cms_user";
|
||||
@@ -71,6 +72,24 @@ public partial class SettingsViewModel : ObservableObject
|
||||
[ObservableProperty] private string _bitwardenProjectId = string.Empty;
|
||||
[ObservableProperty] private string _bitwardenInstanceProjectId = string.Empty;
|
||||
|
||||
// ── Authentik (SAML IdP) ────────────────────────────────────────
|
||||
[ObservableProperty] private string _authentikUrl = string.Empty;
|
||||
[ObservableProperty] private string _authentikApiKey = string.Empty;
|
||||
[ObservableProperty] private string _authentikAuthorizationFlowSlug = string.Empty;
|
||||
[ObservableProperty] private string _authentikInvalidationFlowSlug = string.Empty;
|
||||
[ObservableProperty] private string _authentikSigningKeypairId = string.Empty;
|
||||
[ObservableProperty] private string _authentikStatusMessage = string.Empty;
|
||||
[ObservableProperty] private bool _isAuthentikBusy;
|
||||
|
||||
// Dropdown collections for Authentik flows / keypairs
|
||||
public ObservableCollection<AuthentikFlowItem> AuthentikAuthorizationFlows { get; } = new();
|
||||
public ObservableCollection<AuthentikFlowItem> AuthentikInvalidationFlows { get; } = new();
|
||||
public ObservableCollection<AuthentikKeypairItem> AuthentikKeypairs { get; } = new();
|
||||
|
||||
[ObservableProperty] private AuthentikFlowItem? _selectedAuthorizationFlow;
|
||||
[ObservableProperty] private AuthentikFlowItem? _selectedInvalidationFlow;
|
||||
[ObservableProperty] private AuthentikKeypairItem? _selectedSigningKeypair;
|
||||
|
||||
// ── Xibo Bootstrap OAuth2 ─────────────────────────────────────
|
||||
[ObservableProperty] private string _xiboBootstrapClientId = string.Empty;
|
||||
[ObservableProperty] private string _xiboBootstrapClientSecret = string.Empty;
|
||||
@@ -85,22 +104,27 @@ public partial class SettingsViewModel : ObservableObject
|
||||
[ObservableProperty] private bool _isBitwardenConfigured;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LoadAsync()
|
||||
private Task LoadAsync() => LoadCoreAsync(skipBitwarden: false);
|
||||
|
||||
private async Task LoadCoreAsync(bool skipBitwarden)
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
// ── Load Bitwarden bootstrap config from IOptions<BitwardenOptions> ──
|
||||
var bwOptions = _services.GetRequiredService<IOptions<BitwardenOptions>>().Value;
|
||||
BitwardenIdentityUrl = bwOptions.IdentityUrl;
|
||||
BitwardenApiUrl = bwOptions.ApiUrl;
|
||||
BitwardenAccessToken = bwOptions.AccessToken;
|
||||
BitwardenOrganizationId = bwOptions.OrganizationId;
|
||||
BitwardenProjectId = bwOptions.ProjectId;
|
||||
BitwardenInstanceProjectId = bwOptions.InstanceProjectId;
|
||||
if (!skipBitwarden)
|
||||
{
|
||||
// ── Load Bitwarden bootstrap config (IOptionsMonitor picks up appsettings.json changes) ──
|
||||
var bwOptions = _services.GetRequiredService<IOptionsMonitor<BitwardenOptions>>().CurrentValue;
|
||||
BitwardenIdentityUrl = bwOptions.IdentityUrl;
|
||||
BitwardenApiUrl = bwOptions.ApiUrl;
|
||||
BitwardenAccessToken = bwOptions.AccessToken;
|
||||
BitwardenOrganizationId = bwOptions.OrganizationId;
|
||||
BitwardenProjectId = bwOptions.ProjectId;
|
||||
BitwardenInstanceProjectId = bwOptions.InstanceProjectId;
|
||||
}
|
||||
|
||||
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(bwOptions.AccessToken)
|
||||
&& !string.IsNullOrWhiteSpace(bwOptions.OrganizationId);
|
||||
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
|
||||
&& !string.IsNullOrWhiteSpace(BitwardenOrganizationId);
|
||||
|
||||
if (!IsBitwardenConfigured)
|
||||
{
|
||||
@@ -146,7 +170,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
DefaultNewtImage = await svc.GetAsync(SettingsService.DefaultNewtImage, "fosrl/newt");
|
||||
DefaultMemcachedImage = await svc.GetAsync(SettingsService.DefaultMemcachedImage, "memcached:alpine");
|
||||
DefaultQuickChartImage = await svc.GetAsync(SettingsService.DefaultQuickChartImage, "ianw/quickchart");
|
||||
DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "{abbrev}.ots-signs.com");
|
||||
DefaultCmsServerNameTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.ots-signs.com");
|
||||
DefaultThemeHostPath = await svc.GetAsync(SettingsService.DefaultThemeHostPath, "/cms/{abbrev}-cms-theme-custom");
|
||||
DefaultMySqlDbTemplate = await svc.GetAsync(SettingsService.DefaultMySqlDbTemplate, "{abbrev}_cms_db");
|
||||
DefaultMySqlUserTemplate = await svc.GetAsync(SettingsService.DefaultMySqlUserTemplate, "{abbrev}_cms_user");
|
||||
@@ -154,6 +178,17 @@ public partial class SettingsViewModel : ObservableObject
|
||||
DefaultPhpUploadMaxFilesize = await svc.GetAsync(SettingsService.DefaultPhpUploadMaxFilesize, "10G");
|
||||
DefaultPhpMaxExecutionTime = await svc.GetAsync(SettingsService.DefaultPhpMaxExecutionTime, "600");
|
||||
|
||||
// Authentik
|
||||
AuthentikUrl = await svc.GetAsync(SettingsService.AuthentikUrl, string.Empty);
|
||||
AuthentikApiKey = await svc.GetAsync(SettingsService.AuthentikApiKey, string.Empty);
|
||||
AuthentikAuthorizationFlowSlug = await svc.GetAsync(SettingsService.AuthentikAuthorizationFlowSlug, string.Empty);
|
||||
AuthentikInvalidationFlowSlug = await svc.GetAsync(SettingsService.AuthentikInvalidationFlowSlug, string.Empty);
|
||||
AuthentikSigningKeypairId = await svc.GetAsync(SettingsService.AuthentikSigningKeypairId, string.Empty);
|
||||
|
||||
// If Authentik URL + key are configured, try loading dropdowns
|
||||
if (!string.IsNullOrWhiteSpace(AuthentikUrl) && !string.IsNullOrWhiteSpace(AuthentikApiKey))
|
||||
await FetchAuthentikDropdownsInternalAsync();
|
||||
|
||||
// Xibo Bootstrap
|
||||
XiboBootstrapClientId = await svc.GetAsync(SettingsService.XiboBootstrapClientId, string.Empty);
|
||||
XiboBootstrapClientSecret = await svc.GetAsync(SettingsService.XiboBootstrapClientSecret, string.Empty);
|
||||
@@ -171,28 +206,51 @@ public partial class SettingsViewModel : ObservableObject
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveAsync()
|
||||
private async Task SaveBitwardenLocalAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
// ── 1. Save Bitwarden bootstrap config to appsettings.json ──
|
||||
await SaveBitwardenConfigToFileAsync();
|
||||
|
||||
// Check if Bitwarden is now configured
|
||||
IsBitwardenConfigured = !string.IsNullOrWhiteSpace(BitwardenAccessToken)
|
||||
&& !string.IsNullOrWhiteSpace(BitwardenOrganizationId);
|
||||
|
||||
StatusMessage = IsBitwardenConfigured
|
||||
? "Bitwarden config saved to appsettings.json."
|
||||
: "Bitwarden config saved. Fill in Access Token and Org ID to enable all settings.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = $"Error saving Bitwarden config: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PullFromBitwardenAsync()
|
||||
{
|
||||
await LoadCoreAsync(skipBitwarden: true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task PushToBitwardenAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
if (!IsBitwardenConfigured)
|
||||
{
|
||||
StatusMessage = "Bitwarden config saved to appsettings.json. Fill in Access Token and Org ID to enable all settings.";
|
||||
StatusMessage = "Bitwarden is not configured. Save Bitwarden config first.";
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 2. Save all other settings to Bitwarden ──
|
||||
using var scope = _services.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
svc.InvalidateCache(); // force re-read after config change
|
||||
svc.InvalidateCache();
|
||||
|
||||
var settings = new List<(string Key, string? Value, string Category, bool IsSensitive)>
|
||||
{
|
||||
@@ -238,13 +296,20 @@ public partial class SettingsViewModel : ObservableObject
|
||||
(SettingsService.DefaultPhpUploadMaxFilesize, DefaultPhpUploadMaxFilesize, SettingsService.CatDefaults, false),
|
||||
(SettingsService.DefaultPhpMaxExecutionTime, DefaultPhpMaxExecutionTime, SettingsService.CatDefaults, false),
|
||||
|
||||
// Authentik
|
||||
(SettingsService.AuthentikUrl, NullIfEmpty(AuthentikUrl), SettingsService.CatAuthentik, false),
|
||||
(SettingsService.AuthentikApiKey, NullIfEmpty(AuthentikApiKey), SettingsService.CatAuthentik, true),
|
||||
(SettingsService.AuthentikAuthorizationFlowSlug, NullIfEmpty(SelectedAuthorizationFlow?.Slug ?? AuthentikAuthorizationFlowSlug), SettingsService.CatAuthentik, false),
|
||||
(SettingsService.AuthentikInvalidationFlowSlug, NullIfEmpty(SelectedInvalidationFlow?.Slug ?? AuthentikInvalidationFlowSlug), SettingsService.CatAuthentik, false),
|
||||
(SettingsService.AuthentikSigningKeypairId, NullIfEmpty(SelectedSigningKeypair?.Pk ?? AuthentikSigningKeypairId), SettingsService.CatAuthentik, false),
|
||||
|
||||
// Xibo Bootstrap
|
||||
(SettingsService.XiboBootstrapClientId, NullIfEmpty(XiboBootstrapClientId), SettingsService.CatXibo, false),
|
||||
(SettingsService.XiboBootstrapClientSecret, NullIfEmpty(XiboBootstrapClientSecret), SettingsService.CatXibo, true),
|
||||
};
|
||||
|
||||
await svc.SaveManyAsync(settings);
|
||||
StatusMessage = "Settings saved to Bitwarden.";
|
||||
StatusMessage = "Settings pushed to Bitwarden.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -289,6 +354,135 @@ public partial class SettingsViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Authentik: save, test, fetch dropdowns
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveAndTestAuthentikAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(AuthentikUrl) || string.IsNullOrWhiteSpace(AuthentikApiKey))
|
||||
{
|
||||
AuthentikStatusMessage = "Authentik URL and API Token are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsAuthentikBusy = true;
|
||||
AuthentikStatusMessage = "Saving Authentik settings and testing connection...";
|
||||
try
|
||||
{
|
||||
// Persist URL + API key first so subsequent calls work
|
||||
using var scope = _services.CreateScope();
|
||||
var svc = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||||
await svc.SetAsync(SettingsService.AuthentikUrl, AuthentikUrl.Trim(), SettingsService.CatAuthentik);
|
||||
await svc.SetAsync(SettingsService.AuthentikApiKey, AuthentikApiKey.Trim(), SettingsService.CatAuthentik, isSensitive: true);
|
||||
|
||||
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
|
||||
var (ok, msg) = await authentik.TestConnectionAsync(AuthentikUrl.Trim(), AuthentikApiKey.Trim());
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
AuthentikStatusMessage = $"Connection failed: {msg}";
|
||||
return;
|
||||
}
|
||||
|
||||
AuthentikStatusMessage = "Connected — loading flows and keypairs...";
|
||||
|
||||
// Now fetch dropdowns
|
||||
await FetchAuthentikDropdownsInternalAsync(AuthentikUrl.Trim(), AuthentikApiKey.Trim());
|
||||
|
||||
// Save selected flow/keypair values
|
||||
var authSlug = SelectedAuthorizationFlow?.Slug ?? AuthentikAuthorizationFlowSlug;
|
||||
var invalSlug = SelectedInvalidationFlow?.Slug ?? AuthentikInvalidationFlowSlug;
|
||||
var kpId = SelectedSigningKeypair?.Pk ?? AuthentikSigningKeypairId;
|
||||
|
||||
await svc.SetAsync(SettingsService.AuthentikAuthorizationFlowSlug, authSlug, SettingsService.CatAuthentik);
|
||||
await svc.SetAsync(SettingsService.AuthentikInvalidationFlowSlug, invalSlug, SettingsService.CatAuthentik);
|
||||
await svc.SetAsync(SettingsService.AuthentikSigningKeypairId, kpId, SettingsService.CatAuthentik);
|
||||
|
||||
AuthentikStatusMessage = $"Authentik connected. {AuthentikAuthorizationFlows.Count} flows, {AuthentikKeypairs.Count} keypair(s) loaded.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AuthentikStatusMessage = $"Error: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsAuthentikBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task FetchAuthentikDropdownsAsync()
|
||||
{
|
||||
IsAuthentikBusy = true;
|
||||
AuthentikStatusMessage = "Fetching flows and keypairs from Authentik...";
|
||||
try
|
||||
{
|
||||
await FetchAuthentikDropdownsInternalAsync();
|
||||
AuthentikStatusMessage = $"Loaded {AuthentikAuthorizationFlows.Count} flows, {AuthentikKeypairs.Count} keypair(s).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AuthentikStatusMessage = $"Error fetching data: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsAuthentikBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FetchAuthentikDropdownsInternalAsync(
|
||||
string? overrideUrl = null, string? overrideApiKey = null)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var authentik = scope.ServiceProvider.GetRequiredService<IAuthentikService>();
|
||||
|
||||
var flows = await authentik.ListFlowsAsync(overrideUrl, overrideApiKey);
|
||||
var keypairs = await authentik.ListKeypairsAsync(overrideUrl, overrideApiKey);
|
||||
|
||||
// Populate authorization flows (designation = "authorization")
|
||||
AuthentikAuthorizationFlows.Clear();
|
||||
foreach (var f in flows.Where(f => f.Designation == "authorization"))
|
||||
AuthentikAuthorizationFlows.Add(f);
|
||||
|
||||
// Populate invalidation flows (designation = "invalidation")
|
||||
AuthentikInvalidationFlows.Clear();
|
||||
foreach (var f in flows.Where(f => f.Designation == "invalidation"))
|
||||
AuthentikInvalidationFlows.Add(f);
|
||||
|
||||
// Populate keypairs
|
||||
AuthentikKeypairs.Clear();
|
||||
// Add a "None" option
|
||||
AuthentikKeypairs.Add(new AuthentikKeypairItem { Pk = "", Name = "(none)" });
|
||||
foreach (var k in keypairs)
|
||||
AuthentikKeypairs.Add(k);
|
||||
|
||||
// Select items matching saved slugs
|
||||
SelectedAuthorizationFlow = AuthentikAuthorizationFlows
|
||||
.FirstOrDefault(f => string.Equals(f.Slug, AuthentikAuthorizationFlowSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? AuthentikAuthorizationFlows.FirstOrDefault(f => f.Slug == "default-provider-authorization-implicit-consent")
|
||||
?? AuthentikAuthorizationFlows.FirstOrDefault();
|
||||
|
||||
SelectedInvalidationFlow = AuthentikInvalidationFlows
|
||||
.FirstOrDefault(f => string.Equals(f.Slug, AuthentikInvalidationFlowSlug, StringComparison.OrdinalIgnoreCase))
|
||||
?? AuthentikInvalidationFlows.FirstOrDefault(f => f.Slug == "default-provider-invalidation-flow")
|
||||
?? AuthentikInvalidationFlows.FirstOrDefault();
|
||||
|
||||
SelectedSigningKeypair = string.IsNullOrWhiteSpace(AuthentikSigningKeypairId)
|
||||
? AuthentikKeypairs.First() // "(none)"
|
||||
: AuthentikKeypairs.FirstOrDefault(k => k.Pk == AuthentikSigningKeypairId)
|
||||
?? AuthentikKeypairs.First();
|
||||
|
||||
// Update slug fields to match selection
|
||||
if (SelectedAuthorizationFlow != null)
|
||||
AuthentikAuthorizationFlowSlug = SelectedAuthorizationFlow.Slug;
|
||||
if (SelectedInvalidationFlow != null)
|
||||
AuthentikInvalidationFlowSlug = SelectedInvalidationFlow.Slug;
|
||||
if (SelectedSigningKeypair != null)
|
||||
AuthentikSigningKeypairId = SelectedSigningKeypair.Pk;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task TestXiboBootstrapAsync()
|
||||
{
|
||||
@@ -305,7 +499,7 @@ public partial class SettingsViewModel : ObservableObject
|
||||
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");
|
||||
var cmsServerTemplate = await svc.GetAsync(SettingsService.DefaultCmsServerNameTemplate, "app.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.";
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
<!-- Top toolbar -->
|
||||
<Border DockPanel.Dock="Top" Classes="toolbar" Margin="0,0,0,16">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Content="Save All Settings"
|
||||
<Button Content="Push to Bitwarden"
|
||||
Classes="accent"
|
||||
Command="{Binding SaveCommand}"
|
||||
Command="{Binding PushToBitwardenCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
FontWeight="SemiBold" Padding="20,8" />
|
||||
<Button Content="Reload" Command="{Binding LoadCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Content="Pull from Bitwarden"
|
||||
Command="{Binding PullFromBitwardenCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<TextBlock Text="{Binding StatusMessage}" Classes="status"
|
||||
VerticalAlignment="Center" Margin="6,0,0,0" />
|
||||
</StackPanel>
|
||||
@@ -72,10 +74,15 @@
|
||||
<TextBox Text="{Binding BitwardenInstanceProjectId}"
|
||||
Watermark="00000000-0000-0000-0000-000000000000 (leave empty to use default project)" />
|
||||
|
||||
<Button Content="Test Bitwarden Connection"
|
||||
Command="{Binding TestBitwardenConnectionCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
Margin="0,6,0,0" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="0,6,0,0">
|
||||
<Button Content="Save Bitwarden Config"
|
||||
Classes="accent"
|
||||
Command="{Binding SaveBitwardenLocalCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Content="Test Connection"
|
||||
Command="{Binding TestBitwardenConnectionCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -310,6 +317,88 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ═══ Authentik (SAML IdP) ═══ -->
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<Border Width="4" Height="20" CornerRadius="2" Background="#FB923C" />
|
||||
<TextBlock Text="Authentik (SAML IdP)" FontSize="16" FontWeight="SemiBold"
|
||||
Foreground="#FB923C" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Authentik identity provider settings. A SAML application is automatically provisioned in Authentik for each new instance during post-init, and a settings-custom.php file is deployed."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,6"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Text="Authentik Base URL" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding AuthentikUrl}"
|
||||
Watermark="https://id.oribi-tech.com" />
|
||||
|
||||
<TextBlock Text="API Token" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<TextBox Text="{Binding AuthentikApiKey}" PasswordChar="●"
|
||||
Watermark="Bearer token for /api/v3/" />
|
||||
|
||||
<!-- Save & Test button -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="0,8,0,0">
|
||||
<Button Content="Save & Test Connection"
|
||||
Classes="accent"
|
||||
Command="{Binding SaveAndTestAuthentikCommand}"
|
||||
IsEnabled="{Binding !IsAuthentikBusy}"
|
||||
FontWeight="SemiBold" Padding="16,8" />
|
||||
<Button Content="Refresh Dropdowns"
|
||||
Command="{Binding FetchAuthentikDropdownsCommand}"
|
||||
IsEnabled="{Binding !IsAuthentikBusy}"
|
||||
Padding="16,8" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding AuthentikStatusMessage}"
|
||||
FontSize="12" Foreground="{StaticResource TextSecondaryBrush}"
|
||||
TextWrapping="Wrap" Margin="0,2,0,0" />
|
||||
|
||||
<!-- Flow / Keypair dropdowns -->
|
||||
<Border Height="1" Background="{StaticResource BorderSubtleBrush}" Margin="0,12,0,4" />
|
||||
<TextBlock Text="Flows & Keypairs" FontSize="13" FontWeight="SemiBold" Margin="0,4,0,4"
|
||||
Foreground="{StaticResource TextPrimaryBrush}" />
|
||||
<TextBlock Text="These are loaded from your Authentik instance. Save & Test to populate."
|
||||
FontSize="12" Foreground="{StaticResource TextMutedBrush}" Margin="0,0,0,4"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Text="Authorization Flow" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<ComboBox ItemsSource="{Binding AuthentikAuthorizationFlows}"
|
||||
SelectedItem="{Binding SelectedAuthorizationFlow}"
|
||||
HorizontalAlignment="Stretch"
|
||||
PlaceholderText="(save & test to load flows)">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SettingsViewModel">
|
||||
<TextBlock Text="{Binding}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Text="Invalidation Flow" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<ComboBox ItemsSource="{Binding AuthentikInvalidationFlows}"
|
||||
SelectedItem="{Binding SelectedInvalidationFlow}"
|
||||
HorizontalAlignment="Stretch"
|
||||
PlaceholderText="(save & test to load flows)">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SettingsViewModel">
|
||||
<TextBlock Text="{Binding}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Text="Signing Keypair" FontSize="12" Foreground="{StaticResource TextSecondaryBrush}" />
|
||||
<ComboBox ItemsSource="{Binding AuthentikKeypairs}"
|
||||
SelectedItem="{Binding SelectedSigningKeypair}"
|
||||
HorizontalAlignment="Stretch"
|
||||
PlaceholderText="(save & test to load keypairs)">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SettingsViewModel">
|
||||
<TextBlock Text="{Binding}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel> <!-- end of IsBitwardenConfigured wrapper -->
|
||||
|
||||
</StackPanel>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"Default": "Data Source=otssigns-desktop.db"
|
||||
},
|
||||
"InstanceDefaults": {
|
||||
"CmsServerNameTemplate": "{abbrev}.ots-signs.com",
|
||||
"CmsServerNameTemplate": "app.ots-signs.com",
|
||||
"ThemeHostPath": "/cms/ots-theme",
|
||||
"LibraryShareSubPath": "{abbrev}-cms-library",
|
||||
"MySqlDatabaseTemplate": "{abbrev}_cms_db",
|
||||
|
||||
Reference in New Issue
Block a user