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

29 KiB

Phase 11: HTML Export Branding + ViewModel Integration - Research

Researched: 2026-04-08 Domain: C#/.NET 10/WPF - HTML report branding, ViewModel commands, Microsoft Graph organizational branding API Confidence: HIGH

Summary

Phase 11 adds logo branding to all five HTML report types and provides ViewModel commands for managing MSP and client logos. The core infrastructure (LogoData, BrandingSettings, IBrandingService, TenantProfile.ClientLogo) was built in Phase 10 and is solid. This phase connects that infrastructure to the export pipeline and adds user-facing commands.

The main technical challenges are: (1) injecting a branding header into 5+2 StringBuilder-based HTML exporters without excessive duplication, (2) designing the branding flow from ViewModel through export service, and (3) implementing the Entra branding API auto-pull for client logos. All of these are straightforward given the existing patterns.

Primary recommendation: Create a static BrandingHtmlHelper class with a single BuildBrandingHeader(ReportBranding?) method that all exporters call. Add a ReportBranding record bundling MSP + client LogoData. Each export ViewModel already has _currentProfile (with ClientLogo) and can inject IBrandingService to get the MSP logo.

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

Decision Value
Logo storage format Base64 strings in JSON (not file paths)
MSP logo location BrandingSettings.MspLogo via BrandingRepository
Client logo location TenantProfile.ClientLogo (per-tenant, in profile JSON)
Logo model LogoData { string Base64, string MimeType } -- shared by both MSP and client logos
SVG support Rejected (XSS risk) -- PNG/JPG only
Export service signature change Optional ReportBranding? branding = null parameter on existing BuildHtml methods
No new interfaces No IHtmlExportService<T> -- keep concrete classes with optional branding param
Report header layout display: flex; gap: 16px -- MSP logo left, client logo right
Logo HTML format <img src="data:{MimeType};base64,{Base64}"> inline data-URI
No new NuGet packages All capabilities provided by existing stack

Claude's Discretion

None explicitly stated -- all key decisions are locked.

Deferred Ideas (OUT OF SCOPE)

  • Logo preview in Settings UI (Phase 12)
  • Live thumbnail preview after import (Phase 12)
  • "Pull from Entra" button in profile dialog UI (Phase 12)
  • User directory browse mode (Phase 13-14) </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
BRAND-05 All five HTML report types display MSP and client logos in a consistent header BrandingHtmlHelper pattern, ReportBranding model, BuildHtml signature changes, WriteAsync signature changes
BRAND-04 User can auto-pull client logo from tenant's Entra branding API Graph API endpoint research, squareLogo stream retrieval, 404 handling, ProfileService.UpdateProfileAsync
</phase_requirements>

Standard Stack

Core (already installed -- no new packages)

Library Version Purpose Why Standard
Microsoft.Graph 5.74.0 Entra branding API for auto-pull Already in project for user directory service
CommunityToolkit.Mvvm (project ver) AsyncRelayCommand, ObservableProperty Already used in all ViewModels
Microsoft.Win32 (WPF) built-in OpenFileDialog for logo browse Already used in SettingsViewModel.BrowseFolder

No New Dependencies

All required functionality is provided by the existing stack. The Graph SDK is already installed and authenticated via GraphClientFactory.

Architecture Patterns

SharepointToolbox/
  Core/Models/
    LogoData.cs                    # (exists) record { Base64, MimeType }
    BrandingSettings.cs            # (exists) { LogoData? MspLogo }
    TenantProfile.cs               # (exists) { LogoData? ClientLogo }
    ReportBranding.cs              # NEW - bundles MSP + client for export
  Services/
    IBrandingService.cs            # (exists) + no changes needed
    BrandingService.cs             # (exists) + no changes needed
    ProfileService.cs              # (exists) + add UpdateProfileAsync
    Export/
      BrandingHtmlHelper.cs        # NEW - shared branding header HTML builder
      HtmlExportService.cs         # MODIFY - add branding param to BuildHtml/WriteAsync
      SearchHtmlExportService.cs   # MODIFY - same
      StorageHtmlExportService.cs  # MODIFY - same
      DuplicatesHtmlExportService.cs # MODIFY - same
      UserAccessHtmlExportService.cs # MODIFY - same
  ViewModels/
    Tabs/SettingsViewModel.cs      # MODIFY - add MSP logo commands
    ProfileManagementViewModel.cs  # MODIFY - add client logo commands
    Tabs/PermissionsViewModel.cs   # MODIFY - pass branding to export
    Tabs/SearchViewModel.cs        # MODIFY - pass branding to export
    Tabs/StorageViewModel.cs       # MODIFY - pass branding to export
    Tabs/DuplicatesViewModel.cs    # MODIFY - pass branding to export
    Tabs/UserAccessAuditViewModel.cs # MODIFY - pass branding to export

