--- phase: 11-html-export-branding plan: 04 type: execute wave: 1 depends_on: [] files_modified: - SharepointToolbox/Services/ProfileService.cs - SharepointToolbox/Services/IBrandingService.cs - SharepointToolbox/Services/BrandingService.cs - SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs - SharepointToolbox/ViewModels/ProfileManagementViewModel.cs - SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs - SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs - SharepointToolbox.Tests/Services/ProfileServiceTests.cs autonomous: true requirements: - BRAND-04 - BRAND-05 must_haves: truths: - "SettingsViewModel exposes BrowseMspLogoCommand and ClearMspLogoCommand that are exercisable without a View" - "ProfileManagementViewModel exposes BrowseClientLogoCommand, ClearClientLogoCommand, and AutoPullClientLogoCommand" - "ProfileService.UpdateProfileAsync persists changes to an existing profile (including ClientLogo)" - "AutoPullClientLogoCommand fetches squareLogo from Entra branding API and stores it as client logo" - "Auto-pull handles 404 (no Entra branding) gracefully with an informational message, no exception" - "BrandingService.ImportLogoFromBytesAsync validates and converts raw bytes to LogoData" artifacts: - path: "SharepointToolbox/Services/ProfileService.cs" provides: "UpdateProfileAsync method for persisting profile changes" contains: "UpdateProfileAsync" - path: "SharepointToolbox/Services/IBrandingService.cs" provides: "ImportLogoFromBytesAsync method declaration" contains: "ImportLogoFromBytesAsync" - path: "SharepointToolbox/Services/BrandingService.cs" provides: "ImportLogoFromBytesAsync implementation with magic byte validation" contains: "ImportLogoFromBytesAsync" - path: "SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs" provides: "MSP logo browse/clear commands" contains: "BrowseMspLogoCommand" - path: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs" provides: "Client logo browse/clear/auto-pull commands" contains: "AutoPullClientLogoCommand" - path: "SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs" provides: "Tests for MSP logo commands" min_lines: 40 - path: "SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs" provides: "Tests for client logo commands and auto-pull" min_lines: 60 key_links: - from: "SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs" to: "SharepointToolbox/Services/IBrandingService.cs" via: "constructor injection" pattern: "IBrandingService _brandingService" - from: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs" to: "SharepointToolbox/Services/ProfileService.cs" via: "UpdateProfileAsync call" pattern: "_profileService\\.UpdateProfileAsync" - from: "SharepointToolbox/ViewModels/ProfileManagementViewModel.cs" to: "Microsoft.Graph" via: "GraphClientFactory.CreateClientAsync" pattern: "Organization.*Branding.*SquareLogo" --- Add logo management commands to SettingsViewModel and ProfileManagementViewModel, add UpdateProfileAsync to ProfileService, add ImportLogoFromBytesAsync to BrandingService, and implement Entra branding auto-pull. Purpose: BRAND-05 requires MSP logo management from Settings; BRAND-04 requires client logo management including auto-pull from tenant's Entra branding API. All commands must be exercisable without opening any View (ViewModel-testable). Output: SettingsViewModel has browse/clear MSP logo commands, ProfileManagementViewModel has browse/clear/auto-pull client logo commands, ProfileService has UpdateProfileAsync, BrandingService has ImportLogoFromBytesAsync. All with unit tests. @C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/dev/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/phases/11-html-export-branding/11-CONTEXT.md @.planning/phases/11-html-export-branding/11-RESEARCH.md From SharepointToolbox/Services/IBrandingService.cs: ```csharp public interface IBrandingService { Task ImportLogoAsync(string filePath); Task SaveMspLogoAsync(LogoData logo); Task ClearMspLogoAsync(); Task GetMspLogoAsync(); } ``` From SharepointToolbox/Services/BrandingService.cs: ```csharp public class BrandingService : IBrandingService { private const int MaxSizeBytes = 512 * 1024; private static readonly byte[] PngMagic = { 0x89, 0x50, 0x4E, 0x47 }; private static readonly byte[] JpegMagic = { 0xFF, 0xD8, 0xFF }; private readonly BrandingRepository _repository; // ImportLogoAsync reads file, validates magic bytes, compresses if >512KB // DetectMimeType private static — validates PNG/JPG magic bytes // CompressToLimit private static — WPF PresentationCore imaging } ``` From SharepointToolbox/Services/ProfileService.cs: ```csharp public class ProfileService { private readonly ProfileRepository _repository; public Task> GetProfilesAsync(); public async Task AddProfileAsync(TenantProfile profile); public async Task RenameProfileAsync(string existingName, string newName); public async Task DeleteProfileAsync(string name); // NOTE: No UpdateProfileAsync yet — must be added } ``` From SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs: ```csharp public partial class SettingsViewModel : FeatureViewModelBase { private readonly SettingsService _settingsService; public RelayCommand BrowseFolderCommand { get; } public SettingsViewModel(SettingsService settingsService, ILogger logger) // Uses OpenFolderDialog in BrowseFolder() — same pattern for logo browse } ``` From SharepointToolbox/ViewModels/ProfileManagementViewModel.cs: ```csharp public partial class ProfileManagementViewModel : ObservableObject { private readonly ProfileService _profileService; private readonly ILogger _logger; [ObservableProperty] private TenantProfile? _selectedProfile; [ObservableProperty] private string _validationMessage = string.Empty; public ProfileManagementViewModel(ProfileService profileService, ILogger logger) } ``` From SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs: ```csharp public class GraphClientFactory { public async Task CreateClientAsync(string clientId, CancellationToken ct); } ``` Graph API for auto-pull (from RESEARCH): ```csharp // Endpoint: GET /organization/{orgId}/branding/localizations/default/squareLogo var orgs = await graphClient.Organization.GetAsync(); var orgId = orgs?.Value?.FirstOrDefault()?.Id; var stream = await graphClient.Organization[orgId] .Branding.Localizations["default"].SquareLogo.GetAsync(); // Returns: Stream (image bytes), 404 if no branding, empty body if logo not set ``` Task 1: Add UpdateProfileAsync to ProfileService and ImportLogoFromBytesAsync to BrandingService SharepointToolbox/Services/ProfileService.cs, SharepointToolbox/Services/IBrandingService.cs, SharepointToolbox/Services/BrandingService.cs, SharepointToolbox.Tests/Services/ProfileServiceTests.cs - Test 1: ProfileService.UpdateProfileAsync updates an existing profile and persists the change (round-trip through repository) - Test 2: ProfileService.UpdateProfileAsync throws KeyNotFoundException when profile name not found - Test 3: BrandingService.ImportLogoFromBytesAsync with valid PNG bytes returns LogoData with correct MimeType and Base64 - Test 4: BrandingService.ImportLogoFromBytesAsync with invalid bytes throws InvalidDataException 1. Add `UpdateProfileAsync` to `ProfileService.cs`: ```csharp public async Task UpdateProfileAsync(TenantProfile profile) { var profiles = (await _repository.LoadAsync()).ToList(); var idx = profiles.FindIndex(p => p.Name == profile.Name); if (idx < 0) throw new KeyNotFoundException($"Profile '{profile.Name}' not found."); profiles[idx] = profile; await _repository.SaveAsync(profiles); } ``` 2. Add `ImportLogoFromBytesAsync` to `IBrandingService.cs`: ```csharp Task ImportLogoFromBytesAsync(byte[] bytes); ``` 3. Implement in `BrandingService.cs`: ```csharp public Task ImportLogoFromBytesAsync(byte[] bytes) { var mimeType = DetectMimeType(bytes); if (bytes.Length > MaxSizeBytes) { bytes = CompressToLimit(bytes, mimeType, MaxSizeBytes); } return Task.FromResult(new LogoData { Base64 = Convert.ToBase64String(bytes), MimeType = mimeType }); } ``` This extracts the validation/compression logic that `ImportLogoAsync` also uses. Refactor `ImportLogoAsync` to delegate to `ImportLogoFromBytesAsync` after reading the file: ```csharp public async Task ImportLogoAsync(string filePath) { var bytes = await File.ReadAllBytesAsync(filePath); return await ImportLogoFromBytesAsync(bytes); } ``` 4. Extend `ProfileServiceTests.cs` (the file should already exist) with tests for `UpdateProfileAsync`. If it does not exist, create it following the same pattern as `BrandingRepositoryTests.cs` (IDisposable, temp file, real repository). 5. Add `ImportLogoFromBytesAsync` tests to existing `BrandingServiceTests.cs`. Create a valid PNG byte array (same technique as existing tests — 8-byte PNG signature + minimal IHDR/IEND) and verify the returned LogoData. Test invalid bytes throw `InvalidDataException`. dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~ProfileService|FullyQualifiedName~BrandingService" --no-build -q ProfileService has UpdateProfileAsync that persists profile changes. BrandingService has ImportLogoFromBytesAsync for raw byte validation. ImportLogoAsync delegates to ImportLogoFromBytesAsync. All tests pass. Task 2: Add MSP logo commands to SettingsViewModel and client logo commands to ProfileManagementViewModel SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs, SharepointToolbox/ViewModels/ProfileManagementViewModel.cs, SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs, SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs **SettingsViewModel modifications:** 1. Add `using SharepointToolbox.Services;` if not already present. Add `using Microsoft.Win32;` (already present). 2. Add field: `private readonly IBrandingService _brandingService;` 3. Add properties: ```csharp private string? _mspLogoPreview; public string? MspLogoPreview { get => _mspLogoPreview; private set { _mspLogoPreview = value; OnPropertyChanged(); } } ``` 4. Add commands: ```csharp public IAsyncRelayCommand BrowseMspLogoCommand { get; } public IAsyncRelayCommand ClearMspLogoCommand { get; } ``` 5. Modify constructor to accept `IBrandingService brandingService` and initialize: ```csharp 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); } ``` 6. Add `LoadAsync` extension — after loading settings, also load current MSP logo preview: ```csharp // At end of existing LoadAsync: var mspLogo = await _brandingService.GetMspLogoAsync(); MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null; ``` 7. Implement commands: ```csharp 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; } ``` **ProfileManagementViewModel modifications:** 1. Add fields: ```csharp private readonly IBrandingService _brandingService; private readonly Infrastructure.Auth.GraphClientFactory _graphClientFactory; ``` Add the type alias at the top of the file to avoid conflict with Microsoft.Graph.GraphClientFactory: ```csharp using AppGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory; ``` 2. Add commands: ```csharp public IAsyncRelayCommand BrowseClientLogoCommand { get; } public IAsyncRelayCommand ClearClientLogoCommand { get; } public IAsyncRelayCommand AutoPullClientLogoCommand { get; } ``` 3. Modify constructor: ```csharp 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); } ``` 4. Update `NotifyCommandsCanExecuteChanged` and add `OnSelectedProfileChanged`: ```csharp partial void OnSelectedProfileChanged(TenantProfile? value) { BrowseClientLogoCommand.NotifyCanExecuteChanged(); ClearClientLogoCommand.NotifyCanExecuteChanged(); AutoPullClientLogoCommand.NotifyCanExecuteChanged(); RenameCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged(); } ``` 5. Implement commands: ```csharp 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."); } } ``` Add required usings: `using System.IO;`, `using Microsoft.Win32;`, `using Microsoft.Graph.Models.ODataErrors;` **Tests:** 6. Create `SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs`: - Test that `BrowseMspLogoCommand` is not null after construction - Test that `ClearMspLogoCommand` is not null after construction - Test that `ClearMspLogoAsync` calls `IBrandingService.ClearMspLogoAsync` and sets `MspLogoPreview = null` - Use Moq to mock `IBrandingService` and `ILogger` - Cannot test `BrowseMspLogoAsync` fully (OpenFileDialog requires UI thread), but can test the command exists and ClearMspLogo path works 7. Create `SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs`: - Test that all 3 commands are not null after construction - Test `ClearClientLogoAsync`: mock ProfileService, set SelectedProfile, call command, verify ClientLogo is null and UpdateProfileAsync was called - Test `AutoPullClientLogoCommand` can execute check: false when SelectedProfile is null, true when set - Mock GraphClientFactory, IBrandingService, ProfileService, ILogger - Test auto-pull 404 handling: mock GraphServiceClient to throw ODataError with 404 status code, verify ValidationMessage is set and no exception propagates dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~SettingsViewModel|FullyQualifiedName~ProfileManagementViewModel" --no-build -q SettingsViewModel has BrowseMspLogoCommand and ClearMspLogoCommand. ProfileManagementViewModel has BrowseClientLogoCommand, ClearClientLogoCommand, and AutoPullClientLogoCommand. ProfileService.UpdateProfileAsync persists profile changes. All commands are exercisable without View. Auto-pull handles 404 gracefully. All tests pass. ```bash dotnet build --no-restore -warnaserror dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~SettingsViewModel|FullyQualifiedName~ProfileManagementViewModel|FullyQualifiedName~ProfileService|FullyQualifiedName~BrandingService" --no-build -q dotnet test SharepointToolbox.Tests --no-build -q ``` All three commands must pass with zero failures. - SettingsViewModel exposes BrowseMspLogoCommand and ClearMspLogoCommand (IAsyncRelayCommand) - ProfileManagementViewModel exposes BrowseClientLogoCommand, ClearClientLogoCommand, AutoPullClientLogoCommand - ProfileService.UpdateProfileAsync updates and persists existing profiles - BrandingService.ImportLogoFromBytesAsync validates raw bytes and returns LogoData - ImportLogoAsync delegates to ImportLogoFromBytesAsync (no code duplication) - Auto-pull uses squareLogo endpoint, handles 404 gracefully with user message - All commands exercisable without View (ViewModel-testable) - Full test suite passes with no regressions After completion, create `.planning/phases/11-html-export-branding/11-04-SUMMARY.md`