From 1ab2f2e42636c07fe3335d282de9ac03c111f945 Mon Sep 17 00:00:00 2001 From: Dev Date: Wed, 8 Apr 2026 14:23:01 +0200 Subject: [PATCH] docs(11): create phase plan for HTML export branding and ViewModel integration Co-Authored-By: Claude Opus 4.6 (1M context) --- .planning/ROADMAP.md | 17 +- .../11-html-export-branding/11-01-PLAN.md | 209 ++++++++ .../11-html-export-branding/11-02-PLAN.md | 308 +++++++++++ .../11-html-export-branding/11-03-PLAN.md | 219 ++++++++ .../11-html-export-branding/11-04-PLAN.md | 506 ++++++++++++++++++ 5 files changed, 1253 insertions(+), 6 deletions(-) create mode 100644 .planning/phases/11-html-export-branding/11-01-PLAN.md create mode 100644 .planning/phases/11-html-export-branding/11-02-PLAN.md create mode 100644 .planning/phases/11-html-export-branding/11-03-PLAN.md create mode 100644 .planning/phases/11-html-export-branding/11-04-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 12934eb..90481bf 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -50,9 +50,9 @@ 4. `GraphUserDirectoryService.GetUsersAsync` returns all enabled member users for a tenant, following `@odata.nextLink` until exhausted, without truncating at 999 **Plans**: 3 plans Plans: -- [ ] 10-01-PLAN.md — Logo models, BrandingRepository, BrandingService with validation/compression -- [ ] 10-02-PLAN.md — GraphUserDirectoryService with PageIterator pagination -- [ ] 10-03-PLAN.md — DI registration in App.xaml.cs and full test suite gate +- [x] 10-01-PLAN.md — Logo models, BrandingRepository, BrandingService with validation/compression +- [x] 10-02-PLAN.md — GraphUserDirectoryService with PageIterator pagination +- [x] 10-03-PLAN.md — DI registration in App.xaml.cs and full test suite gate ### Phase 11: HTML Export Branding + ViewModel Integration **Goal**: All five HTML reports display MSP and client logos in a consistent header, and administrators can manage logos from Settings and the profile dialog without touching the View layer. @@ -64,7 +64,12 @@ Plans: 3. When no logo is configured, the HTML export header contains no broken image placeholder and the report renders identically to the pre-branding output 4. SettingsViewModel exposes browse/clear commands for MSP logo; ProfileManagementViewModel exposes browse/clear commands for client logo — both commands are exercisable without opening any View 5. Auto-pulling the client logo from the tenant's Entra branding API stores the logo in the tenant profile and falls back silently when no Entra branding is configured -**Plans**: TBD +**Plans**: 4 plans +Plans: +- [ ] 11-01-PLAN.md — ReportBranding model + BrandingHtmlHelper static class with unit tests +- [ ] 11-02-PLAN.md — Add optional branding param to all 5 HTML export services +- [ ] 11-03-PLAN.md — Wire IBrandingService into all 5 export ViewModels +- [ ] 11-04-PLAN.md — Logo management commands (Settings + Profile) and Entra auto-pull ### Phase 12: Branding UI Views **Goal**: Administrators can see, import, preview, and clear logos directly in the Settings and profile management dialogs. @@ -105,8 +110,8 @@ Plans: |-------|-----------|-------|--------|-----------| | 1-5 | v1.0 | 36/36 | Shipped | 2026-04-07 | | 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 | -| 10. Branding Data Foundation | 3/3 | Complete | 2026-04-08 | — | -| 11. HTML Export Branding + ViewModel Integration | v2.2 | 0/? | Not started | — | +| 10. Branding Data Foundation | v2.2 | 3/3 | Complete | 2026-04-08 | +| 11. HTML Export Branding + ViewModel Integration | v2.2 | 0/4 | Planning complete | — | | 12. Branding UI Views | v2.2 | 0/? | Not started | — | | 13. User Directory ViewModel | v2.2 | 0/? | Not started | — | | 14. User Directory View | v2.2 | 0/? | Not started | — | diff --git a/.planning/phases/11-html-export-branding/11-01-PLAN.md b/.planning/phases/11-html-export-branding/11-01-PLAN.md new file mode 100644 index 0000000..52102e0 --- /dev/null +++ b/.planning/phases/11-html-export-branding/11-01-PLAN.md @@ -0,0 +1,209 @@ +--- +phase: 11-html-export-branding +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - SharepointToolbox/Core/Models/ReportBranding.cs + - SharepointToolbox/Services/Export/BrandingHtmlHelper.cs + - SharepointToolbox.Tests/Services/Export/BrandingHtmlHelperTests.cs +autonomous: true +requirements: + - BRAND-05 + +must_haves: + truths: + - "BrandingHtmlHelper.BuildBrandingHeader returns a div with two img tags when both MSP and client logos are provided" + - "BrandingHtmlHelper.BuildBrandingHeader returns a div with one img tag when only MSP or only client logo is provided" + - "BrandingHtmlHelper.BuildBrandingHeader returns empty string when branding is null or both logos are null" + - "ReportBranding record bundles MspLogo and ClientLogo as nullable LogoData properties" + artifacts: + - path: "SharepointToolbox/Core/Models/ReportBranding.cs" + provides: "Immutable DTO bundling MSP and client logos for export pipeline" + contains: "record ReportBranding" + - path: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs" + provides: "Static helper generating branding header HTML fragment" + contains: "BuildBrandingHeader" + - path: "SharepointToolbox.Tests/Services/Export/BrandingHtmlHelperTests.cs" + provides: "Unit tests covering all 4 branding states" + min_lines: 50 + key_links: + - from: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs" + to: "SharepointToolbox/Core/Models/ReportBranding.cs" + via: "parameter type" + pattern: "ReportBranding\\?" + - from: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs" + to: "SharepointToolbox/Core/Models/LogoData.cs" + via: "property access" + pattern: "MimeType.*Base64" +--- + + +Create the ReportBranding model and BrandingHtmlHelper static class that all HTML exporters will call to render the branding header. + +Purpose: Centralizes the branding header HTML generation so all 5 exporters share identical markup. This is the foundation artifact that Plan 02 depends on. + +Output: ReportBranding record, BrandingHtmlHelper static class, and comprehensive unit tests covering all logo combination states. + + + +@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/STATE.md +@.planning/phases/11-html-export-branding/11-CONTEXT.md +@.planning/phases/11-html-export-branding/11-RESEARCH.md + + + + +From SharepointToolbox/Core/Models/LogoData.cs: +```csharp +namespace SharepointToolbox.Core.Models; + +public record LogoData +{ + public string Base64 { get; init; } = string.Empty; + public string MimeType { get; init; } = string.Empty; +} +``` + +From SharepointToolbox/Core/Models/BrandingSettings.cs: +```csharp +namespace SharepointToolbox.Core.Models; + +public class BrandingSettings +{ + public LogoData? MspLogo { get; set; } +} +``` + +From SharepointToolbox/Core/Models/TenantProfile.cs (relevant property): +```csharp +public LogoData? ClientLogo { get; set; } +``` + + + + + + + Task 1: Create ReportBranding record and BrandingHtmlHelper with tests + + SharepointToolbox/Core/Models/ReportBranding.cs, + SharepointToolbox/Services/Export/BrandingHtmlHelper.cs, + SharepointToolbox.Tests/Services/Export/BrandingHtmlHelperTests.cs + + + - Test 1: BuildBrandingHeader with null ReportBranding returns empty string + - Test 2: BuildBrandingHeader with both logos null returns empty string + - Test 3: BuildBrandingHeader with only MspLogo returns HTML with one img tag containing MSP base64 data-URI, no second img + - Test 4: BuildBrandingHeader with only ClientLogo returns HTML with one img tag containing client base64 data-URI, no flex spacer div + - Test 5: BuildBrandingHeader with both logos returns HTML with two img tags and a flex spacer div between them + - Test 6: All generated img tags use inline data-URI format: src="data:{MimeType};base64,{Base64}" + - Test 7: All generated img tags have max-height:60px and max-width:200px styles + - Test 8: The outer div uses display:flex;gap:16px;align-items:center styling + + + 1. Create `SharepointToolbox/Core/Models/ReportBranding.cs`: + ```csharp + namespace SharepointToolbox.Core.Models; + + /// + /// Bundles MSP and client logos for passing to export services. + /// Export services receive this as a simple DTO — they don't know + /// about IBrandingService or ProfileService. + /// + public record ReportBranding(LogoData? MspLogo, LogoData? ClientLogo); + ``` + This is a positional record (OK because it is never deserialized from JSON — it is always constructed in code). + + 2. Create `SharepointToolbox/Services/Export/BrandingHtmlHelper.cs`: + ```csharp + using System.Text; + using SharepointToolbox.Core.Models; + + namespace SharepointToolbox.Services.Export; + + /// + /// Generates the branding header HTML fragment for HTML reports. + /// Called by each HTML export service between <body> and <h1>. + /// Returns empty string when no logos are configured (no broken images). + /// + 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("
"); + + if (msp is not null) + sb.AppendLine($" \"\""); + + if (msp is not null && client is not null) + sb.AppendLine("
"); + + if (client is not null) + sb.AppendLine($" \"\""); + + sb.AppendLine("
"); + return sb.ToString(); + } + } + ``` + Key decisions per CONTEXT.md locked decisions: + - `display:flex;gap:16px` layout (MSP left, client right) + - `` inline data-URI format + - `max-height:60px` keeps logos reasonable + - Returns empty string (not null) when no branding — callers need no null checks + - `alt=""` (decorative image — not essential content) + - Class is `internal` — only used within Services.Export namespace. Tests access via `InternalsVisibleTo`. + + 3. Create `SharepointToolbox.Tests/Services/Export/BrandingHtmlHelperTests.cs`: + Write tests FIRST (RED phase), then verify the implementation makes them GREEN. + Use `[Trait("Category", "Unit")]` per project convention. + Create helper method `MakeLogo(string mime = "image/png", string base64 = "dGVzdA==")` to build test LogoData instances. + Tests must assert exact HTML structure: data-URI format, style attributes, flex spacer presence/absence. + + 4. Verify the test project has `InternalsVisibleTo` for the helper class. Check `SharepointToolbox.csproj` or `AssemblyInfo.cs` for `[assembly: InternalsVisibleTo("SharepointToolbox.Tests")]`. If missing, add `` inside an `` in `SharepointToolbox.csproj`. +
+ + dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~BrandingHtmlHelperTests" --no-build -q + + ReportBranding record exists in Core/Models. BrandingHtmlHelper generates correct HTML for all 4 states (null branding, both null, single logo, both logos). All tests pass. Build succeeds with no warnings. +
+ +
+ + +```bash +dotnet build --no-restore -warnaserror +dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~BrandingHtmlHelper" --no-build -q +``` +Both commands must pass with zero failures. + + + +- ReportBranding record exists at Core/Models/ReportBranding.cs as a positional record with two nullable LogoData params +- BrandingHtmlHelper.BuildBrandingHeader handles all 4 states correctly (null branding, both null, single, both) +- Generated HTML uses data-URI format, flex layout, 60px max-height per locked decisions +- No broken image tags when logos are missing +- Tests cover all states with assertions on HTML structure +- Build passes with zero warnings + + + +After completion, create `.planning/phases/11-html-export-branding/11-01-SUMMARY.md` + diff --git a/.planning/phases/11-html-export-branding/11-02-PLAN.md b/.planning/phases/11-html-export-branding/11-02-PLAN.md new file mode 100644 index 0000000..9fb2c22 --- /dev/null +++ b/.planning/phases/11-html-export-branding/11-02-PLAN.md @@ -0,0 +1,308 @@ +--- +phase: 11-html-export-branding +plan: 02 +type: execute +wave: 2 +depends_on: ["11-01"] +files_modified: + - SharepointToolbox/Services/Export/HtmlExportService.cs + - SharepointToolbox/Services/Export/SearchHtmlExportService.cs + - SharepointToolbox/Services/Export/StorageHtmlExportService.cs + - SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs + - SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs + - SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs + - SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs + - SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs + - SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs + - SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs +autonomous: true +requirements: + - BRAND-05 + +must_haves: + truths: + - "Each of the 5 HTML exporters accepts an optional ReportBranding? branding = null parameter on BuildHtml and WriteAsync" + - "When branding is provided with logos, the exported HTML contains the branding header div between body and h1" + - "When branding is null or has no logos, the exported HTML is identical to pre-branding output" + - "Existing callers without branding parameter still compile and produce identical output" + artifacts: + - path: "SharepointToolbox/Services/Export/HtmlExportService.cs" + provides: "Permissions HTML export with optional branding" + contains: "ReportBranding? branding = null" + - path: "SharepointToolbox/Services/Export/SearchHtmlExportService.cs" + provides: "Search HTML export with optional branding" + contains: "ReportBranding? branding = null" + - path: "SharepointToolbox/Services/Export/StorageHtmlExportService.cs" + provides: "Storage HTML export with optional branding" + contains: "ReportBranding? branding = null" + - path: "SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs" + provides: "Duplicates HTML export with optional branding" + contains: "ReportBranding? branding = null" + - path: "SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs" + provides: "User access HTML export with optional branding" + contains: "ReportBranding? branding = null" + key_links: + - from: "SharepointToolbox/Services/Export/HtmlExportService.cs" + to: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs" + via: "static method call" + pattern: "BrandingHtmlHelper\\.BuildBrandingHeader" + - from: "SharepointToolbox/Services/Export/SearchHtmlExportService.cs" + to: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs" + via: "static method call" + pattern: "BrandingHtmlHelper\\.BuildBrandingHeader" + - from: "SharepointToolbox/Services/Export/StorageHtmlExportService.cs" + to: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs" + via: "static method call" + pattern: "BrandingHtmlHelper\\.BuildBrandingHeader" + - from: "SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs" + to: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs" + via: "static method call" + pattern: "BrandingHtmlHelper\\.BuildBrandingHeader" + - from: "SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs" + to: "SharepointToolbox/Services/Export/BrandingHtmlHelper.cs" + via: "static method call" + pattern: "BrandingHtmlHelper\\.BuildBrandingHeader" +--- + + +Add optional `ReportBranding? branding = null` parameter to all 5 HTML export services and inject the branding header HTML between `` and `

` in each. + +Purpose: BRAND-05 requires all five HTML report types to display logos. This plan modifies each exporter to call `BrandingHtmlHelper.BuildBrandingHeader(branding)` at the correct injection point. Default null parameter ensures zero regression for existing callers. + +Output: All 5 HTML export services accept branding, inject header when provided, and extended tests verify branding appears in output. + + + +@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 +@.planning/phases/11-html-export-branding/11-01-SUMMARY.md + + + + +From SharepointToolbox/Core/Models/ReportBranding.cs: +```csharp +namespace SharepointToolbox.Core.Models; +public record ReportBranding(LogoData? MspLogo, LogoData? ClientLogo); +``` + +From SharepointToolbox/Services/Export/BrandingHtmlHelper.cs: +```csharp +namespace SharepointToolbox.Services.Export; +internal static class BrandingHtmlHelper +{ + public static string BuildBrandingHeader(ReportBranding? branding); +} +``` + + + +HtmlExportService.cs: +```csharp +public string BuildHtml(IReadOnlyList entries) +public string BuildSimplifiedHtml(IReadOnlyList entries) +public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct) +public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct) +``` + +SearchHtmlExportService.cs: +```csharp +public string BuildHtml(IReadOnlyList results) +public async Task WriteAsync(IReadOnlyList results, string filePath, CancellationToken ct) +``` + +StorageHtmlExportService.cs: +```csharp +public string BuildHtml(IReadOnlyList nodes) +public string BuildHtml(IReadOnlyList nodes, IReadOnlyList fileTypeMetrics) +public async Task WriteAsync(IReadOnlyList nodes, string filePath, CancellationToken ct) +public async Task WriteAsync(IReadOnlyList nodes, IReadOnlyList fileTypeMetrics, string filePath, CancellationToken ct) +``` + +DuplicatesHtmlExportService.cs: +```csharp +public string BuildHtml(IReadOnlyList groups) +public async Task WriteAsync(IReadOnlyList groups, string filePath, CancellationToken ct) +``` + +UserAccessHtmlExportService.cs: +```csharp +public string BuildHtml(IReadOnlyList entries) +public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct) +``` + + + + + + + Task 1: Add branding parameter to all 5 HTML export services + + SharepointToolbox/Services/Export/HtmlExportService.cs, + SharepointToolbox/Services/Export/SearchHtmlExportService.cs, + SharepointToolbox/Services/Export/StorageHtmlExportService.cs, + SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs, + SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs + + + For each of the 5 HTML export service files, apply the same two-step modification: + + **Step 1: Add `using SharepointToolbox.Core.Models;`** at the top if not already present (needed for `ReportBranding`). + + **Step 2: Modify BuildHtml signatures.** Add `ReportBranding? branding = null` as the LAST parameter: + + For `HtmlExportService.cs`: + - `BuildHtml(IReadOnlyList entries, ReportBranding? branding = null)` + - `BuildSimplifiedHtml(IReadOnlyList entries, ReportBranding? branding = null)` + - In both methods, find the line `sb.AppendLine("");` followed by `sb.AppendLine("

...")` + - Insert `sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));` AFTER the `` line and BEFORE the `

` line + + For `SearchHtmlExportService.cs`: + - `BuildHtml(IReadOnlyList results, ReportBranding? branding = null)` + - This file uses raw string literal (`"""`). Find `` followed by `

File Search Results

`. + - Split the raw string: close the raw string after ``, append the branding header call, then start a new raw string or `sb.AppendLine` for the `

`. The simplest approach: break the raw string literal at the `` / `

` boundary and insert `sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));` between the two pieces. + + For `StorageHtmlExportService.cs`: + - Two `BuildHtml` overloads — add `ReportBranding? branding = null` to both + - Both use raw string literals. Same injection approach: break at `` / `

` boundary. + - The 2-param `BuildHtml` also needs the branding param: `BuildHtml(IReadOnlyList nodes, IReadOnlyList fileTypeMetrics, ReportBranding? branding = null)` + + For `DuplicatesHtmlExportService.cs`: + - `BuildHtml(IReadOnlyList groups, ReportBranding? branding = null)` + - Raw string literal. Same injection approach. + + For `UserAccessHtmlExportService.cs`: + - `BuildHtml(IReadOnlyList entries, ReportBranding? branding = null)` + - Uses `sb.AppendLine("");` and `sb.AppendLine("

User Access Audit Report

");` + - Insert `sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));` between them. + + **Step 3: Modify WriteAsync signatures.** Add `ReportBranding? branding = null` as the LAST parameter on each WriteAsync overload. Inside each WriteAsync, pass `branding` through to the corresponding `BuildHtml` call. + + Per RESEARCH Pitfall 2: Place `branding` AFTER `CancellationToken ct` in WriteAsync signatures so existing positional callers are unaffected: + ```csharp + public async Task WriteAsync(..., CancellationToken ct, ReportBranding? branding = null) + ``` + + **CRITICAL:** Do NOT change the `_togIdx` reset logic in `StorageHtmlExportService.BuildHtml` (see RESEARCH Pitfall 5). + + **CRITICAL:** Every existing caller without the branding parameter must compile unchanged. The `= null` default handles this. + + + dotnet build --no-restore -warnaserror + + All 5 HTML export services accept optional ReportBranding parameter on BuildHtml and WriteAsync. BrandingHtmlHelper.BuildBrandingHeader is called between body and h1 in each. Build passes with zero warnings. No existing callers broken. + + + + Task 2: Extend export tests to verify branding injection + + SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs, + SharepointToolbox.Tests/Services/Export/SearchExportServiceTests.cs, + SharepointToolbox.Tests/Services/Export/StorageHtmlExportServiceTests.cs, + SharepointToolbox.Tests/Services/Export/DuplicatesHtmlExportServiceTests.cs, + SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs + + + - Test 1 (HtmlExportServiceTests): BuildHtml with ReportBranding(mspLogo, null) produces HTML containing img tag with MSP logo data-URI + - Test 2 (HtmlExportServiceTests): BuildHtml with null branding produces HTML that does NOT contain "branding-header" or data-URI img tags + - Test 3 (HtmlExportServiceTests): BuildHtml with both logos produces HTML containing two img tags + - Test 4 (SearchExportServiceTests): BuildHtml with branding contains img tag between body and h1 + - Test 5 (StorageHtmlExportServiceTests): BuildHtml with branding contains img tag + - Test 6 (DuplicatesHtmlExportServiceTests): BuildHtml with branding contains img tag + - Test 7 (UserAccessHtmlExportServiceTests): BuildHtml with branding contains img tag + - Test 8 (regression): Each existing test still passes unchanged (no branding = same output) + + + Add new test methods to each existing test file. Each test file already has helper methods for creating test data (e.g., `MakeEntry` in HtmlExportServiceTests). Use the same pattern. + + Create a shared helper in each test class: + ```csharp + private static ReportBranding MakeBranding(bool msp = true, bool client = false) + { + var mspLogo = msp ? new LogoData { Base64 = "bXNw", MimeType = "image/png" } : null; + var clientLogo = client ? new LogoData { Base64 = "Y2xpZW50", MimeType = "image/jpeg" } : null; + return new ReportBranding(mspLogo, clientLogo); + } + ``` + + For HtmlExportServiceTests (the most thorough — 3 new tests): + ```csharp + [Fact] + public void BuildHtml_WithMspBranding_ContainsMspLogoImg() + { + var entry = MakeEntry("Test", "test@contoso.com"); + var svc = new HtmlExportService(); + var html = svc.BuildHtml(new[] { entry }, MakeBranding(msp: true, client: false)); + Assert.Contains("data:image/png;base64,bXNw", html); + } + + [Fact] + public void BuildHtml_WithNullBranding_ContainsNoLogoImg() + { + var entry = MakeEntry("Test", "test@contoso.com"); + var svc = new HtmlExportService(); + var html = svc.BuildHtml(new[] { entry }); + Assert.DoesNotContain("data:image/png;base64,", html); + } + + [Fact] + public void BuildHtml_WithBothLogos_ContainsTwoImgs() + { + var entry = MakeEntry("Test", "test@contoso.com"); + var svc = new HtmlExportService(); + var html = svc.BuildHtml(new[] { entry }, MakeBranding(msp: true, client: true)); + Assert.Contains("data:image/png;base64,bXNw", html); + Assert.Contains("data:image/jpeg;base64,Y2xpZW50", html); + } + ``` + + For each of the other 4 test files, add one test confirming branding injection works: + ```csharp + [Fact] + public void BuildHtml_WithBranding_ContainsLogoImg() + { + // Use existing test data creation pattern from the file + var svc = new XxxHtmlExportService(); + var html = svc.BuildHtml(testData, MakeBranding(msp: true)); + Assert.Contains("data:image/png;base64,bXNw", html); + } + ``` + + Add `using SharepointToolbox.Core.Models;` to each test file if not present. + + + dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Export" --no-build -q + + All 5 export test files have branding tests. Tests confirm: branding img tags appear when branding is provided, no img tags appear when branding is null. All existing export tests continue to pass (regression verified). + + + + + +```bash +dotnet build --no-restore -warnaserror +dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~Export" --no-build -q +dotnet test SharepointToolbox.Tests --no-build -q +``` +All three commands must pass with zero failures. The last command verifies no regressions across the full test suite. + + + +- All 5 HTML export services have optional ReportBranding? branding = null on BuildHtml and WriteAsync +- Branding header is injected between body and h1 via BrandingHtmlHelper.BuildBrandingHeader call +- Default null parameter preserves backward compatibility (existing callers compile unchanged) +- Tests verify branding img tags appear when branding is provided +- Tests verify no img tags appear when branding is null (identical to pre-branding output) +- Full test suite passes with no regressions + + + +After completion, create `.planning/phases/11-html-export-branding/11-02-SUMMARY.md` + diff --git a/.planning/phases/11-html-export-branding/11-03-PLAN.md b/.planning/phases/11-html-export-branding/11-03-PLAN.md new file mode 100644 index 0000000..90d770c --- /dev/null +++ b/.planning/phases/11-html-export-branding/11-03-PLAN.md @@ -0,0 +1,219 @@ +--- +phase: 11-html-export-branding +plan: 03 +type: execute +wave: 3 +depends_on: ["11-02"] +files_modified: + - SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs + - SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs + - SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs + - SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs + - SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs + - SharepointToolbox/App.xaml.cs +autonomous: true +requirements: + - BRAND-05 + +must_haves: + truths: + - "Each of the 5 export ViewModels injects IBrandingService and assembles ReportBranding before calling WriteAsync" + - "ReportBranding is assembled from IBrandingService.GetMspLogoAsync() for MSP logo and _currentProfile.ClientLogo for client logo" + - "The branding ReportBranding is passed as the last parameter to WriteAsync" + - "DI container provides IBrandingService to all 5 export ViewModels" + artifacts: + - path: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs" + provides: "Permissions export with branding assembly" + contains: "IBrandingService" + - path: "SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs" + provides: "Search export with branding assembly" + contains: "IBrandingService" + - path: "SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs" + provides: "Storage export with branding assembly" + contains: "IBrandingService" + - path: "SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs" + provides: "Duplicates export with branding assembly" + contains: "IBrandingService" + - path: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs" + provides: "User access export with branding assembly" + contains: "IBrandingService" + key_links: + - from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs" + to: "SharepointToolbox/Services/IBrandingService.cs" + via: "constructor injection" + pattern: "IBrandingService _brandingService" + - from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs" + to: "SharepointToolbox/Services/Export/HtmlExportService.cs" + via: "WriteAsync with branding" + pattern: "WriteAsync.*branding" +--- + + +Wire IBrandingService into all 5 export ViewModels so each ExportHtmlAsync method assembles a ReportBranding from the MSP logo and the active tenant's client logo, then passes it to WriteAsync. + +Purpose: Connects the branding infrastructure (Plan 01) and export service changes (Plan 02) to the user-facing export commands. After this plan, HTML exports include branding logos when configured. + +Output: All 5 export ViewModels inject IBrandingService, assemble ReportBranding in ExportHtmlAsync, and pass it to WriteAsync. + + + +@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 +@.planning/phases/11-html-export-branding/11-02-SUMMARY.md + + + +From SharepointToolbox/Core/Models/ReportBranding.cs: +```csharp +public record ReportBranding(LogoData? MspLogo, LogoData? ClientLogo); +``` + + +From SharepointToolbox/Services/IBrandingService.cs: +```csharp +public interface IBrandingService +{ + Task ImportLogoAsync(string filePath); + Task SaveMspLogoAsync(LogoData logo); + Task ClearMspLogoAsync(); + Task GetMspLogoAsync(); +} +``` + + +DuplicatesViewModel constructor pattern: +```csharp +public DuplicatesViewModel( + IDuplicatesService duplicatesService, + ISessionManager sessionManager, + DuplicatesHtmlExportService htmlExportService, + ILogger logger) : base(logger) +``` + +Each ViewModel has: +- `private TenantProfile? _currentProfile;` field set via OnTenantSwitched +- `ExportHtmlAsync()` method calling `_htmlExportService.WriteAsync(..., CancellationToken.None)` + +PermissionsViewModel has two constructors: full (DI) and test (internal, omits export services). +UserAccessAuditViewModel also has two constructors. +The other 3 ViewModels (Search, Storage, Duplicates) have a single constructor each. + +DI registrations in App.xaml.cs: +```csharp +services.AddSingleton(); +``` +IBrandingService is already registered — no new DI registration needed for the service itself. +But each ViewModel registration must now resolve IBrandingService in addition to existing deps. + + + + + + + Task 1: Inject IBrandingService into all 5 export ViewModels and assemble branding in ExportHtmlAsync + + SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs, + SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs, + SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs, + SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs, + SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs + + + Apply the same pattern to all 5 ViewModels: + + **For each ViewModel:** + + 1. Add `using SharepointToolbox.Core.Models;` if not already present (needed for `ReportBranding`). + + 2. Add field: `private readonly IBrandingService _brandingService;` + + 3. Modify the DI constructor to accept `IBrandingService brandingService` parameter and assign `_brandingService = brandingService;`. + + 4. For ViewModels with a test constructor (PermissionsViewModel, UserAccessAuditViewModel): add `IBrandingService? brandingService = null` as the last parameter, assign `_brandingService = brandingService!;`. Using `null!` is acceptable because test constructors are only used in tests where branding is not exercised. Alternatively, create a no-op implementation — but `null!` matches existing pattern where `_htmlExportService = null` is already used in test constructors. + + 5. Modify `ExportHtmlAsync()` — add branding assembly BEFORE the WriteAsync call: + ```csharp + // Assemble branding + var mspLogo = await _brandingService.GetMspLogoAsync(); + var clientLogo = _currentProfile?.ClientLogo; + var branding = new ReportBranding(mspLogo, clientLogo); + ``` + Then pass `branding` as the last argument to each `WriteAsync` call. + + **Specific details per ViewModel:** + + **PermissionsViewModel** (2 WriteAsync calls in ExportHtmlAsync): + ```csharp + // Before: + await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None); + // After: + await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding); + ``` + Same for the non-simplified path. + + **SearchViewModel** (1 WriteAsync call): + ```csharp + await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding); + ``` + + **StorageViewModel** (1 WriteAsync call — the one with FileTypeMetrics): + ```csharp + await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None, branding); + ``` + + **DuplicatesViewModel** (1 WriteAsync call): + ```csharp + await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None, branding); + ``` + + **UserAccessAuditViewModel** (1 WriteAsync call): + ```csharp + await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding); + ``` + + **Guard clause:** Add a null check on `_brandingService` before the branding assembly to be safe (in case the test constructor was used). If `_brandingService is null`, set `branding = null` (which means no branding header — graceful degradation): + ```csharp + ReportBranding? branding = null; + if (_brandingService is not null) + { + var mspLogo = await _brandingService.GetMspLogoAsync(); + var clientLogo = _currentProfile?.ClientLogo; + branding = new ReportBranding(mspLogo, clientLogo); + } + ``` + + + dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --no-build -q + + All 5 export ViewModels inject IBrandingService, assemble ReportBranding from MSP logo + active profile's ClientLogo, and pass it to WriteAsync. Build and all tests pass. Test constructors gracefully handle null IBrandingService. + + + + + +```bash +dotnet build --no-restore -warnaserror +dotnet test SharepointToolbox.Tests --no-build -q +``` +Both commands must pass. Full test suite must pass — existing ViewModel tests must not break from the constructor changes. + + + +- All 5 export ViewModels have IBrandingService injected via constructor +- ExportHtmlAsync assembles ReportBranding from GetMspLogoAsync + _currentProfile.ClientLogo +- ReportBranding is passed to WriteAsync as the last parameter +- Test constructors handle null IBrandingService gracefully (branding = null fallback) +- All existing ViewModel and export tests pass without modification +- Build succeeds with zero warnings + + + +After completion, create `.planning/phases/11-html-export-branding/11-03-SUMMARY.md` + diff --git a/.planning/phases/11-html-export-branding/11-04-PLAN.md b/.planning/phases/11-html-export-branding/11-04-PLAN.md new file mode 100644 index 0000000..5243389 --- /dev/null +++ b/.planning/phases/11-html-export-branding/11-04-PLAN.md @@ -0,0 +1,506 @@ +--- +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` +