Pattern 1: ReportBranding Record

What: A simple record that bundles both logos for passing to export services. When to use: Every time an export method is called. Example:

// Source: project convention (records for immutable DTOs)
namespace SharepointToolbox.Core.Models;

public record ReportBranding(LogoData? MspLogo, LogoData? ClientLogo);

Rationale: Export services should not know about IBrandingService or ProfileService. The ViewModel assembles branding from both sources and passes it as a simple DTO. This keeps export services pure (data in, HTML out).

Pattern 2: BrandingHtmlHelper (Static Helper)

What: A static class that generates the branding header HTML fragment. When to use: Called by each export service's BuildHtml method. Example:

// Source: project convention (static helpers for shared concerns)
namespace SharepointToolbox.Services.Export;

internal static class BrandingHtmlHelper
{
    /// <summary>
    /// Returns the branding header HTML (flex container with logo img tags),
    /// or empty string if no logos are configured.
    /// </summary>
    public static string BuildBrandingHeader(ReportBranding? branding)
    {
        if (branding is null) return string.Empty;

        var msp = branding.MspLogo;
        var client = branding.ClientLogo;

        if (msp is null && client is null) return string.Empty;

        var sb = new StringBuilder();
        sb.AppendLine("<div class=\"branding-header\" style=\"display:flex;gap:16px;align-items:center;padding:12px 24px;\">");

        if (msp is not null)
            sb.AppendLine($"  <img src=\"data:{msp.MimeType};base64,{msp.Base64}\" alt=\"MSP Logo\" style=\"max-height:60px;max-width:200px;object-fit:contain;\">");

        // Spacer pushes client logo to the right
        if (msp is not null && client is not null)
            sb.AppendLine("  <div style=\"flex:1\"></div>");

        if (client is not null)
            sb.AppendLine($"  <img src=\"data:{client.MimeType};base64,{client.Base64}\" alt=\"Client Logo\" style=\"max-height:60px;max-width:200px;object-fit:contain;\">");

        sb.AppendLine("</div>");
        return sb.ToString();
    }

    /// <summary>
    /// Returns CSS for the branding header to include in the style block.
    /// </summary>
    public static string BuildBrandingCss()
    {
        return ".branding-header { margin-bottom: 8px; }";
    }
}

Key design decisions:

  • max-height: 60px keeps logos reasonable in report headers
  • max-width: 200px prevents oversized logos from dominating
  • object-fit: contain preserves aspect ratio
  • Flex spacer pushes client logo to the right when both present
  • Returns empty string (not null) when no branding -- callers don't need null checks
  • Handles all 3 states: both logos, one only, none

Pattern 3: BuildHtml Signature Extension

What: Add optional ReportBranding? branding = null to all BuildHtml and WriteAsync methods. When to use: All 5 export service classes, all 7 WriteAsync overloads. Example:

// Before:
public string BuildHtml(IReadOnlyList<PermissionEntry> entries)

// After:
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null)

Injection point in each exporter:

// After: sb.AppendLine("<body>");
// Before: sb.AppendLine("<h1>...");
// Insert:
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));

Default null ensures backward compatibility -- existing callers without branding continue to work identically.

Pattern 4: ViewModel Branding Assembly

What: Export ViewModels assemble ReportBranding before calling export. When to use: In each export command handler (e.g., ExportHtmlAsync). Example:

