docs(11): research html export branding and viewmodel integration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
585
.planning/phases/11-html-export-branding/11-RESEARCH.md
Normal file
585
.planning/phases/11-html-export-branding/11-RESEARCH.md
Normal file
@@ -0,0 +1,585 @@
|
||||
# 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
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
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:**
|
||||
```csharp
|
||||
// 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:**
|
||||
```csharp
|
||||
// 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:**
|
||||
```csharp
|
||||
// Before:
|
||||
public string BuildHtml(IReadOnlyList<PermissionEntry> entries)
|
||||
|
||||
// After:
|
||||
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null)
|
||||
```
|
||||
**Injection point in each exporter:**
|
||||
```csharp
|
||||
// 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:**
|
||||
```csharp
|
||||
// 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:**
|
||||
```csharp
|
||||
// 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`.
|
||||
|
||||
```csharp
|
||||
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.
|
||||
|
||||
```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);
|
||||
}
|
||||
```
|
||||
|
||||
### 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:
|
||||
```csharp
|
||||
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
|
||||
```csharp
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Entra Branding Auto-Pull (squareLogo)
|
||||
```csharp
|
||||
// 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
|
||||
```csharp
|
||||
// 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
|
||||
|
||||
### Endpoint Selection: squareLogo vs bannerLogo
|
||||
**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
|
||||
```csharp
|
||||
// 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](https://learn.microsoft.com/en-us/graph/api/organizationalbranding-get?view=graph-rest-1.0) -- Entra branding API, permissions, 404 behavior, C# SDK snippets
|
||||
- [Microsoft Learn - organizationalBrandingProperties](https://learn.microsoft.com/en-us/graph/api/resources/organizationalbrandingproperties?view=graph-rest-1.0) -- 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)
|
||||
Reference in New Issue
Block a user