Files
Sharepoint-Toolbox/.planning/phases/11-html-export-branding/11-04-PLAN.md
2026-04-08 14:23:01 +02:00

22 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
11-html-export-branding 04 execute 1
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
true
BRAND-04
BRAND-05
truths artifacts key_links
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
path provides contains
SharepointToolbox/Services/ProfileService.cs UpdateProfileAsync method for persisting profile changes UpdateProfileAsync
path provides contains
SharepointToolbox/Services/IBrandingService.cs ImportLogoFromBytesAsync method declaration ImportLogoFromBytesAsync
path provides contains
SharepointToolbox/Services/BrandingService.cs ImportLogoFromBytesAsync implementation with magic byte validation ImportLogoFromBytesAsync
path provides contains
SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs MSP logo browse/clear commands BrowseMspLogoCommand
path provides contains
SharepointToolbox/ViewModels/ProfileManagementViewModel.cs Client logo browse/clear/auto-pull commands AutoPullClientLogoCommand
path provides min_lines
SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs Tests for MSP logo commands 40
path provides min_lines
SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs Tests for client logo commands and auto-pull 60
from to via pattern
SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs SharepointToolbox/Services/IBrandingService.cs constructor injection IBrandingService _brandingService
from to via pattern
SharepointToolbox/ViewModels/ProfileManagementViewModel.cs SharepointToolbox/Services/ProfileService.cs UpdateProfileAsync call _profileService.UpdateProfileAsync
from to via pattern
SharepointToolbox/ViewModels/ProfileManagementViewModel.cs Microsoft.Graph GraphClientFactory.CreateClientAsync 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.

<execution_context> @C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/dev/.claude/get-shit-done/templates/summary.md </execution_context>

@.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:

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:

public class ProfileService
{
    private readonly ProfileRepository _repository;
    public Task<IReadOnlyList<TenantProfile>> 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:

public partial class SettingsViewModel : FeatureViewModelBase
{
    private readonly SettingsService _settingsService;
    public RelayCommand BrowseFolderCommand { get; }
    public SettingsViewModel(SettingsService settingsService, ILogger<FeatureViewModelBase> logger)
    // Uses OpenFolderDialog in BrowseFolder() — same pattern for logo browse
}

From SharepointToolbox/ViewModels/ProfileManagementViewModel.cs:

public partial class ProfileManagementViewModel : ObservableObject
{
    private readonly ProfileService _profileService;
    private readonly ILogger<ProfileManagementViewModel> _logger;
    [ObservableProperty] private TenantProfile? _selectedProfile;
    [ObservableProperty] private string _validationMessage = string.Empty;
    public ProfileManagementViewModel(ProfileService profileService, ILogger<ProfileManagementViewModel> logger)
}

From SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs:

public class GraphClientFactory
{
    public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct);
}

Graph API for auto-pull (from RESEARCH):

// 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<LogoData> ImportLogoFromBytesAsync(byte[] bytes);
   ```

3. Implement in `BrandingService.cs`:
   ```csharp
   public Task<LogoData> 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<LogoData> 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<FeatureViewModelBase> 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<ProfileManagementViewModel> 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<FeatureViewModelBase>`
   - 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.

<success_criteria>

  • 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 </success_criteria>
After completion, create `.planning/phases/11-html-export-branding/11-04-SUMMARY.md`