// In PermissionsViewModel.ExportHtmlAsync:
private async Task ExportHtmlAsync()
{
    if (_htmlExportService == null || Results.Count == 0) return;
    // ... dialog code ...
    
    // Assemble branding from injected services
    var mspLogo = await _brandingService.GetMspLogoAsync();
    var clientLogo = _currentProfile?.ClientLogo;
    var branding = new ReportBranding(mspLogo, clientLogo);
    
    await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding);
}

Key insight: Each export ViewModel already has _currentProfile (set via TenantSwitchedMessage). It just needs IBrandingService injected for the MSP logo. No new service composition needed.

Pattern 5: SettingsViewModel Logo Commands

What: Browse/clear commands for MSP logo using existing patterns. When to use: SettingsViewModel only. Example:

// Following existing BrowseFolderCommand pattern (synchronous RelayCommand)
// But logo operations are async, so use AsyncRelayCommand

private readonly IBrandingService _brandingService;

// Properties for Phase 12 UI binding (just expose, no UI yet)
private string? _mspLogoPreview;
public string? MspLogoPreview
{
    get => _mspLogoPreview;
    private set { _mspLogoPreview = value; OnPropertyChanged(); }
}

public IAsyncRelayCommand BrowseMspLogoCommand { get; }
public IAsyncRelayCommand ClearMspLogoCommand { get; }

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;
}

Pattern 6: ProfileManagementViewModel Client Logo Commands

What: Browse/clear/auto-pull commands for client logo. When to use: ProfileManagementViewModel only. Key difference from MSP: Client logo is stored on TenantProfile.ClientLogo and persisted through ProfileService, not IBrandingService.

public IAsyncRelayCommand BrowseClientLogoCommand { get; }
public IAsyncRelayCommand ClearClientLogoCommand { get; }
public IAsyncRelayCommand AutoPullClientLogoCommand { get; }

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;

    var logo = await _brandingService.ImportLogoAsync(dialog.FileName);
    SelectedProfile.ClientLogo = logo;
    await _profileService.UpdateProfileAsync(SelectedProfile);
}

private async Task ClearClientLogoAsync()
{
    if (SelectedProfile == null) return;
    SelectedProfile.ClientLogo = null;
    await _profileService.UpdateProfileAsync(SelectedProfile);
}

Pattern 7: ProfileService.UpdateProfileAsync

What: New method to update an existing profile in the list and persist. When to use: When modifying a profile's ClientLogo. Rationale: ProfileService currently has Add/Rename/Delete but no Update. We need one for client logo changes.

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);
}

Anti-Patterns to Avoid

  • Injecting IBrandingService into export services: Export services should remain pure data-to-HTML transformers. Branding data flows in via ReportBranding parameter.
  • Creating a separate "branding provider" service: Unnecessary indirection. ViewModels already have both data sources (IBrandingService + _currentProfile).
  • Modifying existing method signatures non-optionally: Would break all existing callers and tests. Default null parameter preserves backward compatibility.
  • Duplicating branding HTML in each exporter: Use BrandingHtmlHelper to centralize the header generation.

Don't Hand-Roll

Problem Don't Build Use Instead Why
File dialog for logo selection Custom file picker Microsoft.Win32.OpenFileDialog WPF standard, already used in SettingsViewModel
Logo validation/compression Custom image processing IBrandingService.ImportLogoAsync Already validates PNG/JPG magic bytes and auto-compresses >512KB
HTML encoding in export helpers Manual string replacement Use existing HtmlEncode method in each service or System.Net.WebUtility.HtmlEncode XSS prevention
Graph API auth for Entra branding Manual HTTP + token GraphClientFactory.CreateClientAsync Already handles MSAL auth flow

Common Pitfalls

Pitfall 1: Broken Images When Logo Is Missing

What goes wrong: If branding header renders <img> tags for missing logos, the report shows broken image icons. Why it happens: Not checking for null LogoData before generating <img> tag. How to avoid: BrandingHtmlHelper.BuildBrandingHeader checks each logo for null individually. If both are null, returns empty string. No <img> tag is emitted without valid data. Warning signs: Visual broken-image icons in exported HTML when no logos configured.

