From 9493bdb9df0a27ecd0fbffb218e048408be96eaa Mon Sep 17 00:00:00 2001 From: Matt Batchelder Date: Wed, 4 Mar 2026 21:33:29 -0500 Subject: [PATCH] feat: Implement Authentik group synchronization and add confirmation dialogs for service management --- .template-cache/2dc03e2b2b45fef3 | 2 +- .../Models/DTOs/AuthentikGroupItem.cs | 18 ++++ .../Services/AuthentikService.cs | 61 +++++++++++- .../Services/IAuthentikService.cs | 9 ++ .../Services/PostInstanceInitService.cs | 88 ++++++++++++++++++ .../Services/XiboApiService.cs | 60 ++++++++++++ .../ViewModels/InstanceDetailsViewModel.cs | 70 ++++++++++++++ .../ViewModels/InstancesViewModel.cs | 79 ++++++++++++++++ .../ViewModels/SettingsViewModel.cs | 1 + .../Views/ConfirmationDialog.axaml | 27 ++++++ .../Views/ConfirmationDialog.axaml.cs | 50 ++++++++++ .../Views/InstanceDetailsWindow.axaml | 52 ++++++++++- .../Views/InstanceDetailsWindow.axaml.cs | 11 +++ .../Views/InstancesView.axaml | 29 ++++-- .../Views/InstancesView.axaml.cs | 10 ++ otssigns-desktop.db-shm | Bin 32768 -> 0 bytes otssigns-desktop.db-wal | Bin 111272 -> 0 bytes templates/settings-custom.php.template | 4 +- templates/template.yml | 6 -- 19 files changed, 556 insertions(+), 21 deletions(-) create mode 100644 OTSSignsOrchestrator.Core/Models/DTOs/AuthentikGroupItem.cs create mode 100644 OTSSignsOrchestrator.Desktop/Views/ConfirmationDialog.axaml create mode 100644 OTSSignsOrchestrator.Desktop/Views/ConfirmationDialog.axaml.cs delete mode 100644 otssigns-desktop.db-shm delete mode 100644 otssigns-desktop.db-wal diff --git a/.template-cache/2dc03e2b2b45fef3 b/.template-cache/2dc03e2b2b45fef3 index eaf06cf..0cc0da3 160000 --- a/.template-cache/2dc03e2b2b45fef3 +++ b/.template-cache/2dc03e2b2b45fef3 @@ -1 +1 @@ -Subproject commit eaf06cf6247b58eb638f4d2ebc774f607b3d2fd7 +Subproject commit 0cc0da3c6e596f18c5697f2eb11450d72a18f7c3 diff --git a/OTSSignsOrchestrator.Core/Models/DTOs/AuthentikGroupItem.cs b/OTSSignsOrchestrator.Core/Models/DTOs/AuthentikGroupItem.cs new file mode 100644 index 0000000..6a9c94b --- /dev/null +++ b/OTSSignsOrchestrator.Core/Models/DTOs/AuthentikGroupItem.cs @@ -0,0 +1,18 @@ +namespace OTSSignsOrchestrator.Core.Models.DTOs; + +/// +/// Represents an Authentik group for display and sync operations. +/// +public class AuthentikGroupItem +{ + public string Pk { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public int MemberCount { get; set; } + + /// Display text for UI: "Group Name (N members)". + public string DisplayText => MemberCount > 0 + ? $"{Name} ({MemberCount} member{(MemberCount == 1 ? "" : "s")})" + : Name; + + public override string ToString() => DisplayText; +} diff --git a/OTSSignsOrchestrator.Core/Services/AuthentikService.cs b/OTSSignsOrchestrator.Core/Services/AuthentikService.cs index 8ea6b22..c92a111 100644 --- a/OTSSignsOrchestrator.Core/Services/AuthentikService.cs +++ b/OTSSignsOrchestrator.Core/Services/AuthentikService.cs @@ -95,7 +95,7 @@ public class AuthentikService : IAuthentikService } // ── 4. Create application linked to provider ────────────────── - await CreateApplicationAsync(client, baseUrl, instanceAbbrev, slug, providerId, ct); + await CreateApplicationAsync(client, baseUrl, instanceAbbrev, slug, providerId, instanceBaseUrl, ct); } // ── 5. Ensure provider has a signing keypair (required for metadata) ── @@ -175,6 +175,62 @@ public class AuthentikService : IAuthentikService }).OrderBy(k => k.Name).ToList() ?? new(); } + /// + public async Task> ListGroupsAsync( + string? overrideUrl = null, string? overrideApiKey = null, + CancellationToken ct = default) + { + var (_, client) = await CreateAuthenticatedClientAsync(overrideUrl, overrideApiKey); + + var groups = new List(); + var nextUrl = "/api/v3/core/groups/?page_size=200"; + + while (!string.IsNullOrEmpty(nextUrl)) + { + var resp = await client.GetAsync(nextUrl, ct); + resp.EnsureSuccessStatusCode(); + + var body = await resp.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + + if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array) + { + foreach (var g in results.EnumerateArray()) + { + var pk = g.TryGetProperty("pk", out var pkProp) ? pkProp.GetString() ?? "" : ""; + var name = g.TryGetProperty("name", out var nProp) ? nProp.GetString() ?? "" : ""; + var memberCount = g.TryGetProperty("users_obj", out var usersObj) && usersObj.ValueKind == JsonValueKind.Array + ? usersObj.GetArrayLength() + : (g.TryGetProperty("users", out var users) && users.ValueKind == JsonValueKind.Array + ? users.GetArrayLength() + : 0); + + // Skip Authentik built-in groups (authentik Admins, etc.) + if (!string.IsNullOrEmpty(name) && !name.StartsWith("authentik ", StringComparison.OrdinalIgnoreCase)) + { + groups.Add(new AuthentikGroupItem + { + Pk = pk, + Name = name, + MemberCount = memberCount, + }); + } + } + } + + // Handle pagination + nextUrl = root.TryGetProperty("pagination", out var pagination) && + pagination.TryGetProperty("next", out var nextProp) && + nextProp.ValueKind == JsonValueKind.Number + ? $"/api/v3/core/groups/?page_size=200&page={nextProp.GetInt32()}" + : null; + } + + _logger.LogInformation("[Authentik] Found {Count} group(s)", groups.Count); + return groups.OrderBy(g => g.Name).ToList(); + } + // ───────────────────────────────────────────────────────────────────────── // HTTP client setup // ───────────────────────────────────────────────────────────────────────── @@ -640,7 +696,7 @@ public class AuthentikService : IAuthentikService private async Task CreateApplicationAsync( HttpClient client, string baseUrl, string abbrev, string slug, int providerId, - CancellationToken ct) + string instanceBaseUrl, CancellationToken ct) { _logger.LogInformation("[Authentik] Creating application '{Slug}' linked to provider {ProviderId}", slug, providerId); @@ -649,6 +705,7 @@ public class AuthentikService : IAuthentikService ["name"] = $"OTS Signs — {abbrev.ToUpperInvariant()}", ["slug"] = slug, ["provider"] = providerId, + ["meta_launch_url"] = instanceBaseUrl.TrimEnd('/'), }; var jsonBody = JsonSerializer.Serialize(payload); diff --git a/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs b/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs index fbb84f7..f98fb1d 100644 --- a/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs +++ b/OTSSignsOrchestrator.Core/Services/IAuthentikService.cs @@ -42,4 +42,13 @@ public interface IAuthentikService string? overrideUrl = null, string? overrideApiKey = null, CancellationToken ct = default); + + /// + /// Returns all groups from Authentik, optionally filtered to those with + /// at least one member. Used for syncing groups to Xibo instances. + /// + Task> ListGroupsAsync( + string? overrideUrl = null, + string? overrideApiKey = null, + CancellationToken ct = default); } diff --git a/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs index eea95d2..1bf5d27 100644 --- a/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs +++ b/OTSSignsOrchestrator.Core/Services/PostInstanceInitService.cs @@ -460,6 +460,11 @@ public class PostInstanceInitService samlConfig != null ? $" (Authentik provider={samlConfig.ProviderId})" : " (without Authentik — needs manual IdP config)"); + + // ── 5. Sync Authentik groups to Xibo ────────────────────────────── + // Pre-create Authentik groups as Xibo user groups so they're available + // immediately (before any user logs in via SSO). + await SyncGroupsFromAuthentikAsync(abbrev, instanceUrl, settings, ct); } catch (Exception ex) { @@ -469,6 +474,89 @@ public class PostInstanceInitService } } + /// + /// Fetches all groups from Authentik and creates matching user groups in the + /// specified Xibo instance. Groups that already exist in Xibo are skipped. + /// This ensures that groups are available in Xibo for permission assignment + /// before any user logs in via SAML SSO. + /// + public async Task SyncGroupsFromAuthentikAsync( + string abbrev, + string instanceUrl, + SettingsService settings, + CancellationToken ct = default) + { + var synced = 0; + try + { + _logger.LogInformation("[GroupSync] Syncing Authentik groups to Xibo for {Abbrev}", abbrev); + + using var scope = _services.CreateScope(); + var authentik = scope.ServiceProvider.GetRequiredService(); + var xibo = scope.ServiceProvider.GetRequiredService(); + + // ── 1. Fetch groups from Authentik ──────────────────────────────── + var authentikGroups = await authentik.ListGroupsAsync(ct: ct); + if (authentikGroups.Count == 0) + { + _logger.LogInformation("[GroupSync] No groups found in Authentik — nothing to sync"); + return 0; + } + + _logger.LogInformation("[GroupSync] Found {Count} Authentik group(s) to sync", authentikGroups.Count); + + // ── 2. Authenticate to Xibo ─────────────────────────────────────── + var oauthClientId = await settings.GetAsync(SettingsService.InstanceOAuthClientId(abbrev)); + var oauthSecretId = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev)); + + if (string.IsNullOrWhiteSpace(oauthClientId) || string.IsNullOrWhiteSpace(oauthSecretId)) + { + _logger.LogWarning("[GroupSync] No OAuth credentials for {Abbrev} — cannot sync groups", abbrev); + return 0; + } + + var bws = scope.ServiceProvider.GetRequiredService(); + var oauthSecret = await bws.GetSecretAsync(oauthSecretId); + var accessToken = await xibo.LoginAsync(instanceUrl, oauthClientId, oauthSecret.Value); + + // ── 3. List existing Xibo groups ────────────────────────────────── + var existingGroups = await xibo.ListUserGroupsAsync(instanceUrl, accessToken); + var existingNames = new HashSet( + existingGroups.Select(g => g.Group), + StringComparer.OrdinalIgnoreCase); + + // ── 4. Create missing groups in Xibo ────────────────────────────── + foreach (var group in authentikGroups) + { + if (existingNames.Contains(group.Name)) + { + _logger.LogDebug("[GroupSync] Group '{Name}' already exists in Xibo", group.Name); + continue; + } + + try + { + var groupId = await xibo.CreateUserGroupAsync(instanceUrl, accessToken, group.Name); + _logger.LogInformation("[GroupSync] Created Xibo group '{Name}' (id={Id})", group.Name, groupId); + synced++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[GroupSync] Failed to create Xibo group '{Name}'", group.Name); + } + } + + _logger.LogInformation("[GroupSync] Sync complete for {Abbrev}: {Synced} group(s) created", abbrev, synced); + } + catch (Exception ex) + { + _logger.LogError(ex, "[GroupSync] Group sync failed for {Abbrev}: {Message}", abbrev, ex.Message); + // Don't rethrow — group sync failure should not block other operations + } + + return synced; + } + // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── diff --git a/OTSSignsOrchestrator.Core/Services/XiboApiService.cs b/OTSSignsOrchestrator.Core/Services/XiboApiService.cs index 5e0a078..b37cb10 100644 --- a/OTSSignsOrchestrator.Core/Services/XiboApiService.cs +++ b/OTSSignsOrchestrator.Core/Services/XiboApiService.cs @@ -321,6 +321,60 @@ public class XiboApiService // User groups // ───────────────────────────────────────────────────────────────────────── + /// + /// Lists all user groups in the Xibo instance and returns their names and IDs. + /// + public async Task> ListUserGroupsAsync( + string instanceUrl, + string accessToken) + { + var client = _httpClientFactory.CreateClient("XiboApi"); + var baseUrl = instanceUrl.TrimEnd('/'); + + SetBearer(client, accessToken); + + var response = await client.GetAsync($"{baseUrl}/api/group"); + await EnsureSuccessAsync(response, "list Xibo user groups"); + + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + var groups = new List(); + + foreach (var el in doc.RootElement.EnumerateArray()) + { + groups.Add(new XiboGroupInfo + { + GroupId = el.GetProperty("groupId").GetInt32(), + Group = el.GetProperty("group").GetString() ?? "", + }); + } + + return groups; + } + + /// + /// Finds an existing Xibo group by name or creates it if it doesn't exist. + /// Returns the group ID. + /// + public async Task GetOrCreateUserGroupAsync( + string instanceUrl, + string accessToken, + string groupName) + { + // Try to find existing group first + var existing = await ListUserGroupsAsync(instanceUrl, accessToken); + var match = existing.FirstOrDefault(g => + string.Equals(g.Group, groupName, StringComparison.OrdinalIgnoreCase)); + + if (match != null) + { + _logger.LogDebug("Xibo group '{Name}' already exists (id={Id})", groupName, match.GroupId); + return match.GroupId; + } + + // Create new group + return await CreateUserGroupAsync(instanceUrl, accessToken, groupName); + } + /// /// Creates a new user group and returns its numeric group ID. /// @@ -529,6 +583,12 @@ public class XiboTestResult public int HttpStatus { get; set; } } +public class XiboGroupInfo +{ + public int GroupId { get; set; } + public string Group { get; set; } = string.Empty; +} + public class XiboAuthException : Exception { public int HttpStatus { get; } diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs index 32955a1..e85c59b 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/InstanceDetailsViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.DependencyInjection; +using OTSSignsOrchestrator.Core.Models.DTOs; using OTSSignsOrchestrator.Core.Models.Entities; using OTSSignsOrchestrator.Core.Services; using OTSSignsOrchestrator.Desktop.Models; @@ -49,7 +50,15 @@ public partial class InstanceDetailsViewModel : ObservableObject [ObservableProperty] private bool _isPendingSetup; [ObservableProperty] private string _initClientId = string.Empty; [ObservableProperty] private string _initClientSecret = string.Empty; + // ── Services (for restart) ───────────────────────────────────────────────────── + [ObservableProperty] private ObservableCollection _stackServices = new(); + [ObservableProperty] private bool _isLoadingServices; + /// + /// Callback the View wires up to show a confirmation dialog. + /// Parameters: (title, message) → returns true if the user confirmed. + /// + public Func>? ConfirmAsync { get; set; } // Cached instance — needed by InitializeCommand to reload after setup private LiveStackItem? _currentInstance; public InstanceDetailsViewModel(IServiceProvider services) @@ -111,6 +120,9 @@ public partial class InstanceDetailsViewModel : ObservableObject InitClientId = string.Empty; InitClientSecret = string.Empty; } + + // ── Load stack services ─────────────────────────────────────── + await LoadServicesAsync(); } catch (Exception ex) { @@ -213,6 +225,64 @@ public partial class InstanceDetailsViewModel : ObservableObject // Rotation // ───────────────────────────────────────────────────────────────────────── + [RelayCommand] + private async Task RestartServiceAsync(ServiceInfo? service) + { + if (service is null || _currentInstance is null) return; + + if (ConfirmAsync is not null) + { + var confirmed = await ConfirmAsync( + "Restart Service", + $"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated."); + if (!confirmed) return; + } + + IsBusy = true; + StatusMessage = $"Restarting service '{service.Name}'..."; + try + { + var dockerCli = _services.GetRequiredService(); + var ok = await dockerCli.ForceUpdateServiceAsync(service.Name); + StatusMessage = ok + ? $"Service '{service.Name}' restarted successfully." + : $"Failed to restart service '{service.Name}'."; + + // Refresh service list to show updated replica status + await LoadServicesAsync(); + } + catch (Exception ex) + { + StatusMessage = $"Error restarting service: {ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + private async Task LoadServicesAsync() + { + if (_currentInstance is null) return; + + IsLoadingServices = true; + try + { + var dockerCli = _services.GetRequiredService(); + dockerCli.SetHost(_currentInstance.Host); + var services = await dockerCli.InspectStackServicesAsync(_currentInstance.StackName); + StackServices = new ObservableCollection(services); + } + catch (Exception ex) + { + StatusMessage = $"Error loading services: {ex.Message}"; + } + finally + { + IsLoadingServices = false; + } + } + [RelayCommand] private async Task RotateAdminPasswordAsync() { diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs index 19dc11e..46a739d 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/InstancesViewModel.cs @@ -48,6 +48,12 @@ public partial class InstancesViewModel : ObservableObject /// Raised when the instance details modal should be opened for the given ViewModel. public event Action? OpenDetailsRequested; + /// + /// Callback the View wires up to show a confirmation dialog. + /// Parameters: (title, message) → returns true if the user confirmed. + /// + public Func>? ConfirmAsync { get; set; } + private string? _pendingSelectAbbrev; public InstancesViewModel(IServiceProvider services) @@ -245,6 +251,79 @@ public partial class InstancesViewModel : ObservableObject _logRefreshTimer = null; } + // ── Restart Commands ──────────────────────────────────────────────── + + [RelayCommand] + private async Task RestartStackAsync() + { + if (SelectedInstance == null) return; + + if (ConfirmAsync is not null) + { + var confirmed = await ConfirmAsync( + "Restart Stack", + $"Are you sure you want to restart all services in '{SelectedInstance.StackName}'?\n\nThis will force-update every service in the stack, causing brief downtime."); + if (!confirmed) return; + } + + IsBusy = true; + StatusMessage = $"Restarting all services in '{SelectedInstance.StackName}'..."; + try + { + var dockerCli = _services.GetRequiredService(); + dockerCli.SetHost(SelectedInstance.Host); + + var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName); + var failures = new List(); + + for (var i = 0; i < services.Count; i++) + { + var svc = services[i]; + StatusMessage = $"Restarting service {i + 1}/{services.Count}: {svc.Name}..."; + var ok = await dockerCli.ForceUpdateServiceAsync(svc.Name); + if (!ok) failures.Add(svc.Name); + } + + StatusMessage = failures.Count == 0 + ? $"All {services.Count} service(s) in '{SelectedInstance.StackName}' restarted successfully." + : $"Restarted with errors — failed services: {string.Join(", ", failures)}"; + } + catch (Exception ex) { StatusMessage = $"Error restarting stack: {ex.Message}"; } + finally { IsBusy = false; } + } + + [RelayCommand] + private async Task RestartServiceAsync(ServiceInfo? service) + { + if (service is null || SelectedInstance is null) return; + + if (ConfirmAsync is not null) + { + var confirmed = await ConfirmAsync( + "Restart Service", + $"Are you sure you want to restart '{service.Name}'?\n\nThis will force-update the service, causing its tasks to be recreated."); + if (!confirmed) return; + } + + IsBusy = true; + StatusMessage = $"Restarting service '{service.Name}'..."; + try + { + var dockerCli = _services.GetRequiredService(); + dockerCli.SetHost(SelectedInstance.Host); + var ok = await dockerCli.ForceUpdateServiceAsync(service.Name); + StatusMessage = ok + ? $"Service '{service.Name}' restarted successfully." + : $"Failed to restart service '{service.Name}'."; + + // Refresh services to show updated replica status + var services = await dockerCli.InspectStackServicesAsync(SelectedInstance.StackName); + SelectedServices = new ObservableCollection(services); + } + catch (Exception ex) { StatusMessage = $"Error restarting service: {ex.Message}"; } + finally { IsBusy = false; } + } + [RelayCommand] private async Task DeleteInstanceAsync() { diff --git a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs index e8add32..2ea0d1b 100644 --- a/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs +++ b/OTSSignsOrchestrator.Desktop/ViewModels/SettingsViewModel.cs @@ -135,6 +135,7 @@ public partial class SettingsViewModel : ObservableObject // ── Load all other settings from Bitwarden ── using var scope = _services.CreateScope(); var svc = scope.ServiceProvider.GetRequiredService(); + svc.InvalidateCache(); // Git GitRepoUrl = await svc.GetAsync(SettingsService.GitRepoUrl, string.Empty); diff --git a/OTSSignsOrchestrator.Desktop/Views/ConfirmationDialog.axaml b/OTSSignsOrchestrator.Desktop/Views/ConfirmationDialog.axaml new file mode 100644 index 0000000..3590d0a --- /dev/null +++ b/OTSSignsOrchestrator.Desktop/Views/ConfirmationDialog.axaml @@ -0,0 +1,27 @@ + + + + + +