diff --git a/SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs b/SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs new file mode 100644 index 0000000..5f57a48 --- /dev/null +++ b/SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs @@ -0,0 +1,118 @@ +using System.IO; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SharepointToolbox.Core.Models; +using SharepointToolbox.Infrastructure.Auth; +using SharepointToolbox.Infrastructure.Persistence; +using SharepointToolbox.Services; +using SharepointToolbox.ViewModels; + +namespace SharepointToolbox.Tests.ViewModels; + +[Trait("Category", "Unit")] +public class ProfileManagementViewModelLogoTests : IDisposable +{ + private readonly string _tempFile; + private readonly Mock _mockBranding; + private readonly GraphClientFactory _graphClientFactory; + private readonly ILogger _logger; + + public ProfileManagementViewModelLogoTests() + { + _tempFile = Path.GetTempFileName(); + File.Delete(_tempFile); + _mockBranding = new Mock(); + _graphClientFactory = new GraphClientFactory(new MsalClientFactory()); + _logger = NullLogger.Instance; + } + + public void Dispose() + { + if (File.Exists(_tempFile)) File.Delete(_tempFile); + if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp"); + } + + private ProfileManagementViewModel CreateViewModel() + { + var profileService = new ProfileService(new ProfileRepository(_tempFile)); + return new ProfileManagementViewModel( + profileService, + _mockBranding.Object, + _graphClientFactory, + _logger); + } + + [Fact] + public void Constructor_BrowseClientLogoCommand_IsNotNull() + { + var vm = CreateViewModel(); + Assert.NotNull(vm.BrowseClientLogoCommand); + } + + [Fact] + public void Constructor_ClearClientLogoCommand_IsNotNull() + { + var vm = CreateViewModel(); + Assert.NotNull(vm.ClearClientLogoCommand); + } + + [Fact] + public void Constructor_AutoPullClientLogoCommand_IsNotNull() + { + var vm = CreateViewModel(); + Assert.NotNull(vm.AutoPullClientLogoCommand); + } + + [Fact] + public void BrowseClientLogoCommand_CannotExecute_WhenNoProfileSelected() + { + var vm = CreateViewModel(); + Assert.False(vm.BrowseClientLogoCommand.CanExecute(null)); + } + + [Fact] + public void ClearClientLogoCommand_CannotExecute_WhenNoProfileSelected() + { + var vm = CreateViewModel(); + Assert.False(vm.ClearClientLogoCommand.CanExecute(null)); + } + + [Fact] + public void AutoPullClientLogoCommand_CannotExecute_WhenNoProfileSelected() + { + var vm = CreateViewModel(); + Assert.False(vm.AutoPullClientLogoCommand.CanExecute(null)); + } + + [Fact] + public async Task ClearClientLogoCommand_ClearsClientLogo_AndPersists() + { + var profileService = new ProfileService(new ProfileRepository(_tempFile)); + var profile = new TenantProfile + { + Name = "TestTenant", + TenantUrl = "https://test.sharepoint.com", + ClientId = "00000000-0000-0000-0000-000000000001", + ClientLogo = new LogoData { Base64 = "dGVzdA==", MimeType = "image/png" } + }; + await profileService.AddProfileAsync(profile); + + var vm = new ProfileManagementViewModel( + profileService, + _mockBranding.Object, + _graphClientFactory, + _logger); + + vm.SelectedProfile = profile; + + await vm.ClearClientLogoCommand.ExecuteAsync(null); + + Assert.Null(profile.ClientLogo); + + // Verify persisted + var profiles = await profileService.GetProfilesAsync(); + var persisted = profiles.First(p => p.Name == "TestTenant"); + Assert.Null(persisted.ClientLogo); + } +} diff --git a/SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs b/SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs new file mode 100644 index 0000000..62090ea --- /dev/null +++ b/SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs @@ -0,0 +1,72 @@ +using System.IO; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using SharepointToolbox.Core.Models; +using SharepointToolbox.Infrastructure.Persistence; +using SharepointToolbox.Services; +using SharepointToolbox.ViewModels; +using SharepointToolbox.ViewModels.Tabs; + +namespace SharepointToolbox.Tests.ViewModels; + +[Trait("Category", "Unit")] +public class SettingsViewModelLogoTests : IDisposable +{ + private readonly string _tempFile; + + public SettingsViewModelLogoTests() + { + _tempFile = Path.GetTempFileName(); + File.Delete(_tempFile); + } + + public void Dispose() + { + if (File.Exists(_tempFile)) File.Delete(_tempFile); + if (File.Exists(_tempFile + ".tmp")) File.Delete(_tempFile + ".tmp"); + } + + private SettingsViewModel CreateViewModel(IBrandingService? brandingService = null) + { + var settingsService = new SettingsService(new SettingsRepository(_tempFile)); + var mockBranding = brandingService ?? new Mock().Object; + var logger = NullLogger.Instance; + return new SettingsViewModel(settingsService, mockBranding, logger); + } + + [Fact] + public void Constructor_BrowseMspLogoCommand_IsNotNull() + { + var vm = CreateViewModel(); + Assert.NotNull(vm.BrowseMspLogoCommand); + } + + [Fact] + public void Constructor_ClearMspLogoCommand_IsNotNull() + { + var vm = CreateViewModel(); + Assert.NotNull(vm.ClearMspLogoCommand); + } + + [Fact] + public void Constructor_MspLogoPreview_IsNullByDefault() + { + var vm = CreateViewModel(); + Assert.Null(vm.MspLogoPreview); + } + + [Fact] + public async Task ClearMspLogoCommand_CallsClearMspLogoAsync_AndSetsMspLogoPreviewToNull() + { + var mockBranding = new Mock(); + mockBranding.Setup(b => b.ClearMspLogoAsync()).Returns(Task.CompletedTask); + + var vm = CreateViewModel(mockBranding.Object); + + await vm.ClearMspLogoCommand.ExecuteAsync(null); + + mockBranding.Verify(b => b.ClearMspLogoAsync(), Times.Once); + Assert.Null(vm.MspLogoPreview); + } +} diff --git a/SharepointToolbox/App.xaml.cs b/SharepointToolbox/App.xaml.cs index 1557a06..bbe6dbb 100644 --- a/SharepointToolbox/App.xaml.cs +++ b/SharepointToolbox/App.xaml.cs @@ -89,6 +89,7 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // Phase 11-04: ProfileManagementViewModel and SettingsViewModel now receive IBrandingService and GraphClientFactory services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs b/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs index 79bd3c0..61ed552 100644 --- a/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs +++ b/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs @@ -1,15 +1,20 @@ using System.Collections.ObjectModel; +using System.IO; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; +using Microsoft.Win32; using SharepointToolbox.Core.Models; using SharepointToolbox.Services; +using AppGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory; namespace SharepointToolbox.ViewModels; public partial class ProfileManagementViewModel : ObservableObject { private readonly ProfileService _profileService; + private readonly IBrandingService _brandingService; + private readonly AppGraphClientFactory _graphClientFactory; private readonly ILogger _logger; [ObservableProperty] @@ -32,15 +37,27 @@ public partial class ProfileManagementViewModel : ObservableObject public IAsyncRelayCommand AddCommand { get; } public IAsyncRelayCommand RenameCommand { get; } public IAsyncRelayCommand DeleteCommand { get; } + public IAsyncRelayCommand BrowseClientLogoCommand { get; } + public IAsyncRelayCommand ClearClientLogoCommand { get; } + public IAsyncRelayCommand AutoPullClientLogoCommand { get; } - public ProfileManagementViewModel(ProfileService profileService, ILogger logger) + public ProfileManagementViewModel( + ProfileService profileService, + IBrandingService brandingService, + AppGraphClientFactory graphClientFactory, + ILogger logger) { _profileService = profileService; + _brandingService = brandingService; + _graphClientFactory = graphClientFactory; _logger = logger; AddCommand = new AsyncRelayCommand(AddAsync, CanAdd); RenameCommand = new AsyncRelayCommand(RenameAsync, () => SelectedProfile != null && !string.IsNullOrWhiteSpace(NewName)); DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedProfile != null); + BrowseClientLogoCommand = new AsyncRelayCommand(BrowseClientLogoAsync, () => SelectedProfile != null); + ClearClientLogoCommand = new AsyncRelayCommand(ClearClientLogoAsync, () => SelectedProfile != null); + AutoPullClientLogoCommand = new AsyncRelayCommand(AutoPullClientLogoAsync, () => SelectedProfile != null); } public async Task LoadAsync() @@ -62,6 +79,15 @@ public partial class ProfileManagementViewModel : ObservableObject partial void OnNewTenantUrlChanged(string value) => NotifyCommandsCanExecuteChanged(); partial void OnNewClientIdChanged(string value) => NotifyCommandsCanExecuteChanged(); + partial void OnSelectedProfileChanged(TenantProfile? value) + { + BrowseClientLogoCommand.NotifyCanExecuteChanged(); + ClearClientLogoCommand.NotifyCanExecuteChanged(); + AutoPullClientLogoCommand.NotifyCanExecuteChanged(); + RenameCommand.NotifyCanExecuteChanged(); + DeleteCommand.NotifyCanExecuteChanged(); + } + private void NotifyCommandsCanExecuteChanged() { AddCommand.NotifyCanExecuteChanged(); @@ -132,4 +158,88 @@ public partial class ProfileManagementViewModel : ObservableObject _logger.LogError(ex, "Failed to delete profile."); } } + + private async Task BrowseClientLogoAsync() + { + if (SelectedProfile == null) return; + var dialog = new OpenFileDialog + { + Title = "Select client logo", + Filter = "Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg", + }; + if (dialog.ShowDialog() != true) return; + try + { + var logo = await _brandingService.ImportLogoAsync(dialog.FileName); + SelectedProfile.ClientLogo = logo; + await _profileService.UpdateProfileAsync(SelectedProfile); + ValidationMessage = string.Empty; + } + catch (Exception ex) + { + ValidationMessage = ex.Message; + _logger.LogError(ex, "Failed to import client logo."); + } + } + + private async Task ClearClientLogoAsync() + { + if (SelectedProfile == null) return; + try + { + SelectedProfile.ClientLogo = null; + await _profileService.UpdateProfileAsync(SelectedProfile); + ValidationMessage = string.Empty; + } + catch (Exception ex) + { + ValidationMessage = ex.Message; + _logger.LogError(ex, "Failed to clear client logo."); + } + } + + private async Task AutoPullClientLogoAsync() + { + if (SelectedProfile == null) return; + try + { + var graphClient = await _graphClientFactory.CreateClientAsync( + SelectedProfile.ClientId, CancellationToken.None); + + var orgs = await graphClient.Organization.GetAsync(); + var orgId = orgs?.Value?.FirstOrDefault()?.Id; + if (orgId is null) + { + ValidationMessage = "Could not determine organization ID."; + return; + } + + var stream = await graphClient.Organization[orgId] + .Branding.Localizations["default"].SquareLogo.GetAsync(); + + if (stream is null || stream.Length == 0) + { + ValidationMessage = "No branding logo found for this tenant."; + return; + } + + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + var bytes = ms.ToArray(); + + var logo = await _brandingService.ImportLogoFromBytesAsync(bytes); + SelectedProfile.ClientLogo = logo; + await _profileService.UpdateProfileAsync(SelectedProfile); + ValidationMessage = "Client logo pulled from Entra branding."; + } + catch (Microsoft.Graph.Models.ODataErrors.ODataError ex) when (ex.ResponseStatusCode == 404) + { + ValidationMessage = "No Entra branding configured for this tenant."; + } + catch (Exception ex) + { + ValidationMessage = $"Failed to pull logo: {ex.Message}"; + _logger.LogWarning(ex, "Auto-pull client logo failed."); + } + } } diff --git a/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs b/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs index 350350e..c7e3d02 100644 --- a/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs @@ -11,6 +11,7 @@ namespace SharepointToolbox.ViewModels.Tabs; public partial class SettingsViewModel : FeatureViewModelBase { private readonly SettingsService _settingsService; + private readonly IBrandingService _brandingService; private string _selectedLanguage = "en"; public string SelectedLanguage @@ -38,13 +39,25 @@ public partial class SettingsViewModel : FeatureViewModelBase } } - public RelayCommand BrowseFolderCommand { get; } + private string? _mspLogoPreview; + public string? MspLogoPreview + { + get => _mspLogoPreview; + private set { _mspLogoPreview = value; OnPropertyChanged(); } + } - public SettingsViewModel(SettingsService settingsService, ILogger logger) + public RelayCommand BrowseFolderCommand { get; } + public IAsyncRelayCommand BrowseMspLogoCommand { get; } + public IAsyncRelayCommand ClearMspLogoCommand { get; } + + public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ILogger logger) : base(logger) { _settingsService = settingsService; + _brandingService = brandingService; BrowseFolderCommand = new RelayCommand(BrowseFolder); + BrowseMspLogoCommand = new AsyncRelayCommand(BrowseMspLogoAsync); + ClearMspLogoCommand = new AsyncRelayCommand(ClearMspLogoAsync); } public async Task LoadAsync() @@ -54,6 +67,9 @@ public partial class SettingsViewModel : FeatureViewModelBase _dataFolder = settings.DataFolder; OnPropertyChanged(nameof(SelectedLanguage)); OnPropertyChanged(nameof(DataFolder)); + + var mspLogo = await _brandingService.GetMspLogoAsync(); + MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null; } private async Task ApplyLanguageAsync(string code) @@ -86,6 +102,32 @@ public partial class SettingsViewModel : FeatureViewModelBase } } + private async Task BrowseMspLogoAsync() + { + var dialog = new OpenFileDialog + { + Title = "Select MSP logo", + Filter = "Image files (*.png;*.jpg;*.jpeg)|*.png;*.jpg;*.jpeg", + }; + if (dialog.ShowDialog() != true) return; + try + { + var logo = await _brandingService.ImportLogoAsync(dialog.FileName); + await _brandingService.SaveMspLogoAsync(logo); + MspLogoPreview = $"data:{logo.MimeType};base64,{logo.Base64}"; + } + catch (Exception ex) + { + StatusMessage = ex.Message; + } + } + + private async Task ClearMspLogoAsync() + { + await _brandingService.ClearMspLogoAsync(); + MspLogoPreview = null; + } + protected override Task RunOperationAsync(CancellationToken ct, IProgress progress) { // Settings tab has no long-running operation