Pitfall 2: WriteAsync Parameter Order Confusion

What goes wrong: Adding ReportBranding? parameter in wrong position causes ambiguity or breaks existing callers. Why it happens: Some WriteAsync overloads have different parameter counts already. How to avoid: Always add ReportBranding? branding = null as the LAST parameter before or after CancellationToken. Convention: place it after filePath and before CancellationToken for consistency, but since it's optional and CT is not, place after CT:

WriteAsync(data, filePath, CancellationToken, ReportBranding? branding = null)

This way existing callers pass positional args without change. Warning signs: Compiler errors in existing test files.

Pitfall 3: Graph API 404 for Unbranded Tenants

What goes wrong: Auto-pull throws unhandled exception when tenant has no Entra branding configured. Why it happens: Graph returns 404 when no branding exists, and ODataError when stream is not set (empty response body with 200). How to avoid: Wrap Graph call in try/catch for ServiceException/ODataError. On 404 or empty stream, return gracefully (null logo) instead of throwing. Log informational message. Warning signs: Unhandled exceptions in ProfileManagementViewModel when testing with tenants that have no branding.

Pitfall 4: Thread Affinity for OpenFileDialog

What goes wrong: OpenFileDialog.ShowDialog() called from non-UI thread throws. Why it happens: AsyncRelayCommand runs on thread pool by default. How to avoid: The dialog call itself is synchronous and runs before any await. In the CommunityToolkit.Mvvm pattern, AsyncRelayCommand invokes the delegate on the calling thread (UI thread for command binding). The dialog opens before any async work begins. This matches the existing BrowseFolderCommand pattern. Warning signs: InvalidOperationException at runtime.

Pitfall 5: StorageHtmlExportService Has Mutable State

What goes wrong: _togIdx instance field means the service is not stateless. Why it happens: StorageHtmlExportService uses _togIdx for collapsible row IDs and resets it in BuildHtml. How to avoid: When adding the branding parameter, don't change the _togIdx reset logic. The _togIdx = 0 at the start of each BuildHtml call handles this correctly. Warning signs: Duplicate HTML IDs in storage reports if reset is accidentally removed.

Code Examples

Complete BrandingHtmlHelper Implementation

// Source: derived from CONTEXT.md locked decisions
using System.Text;
using SharepointToolbox.Core.Models;

namespace SharepointToolbox.Services.Export;

internal static class BrandingHtmlHelper
{
    public static string BuildBrandingHeader(ReportBranding? branding)
    {
        if (branding is null) return string.Empty;

        var msp = branding.MspLogo;
        var client = branding.ClientLogo;

        if (msp is null && client is null) return string.Empty;

        var sb = new StringBuilder();
        sb.AppendLine("<div style=\"display:flex;gap:16px;align-items:center;padding:12px 24px 0;\">");

        if (msp is not null)
            sb.AppendLine($"  <img src=\"data:{msp.MimeType};base64,{msp.Base64}\" alt=\"\" style=\"max-height:60px;max-width:200px;object-fit:contain;\">");

        if (msp is not null && client is not null)
            sb.AppendLine("  <div style=\"flex:1\"></div>");

        if (client is not null)
            sb.AppendLine($"  <img src=\"data:{client.MimeType};base64,{client.Base64}\" alt=\"\" style=\"max-height:60px;max-width:200px;object-fit:contain;\">");

        sb.AppendLine("</div>");
        return sb.ToString();
    }
}
// Source: Microsoft Learn - GET organizationalBrandingLocalization bannerLogo
// Endpoint: GET /organization/{orgId}/branding/localizations/default/squareLogo
// Returns: Stream (image/*) or empty 200 when not set, 404 when no branding at all

private async Task AutoPullClientLogoAsync()
{
    if (SelectedProfile == null) return;
    try
    {
        var graphClient = await _graphClientFactory.CreateClientAsync(
            SelectedProfile.ClientId, CancellationToken.None);

        // Get organization ID first
        var orgs = await graphClient.Organization.GetAsync();
        var orgId = orgs?.Value?.FirstOrDefault()?.Id;
        if (orgId is null) { ValidationMessage = "Could not determine organization ID."; return; }

        // Fetch squareLogo stream
        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();

        // Detect MIME type via BrandingService (validates PNG/JPG)
        var logo = await _brandingService.ImportLogoFromBytesAsync(bytes);
        SelectedProfile.ClientLogo = logo;
        await _profileService.UpdateProfileAsync(SelectedProfile);
    }
    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.");
    }
}

ExportHtml with Branding Assembly

// Source: existing PermissionsViewModel.ExportHtmlAsync pattern
private async Task ExportHtmlAsync()
{
    if (_htmlExportService == null || Results.Count == 0) return;
    var dialog = new SaveFileDialog { /* existing dialog setup */ };
    if (dialog.ShowDialog() != true) return;
    try
    {
        // NEW: assemble branding
        var mspLogo = await _brandingService.GetMspLogoAsync();
        var clientLogo = _currentProfile?.ClientLogo;
        var branding = new ReportBranding(mspLogo, clientLogo);

        if (IsSimplifiedMode && SimplifiedResults.Count > 0)
            await _htmlExportService.WriteAsync(
                SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding);
        else
            await _htmlExportService.WriteAsync(
                Results, dialog.FileName, CancellationToken.None, branding);
        OpenFile(dialog.FileName);
    }
    catch (Exception ex)
    {
        StatusMessage = $"Export failed: {ex.Message}";
    }
}

Entra Branding API Details

Recommendation: Use squareLogo (Confidence: HIGH)

Logo Type Dimensions Use Case Suitability for Reports
bannerLogo Rectangle, ~280x36px Sign-in page top banner Too wide/thin for report headers
squareLogo Square, ~240x240px Sign-in page tile logo Good fit for report headers at 60px height
squareLogoDark Square Dark mode variant Not needed for HTML reports

The squareLogo is the company tile logo used in sign-in pages. It renders well at the 60px max-height used in report headers because it's square and high-resolution.

API Details

Property Value
HTTP endpoint GET /organization/{orgId}/branding/localizations/default/squareLogo
Graph SDK (C#) graphClient.Organization[orgId].Branding.Localizations["default"].SquareLogo.GetAsync()
Response type Stream (image bytes)
Content-Type image/* (PNG or other image format)
No branding configured 404 ODataError
Logo not set 200 with empty body
Permission (delegated) User.Read (least privileged) or Organization.Read.All
Permission (app) OrganizationalBranding.Read.All

Error Handling Strategy

// 404 = no branding configured at all -> inform user, not an error
// 200 empty = branding exists but no squareLogo set -> inform user
// Stream with data = success -> validate PNG/JPG, convert to LogoData

ImportLogoFromBytes Consideration

The existing BrandingService.ImportLogoAsync(string filePath) reads from file. For the Entra auto-pull, we receive bytes from a stream. Two options:

  1. Add ImportLogoFromBytesAsync(byte[] bytes) to IBrandingService -- cleaner, avoids temp file
  2. Write to temp file and call existing ImportLogoAsync -- wasteful

Recommendation: Add a new method ImportLogoFromBytesAsync(byte[] bytes) that extracts the validation/compression logic from ImportLogoAsync. The existing method can delegate to it after reading the file.

State of the Art

Old Approach Current Approach When Changed Impact
Graph SDK 4.x Organization.Branding Graph SDK 5.x Localizations["default"].SquareLogo SDK 5.0 (2023) Different fluent API path
OrganizationalBranding.Read.All required User.Read sufficient for delegated v1.0 current Lower permission bar

Open Questions

  1. Organization ID retrieval

    • What we know: Graph SDK requires org ID for the branding endpoint. GET /organization returns the tenant's organization list.
    • What's unclear: Whether the app already caches the org ID anywhere, or if we need a Graph call each time.
    • Recommendation: Call graphClient.Organization.GetAsync() and take Value[0].Id. Cache it per-session if performance is a concern, but for a one-time auto-pull operation, a single extra call is acceptable.
  2. MIME type detection from Graph stream

    • What we know: Graph returns image/* content-type. The actual bytes could be PNG, JPEG, or theoretically other formats.
    • What's unclear: Whether Graph always returns PNG for squareLogo or preserves original upload format.
    • Recommendation: Use the existing BrandingService magic-byte detection on the downloaded bytes. If it's not PNG/JPG, inform the user that the logo format is unsupported.

Validation Architecture

Test Framework

Property Value
Framework xUnit 2.9.3 + Moq 4.20.72
Config file SharepointToolbox.Tests/SharepointToolbox.Tests.csproj
Quick run command dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Export" --no-build -q
Full suite command dotnet test SharepointToolbox.Tests --no-build

Phase Requirements -> Test Map

Req ID Behavior Test Type Automated Command File Exists?
BRAND-05a BrandingHtmlHelper produces correct HTML for both logos unit dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~BrandingHtmlHelper" --no-build -q No - Wave 0
BRAND-05b BrandingHtmlHelper produces empty string for no logos unit same as above No - Wave 0
BRAND-05c BrandingHtmlHelper handles single logo (MSP only / client only) unit same as above No - Wave 0
BRAND-05d HtmlExportService.BuildHtml with branding includes header unit dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~HtmlExportServiceTests" --no-build -q Yes (extend)
BRAND-05e HtmlExportService.BuildHtml without branding unchanged unit same as above Yes (extend)
BRAND-05f Each of 5 exporters injects branding header between body and h1 unit dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Export" --no-build -q Partially (extend existing)
BRAND-04a Auto-pull handles 404 (no branding) gracefully unit dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~AutoPull" --no-build -q No - Wave 0
BRAND-04b Auto-pull handles empty stream gracefully unit same as above No - Wave 0

Sampling Rate

  • Per task commit: dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Export" --no-build -q
  • Per wave merge: dotnet test SharepointToolbox.Tests --no-build
  • Phase gate: Full suite green before /gsd:verify-work

Wave 0 Gaps

  • SharepointToolbox.Tests/Services/Export/BrandingHtmlHelperTests.cs -- covers BRAND-05a/b/c
  • SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs -- covers MSP logo commands
  • SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelLogoTests.cs -- covers client logo + auto-pull (BRAND-04)
  • Extend existing HtmlExportServiceTests.cs -- covers BRAND-05d/e
  • Extend existing SearchExportServiceTests.cs, StorageHtmlExportServiceTests.cs, DuplicatesHtmlExportServiceTests.cs, UserAccessHtmlExportServiceTests.cs -- covers BRAND-05f

Sources

Primary (HIGH confidence)

  • Project source code -- all Phase 10 infrastructure (LogoData, BrandingSettings, IBrandingService, BrandingService, TenantProfile, ProfileService, ProfileRepository)
  • Project source code -- all 5 HTML export services (HtmlExportService, SearchHtmlExportService, StorageHtmlExportService, DuplicatesHtmlExportService, UserAccessHtmlExportService)
  • Project source code -- ViewModels (SettingsViewModel, ProfileManagementViewModel, PermissionsViewModel, MainWindowViewModel, FeatureViewModelBase)
  • Microsoft Learn - Get organizationalBranding -- Entra branding API, permissions, 404 behavior, C# SDK snippets
  • Microsoft Learn - organizationalBrandingProperties -- squareLogo vs bannerLogo property descriptions

Secondary (MEDIUM confidence)

  • Graph SDK 5.74.0 fluent API path for branding localizations -- verified via official docs C# snippets

Metadata

Confidence breakdown:

  • Standard stack: HIGH - all libraries already in project, no new dependencies
  • Architecture: HIGH - patterns derived directly from existing codebase conventions
  • Pitfalls: HIGH - based on actual code inspection of all 5 exporters and ViewModels
  • Entra branding API: HIGH - verified via official Microsoft Learn documentation with C# code samples

Research date: 2026-04-08 Valid until: 2026-05-08 (stable -- Graph v1.0 API, no breaking changes expected)