# Architecture Patterns **Domain:** C#/WPF MVVM desktop app — SharePoint Online MSP admin tool **Feature scope:** Report branding (MSP/client logos in HTML) + User directory browse mode **Researched:** 2026-04-08 **Confidence:** HIGH — based on direct codebase inspection, not assumptions --- ## Existing Architecture (Baseline) ``` Core/ Models/ — TenantProfile, AppSettings, domain records (all POCOs/records) Messages/ — WeakReferenceMessenger value message types Helpers/ — Static utility classes Infrastructure/ Auth/ — MsalClientFactory, GraphClientFactory (MSAL PCA per-tenant + Graph SDK bridge) Persistence/ — ProfileRepository, SettingsRepository, TemplateRepository (JSON, atomic write-then-replace) Logging/ — LogPanelSink (Serilog sink to in-app RichTextBox) Services/ Export/ — Concrete HTML/CSV export services per domain (no interface, consumed directly) *.cs — Domain services with IXxx interfaces ViewModels/ FeatureViewModelBase.cs — Abstract base: RunCommand, CancelCommand, ProgressValue, StatusMessage, GlobalSites, WeakReferenceMessenger registration MainWindowViewModel.cs — Toolbar: tenant picker, Connect, global site picker, broadcasts TenantSwitchedMessage Tabs/ — One ViewModel per tab, all extend FeatureViewModelBase ProfileManagementViewModel.cs — Profile CRUD dialog VM Views/ Dialogs/ — ProfileManagementDialog, SitePickerDialog, ConfirmBulkOperationDialog, FolderBrowserDialog Tabs/ — One UserControl per tab (XAML + code-behind) App.xaml.cs — Generic Host IServiceCollection DI registration for all layers ``` ### Key Patterns Already Established | Pattern | How It Works | |---------|-------------| | Tenant switching | `MainWindowViewModel.OnSelectedProfileChanged` broadcasts `TenantSwitchedMessage` via `WeakReferenceMessenger`; each tab VM overrides `OnTenantSwitched(profile)` | | Global site propagation | `GlobalSitesChangedMessage` received in `FeatureViewModelBase.OnGlobalSitesReceived` | | HTML export | Concrete service class (e.g. `UserAccessHtmlExportService`), `BuildHtml(entries)` returns a string, `WriteAsync(entries, path, ct)` writes it. No interface. Pure data-in, HTML-out. | | JSON persistence | Repository pattern: constructor takes `string filePath`, atomic write via `.tmp` + round-trip JSON validation before `File.Move`, `SemaphoreSlim` write lock. | | DI registration | All in `App.xaml.cs RegisterServices()`. Export services and ViewModels are `AddTransient`; shared infrastructure is `AddSingleton`. | | Dialog factory | View code-behind sets `ViewModel.OpenXxxDialog = () => new XxxDialog(...)` — keeps dialogs out of ViewModel layer | | People-picker search | `IGraphUserSearchService.SearchUsersAsync(clientId, query, maxResults, ct)` calls Graph `/users?$filter=startsWith(...)` with `ConsistencyLevel: eventual` | | Test constructor | `UserAccessAuditViewModel` has a `internal` 3-param constructor without export services — test pattern to replicate for new injections | --- ## Feature 1: Report Branding (MSP/Client Logos in HTML Reports) ### What It Needs - **MSP logo** — one global image, shown in every HTML report from every tenant - **Client logo** — one image per tenant, shown in reports for that tenant only - **Storage** — base64-encoded strings in JSON (no separate image files — preserves atomic save semantics and single-data-folder design) - **Embedding** — `data:image/...;base64,...` `` tag injected into the HTML header (maintains self-contained HTML invariant — zero external file references) - **User action** — file picker → read bytes → detect MIME type → convert to base64 → store in JSON → preview in UI ### New Components (create from scratch) **`Core/Models/BrandingSettings.cs`** ```csharp public class BrandingSettings { public string? MspLogoBase64 { get; set; } public string? MspLogoMimeType { get; set; } // "image/png", "image/jpeg", etc. } ``` Belongs in Core/Models alongside AppSettings. Kept separate — branding may grow independently of general app settings. **`Core/Models/ReportBranding.cs`** ```csharp public record ReportBranding( string? MspLogoBase64, string? MspLogoMimeType, string? ClientLogoBase64, string? ClientLogoMimeType); ``` Lightweight data transfer record assembled at export time from BrandingSettings + current TenantProfile. Not persisted directly — constructed on demand. **`Infrastructure/Persistence/BrandingRepository.cs`** Same pattern as `SettingsRepository`: constructor takes `string filePath`, atomic write-then-replace, `SemaphoreSlim` write lock. File: `%AppData%\SharepointToolbox\branding.json`. **`Services/BrandingService.cs`** ```csharp public class BrandingService { public Task GetBrandingAsync(); public Task SetMspLogoAsync(string filePath); // reads file, detects MIME, converts to base64, saves public Task ClearMspLogoAsync(); } ``` Thin orchestration, same pattern as `SettingsService`. MSP logo only — client logo is managed via `ProfileService` (it belongs to `TenantProfile`). ### Modified Components **`Core/Models/TenantProfile.cs`** — Add two nullable string properties: ```csharp public string? ClientLogoBase64 { get; set; } public string? ClientLogoMimeType { get; set; } ``` This is backward-compatible. `ProfileRepository` uses `JsonSerializer` with `PropertyNameCaseInsensitive: true` — missing JSON fields deserialize to null without error. Existing `profiles.json` files continue to load correctly. **All HTML export services** — Add `ReportBranding? branding = null` optional parameter to every `BuildHtml()` overload. When non-null and at least one logo is present, inject a branding header div between `` open and `

`: ```html
MSP Client
``` When `branding` is null (existing callers) the block is omitted entirely. No behavior change for callers that do not pass branding. Affected services (all in `Services/Export/`): - `HtmlExportService` (two `BuildHtml` overloads — `PermissionEntry` and `SimplifiedPermissionEntry`) - `UserAccessHtmlExportService` - `StorageHtmlExportService` (two `BuildHtml` overloads — with and without `FileTypeMetric`) - `SearchHtmlExportService` - `DuplicatesHtmlExportService` **ViewModels that call HTML export** — All `ExportHtmlAsync` methods need to resolve branding before calling the export service. The ViewModel calls `BrandingService.GetBrandingAsync()` and reads `_currentProfile.ClientLogoBase64` to assemble a `ReportBranding`, then passes it to `BuildHtml`. Affected ViewModels: `PermissionsViewModel`, `UserAccessAuditViewModel`, `StorageViewModel`, `SearchViewModel`, `DuplicatesViewModel`. Each gets `BrandingService` injected via constructor. **`ViewModels/Tabs/SettingsViewModel.cs`** — Add MSP logo management: ```csharp [ObservableProperty] private string? _mspLogoPreviewBase64; public RelayCommand BrowseMspLogoCommand { get; } public RelayCommand ClearMspLogoCommand { get; } ``` On browse: open `OpenFileDialog` (filter: PNG, JPG, GIF) → call `BrandingService.SetMspLogoAsync(path)` → reload and refresh `MspLogoPreviewBase64`. **`Views/Tabs/SettingsView.xaml`** — Add a "Report Branding — MSP Logo" section: - `` bound to `MspLogoPreviewBase64` via a base64-to-BitmapSource converter - "Browse Logo" button → `BrowseMspLogoCommand` - "Clear" button → `ClearMspLogoCommand` - Note label: "Applies to all reports" **Client logo placement:** Client logo belongs to a `TenantProfile`, not to global settings. The natural place to manage it is `ProfileManagementDialog` (already handles profile CRUD). Add logo fields there rather than in SettingsView. **`ViewModels/ProfileManagementViewModel.cs`** — Add client logo management per profile: ```csharp [ObservableProperty] private string? _clientLogoPreviewBase64; public RelayCommand BrowseClientLogoCommand { get; } public RelayCommand ClearClientLogoCommand { get; } ``` On browse: read image bytes → base64 → set on the being-edited `TenantProfile` object before saving. Uses `ProfileService.AddProfileAsync` / rename pipeline that already exists. **`Views/Dialogs/ProfileManagementDialog.xaml`** — Add client logo fields to the add/edit profile form (same pattern as SettingsView branding section). ### Data Flow: Report Branding ``` User picks MSP logo (SettingsView "Browse Logo" button) → SettingsViewModel.BrowseMspLogoCommand → OpenFileDialog in View code-behind or VM (follow existing BrowseFolder pattern) → BrandingService.SetMspLogoAsync(path) → File.ReadAllBytesAsync → Convert.ToBase64String → detect MIME from extension (.png → image/png, .jpg/.jpeg → image/jpeg, .gif → image/gif) → BrandingRepository.SaveAsync(BrandingSettings) → ViewModel refreshes MspLogoPreviewBase64 User runs export (e.g. ExportHtmlCommand in UserAccessAuditViewModel) → BrandingService.GetBrandingAsync() → BrandingSettings → reads _currentProfile.ClientLogoBase64, _currentProfile.ClientLogoMimeType → new ReportBranding(mspBase64, mspMime, clientBase64, clientMime) → UserAccessHtmlExportService.BuildHtml(entries, branding) → injects data URIs in header when base64 is non-null → writes HTML file ``` --- ## Feature 2: User Directory Browse Mode ### What It Needs The existing `UserAccessAuditView` has a people-picker: search box → Graph API `startsWith` filter → autocomplete dropdown → add to `SelectedUsers`. Directory browse mode is an alternative to the search box: show a paginated, filterable list of all tenant users, allow multi-select, bulk-add to `SelectedUsers`. This is purely additive. The underlying audit logic (`IUserAccessAuditService`, `RunOperationAsync`, `SelectedUsers` collection, export commands) is completely unchanged. ### New Components (create from scratch) **`Core/Models/PagedUserResult.cs`** ```csharp public record PagedUserResult( IReadOnlyList Users, string? NextPageToken); // null = last page ``` **`Services/IGraphUserDirectoryService.cs`** ```csharp public interface IGraphUserDirectoryService { Task GetUsersPageAsync( string clientId, string? filter = null, string? pageToken = null, int pageSize = 100, CancellationToken ct = default); } ``` **`Services/GraphUserDirectoryService.cs`** Reuses `GraphClientFactory` (already injected elsewhere). Calls `graphClient.Users.GetAsync()` without the `startsWith` constraint used in search — uses `$top=100` with cursor-based paging via Graph's `@odata.nextLink`. Returns `PagedUserResult` so callers control pagination. Uses `ConsistencyLevel: eventual` + `$count=true` (same as existing search service). ### Modified Components **`ViewModels/Tabs/UserAccessAuditViewModel.cs`** — Add browse mode state: ```csharp [ObservableProperty] private bool _isBrowseModeActive; [ObservableProperty] private ObservableCollection _directoryUsers = new(); [ObservableProperty] private string _directoryFilter = string.Empty; [ObservableProperty] private bool _isLoadingDirectory; [ObservableProperty] private bool _hasMoreDirectoryPages; private string? _directoryNextPageToken; public IAsyncRelayCommand LoadDirectoryCommand { get; } public IAsyncRelayCommand LoadMoreDirectoryCommand { get; } public RelayCommand> AddDirectoryUsersCommand { get; } ``` `partial void OnIsBrowseModeActiveChanged(bool value)` → when `value == true`, fire `LoadDirectoryCommand` to populate page 1. `partial void OnDirectoryFilterChanged(string value)` → debounce 300ms (same pattern as `OnSearchQueryChanged`), re-fire `LoadDirectoryCommand` with new filter, clear `_directoryNextPageToken`. The `IGraphUserDirectoryService` is added to the constructor. The internal test constructor (currently 3 params) gets a 4-param overload adding the directory service with a null-safe default, or a new explicit test constructor. **`App.xaml.cs RegisterServices()`** — Add: ```csharp services.AddTransient(); ``` The `UserAccessAuditViewModel` transient registration picks up the new injection automatically (DI resolves by type). **`UserAccessAuditViewModel.OnTenantSwitched`** — Also clear `DirectoryUsers`, reset `_directoryNextPageToken`, `HasMoreDirectoryPages`, `IsLoadingDirectory`. **`Views/Tabs/UserAccessAuditView.xaml`** — Add to the top of the left panel: - Mode toggle: two `RadioButton`s or `ToggleButton`s bound to `IsBrowseModeActive` - "Search" panel: existing `GroupBox` shown when `IsBrowseModeActive == false` - "Browse" panel: new `GroupBox` shown when `IsBrowseModeActive == true`, containing: - Filter `TextBox` bound to `DirectoryFilter` - `ListView` with `SelectionMode="Extended"` bound to `DirectoryUsers`, `SelectionChanged` handler in code-behind - "Add Selected" `Button` → `AddDirectoryUsersCommand` - "Load more" `Button` shown when `HasMoreDirectoryPages == true` → `LoadMoreDirectoryCommand` - Loading indicator (existing `IsSearching` pattern, but for `IsLoadingDirectory`) - Show/hide panels via `DataTrigger` on `IsBrowseModeActive` **`Views/Tabs/UserAccessAuditView.xaml.cs`** — Add `SelectionChanged` handler to pass `ListView.SelectedItems` (as `IList`) to `AddDirectoryUsersCommand`. Follow the existing `SearchResultsListBox_SelectionChanged` pattern. ### Data Flow: Directory Browse Mode ``` User clicks "Browse" mode toggle → IsBrowseModeActive = true → OnIsBrowseModeActiveChanged fires LoadDirectoryCommand → GraphUserDirectoryService.GetUsersPageAsync(clientId, filter: null, pageToken: null, 100, ct) → Graph GET /users?$select=displayName,userPrincipalName,mail&$top=100&$orderby=displayName → returns PagedUserResult { Users = [...100 items], NextPageToken = "..." } → DirectoryUsers = new collection of returned users → HasMoreDirectoryPages = (NextPageToken != null) → _directoryNextPageToken = returned token User types in DirectoryFilter → debounce 300ms → LoadDirectoryCommand re-fires with filter → DirectoryUsers replaced with filtered page 1 User selects users in ListView + clicks "Add Selected" → AddDirectoryUsersCommand(selectedItems) → for each: if not already in SelectedUsers by UPN → SelectedUsers.Add(user) User clicks "Load more" → LoadMoreDirectoryCommand → GetUsersPageAsync(clientId, currentFilter, _directoryNextPageToken, 100, ct) → DirectoryUsers items appended (not replaced) → _directoryNextPageToken updated ``` --- ## Component Boundary Summary ### New Components (create) | Component | Layer | Type | Purpose | |-----------|-------|------|---------| | `BrandingSettings` | Core/Models | class | MSP logo storage (base64 + MIME type) | | `ReportBranding` | Core/Models | record | Data passed to `BuildHtml` overloads at export time | | `BrandingRepository` | Infrastructure/Persistence | class | JSON load/save for `BrandingSettings` | | `BrandingService` | Services | class | Orchestrates logo file read / MIME detect / base64 convert / save | | `PagedUserResult` | Core/Models | record | Page of `GraphUserResult` items + next-page token | | `IGraphUserDirectoryService` | Services | interface | Contract for paginated tenant user enumeration | | `GraphUserDirectoryService` | Services | class | Graph API user listing with cursor pagination | Total new files: 7 ### Modified Components (extend) | Component | Change | Risk | |-----------|--------|------| | `TenantProfile` | + 2 nullable logo props | LOW — JSON backward-compatible | | `HtmlExportService` | + optional `ReportBranding?` on 2 overloads | LOW — optional param, existing callers unaffected | | `UserAccessHtmlExportService` | + optional `ReportBranding?` | LOW | | `StorageHtmlExportService` | + optional `ReportBranding?` on 2 overloads | LOW | | `SearchHtmlExportService` | + optional `ReportBranding?` | LOW | | `DuplicatesHtmlExportService` | + optional `ReportBranding?` | LOW | | `PermissionsViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW | | `UserAccessAuditViewModel` | + inject `BrandingService` + `IGraphUserDirectoryService`, browse mode state/commands | MEDIUM | | `StorageViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW | | `SearchViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW | | `DuplicatesViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW | | `SettingsViewModel` | + inject `BrandingService`, MSP logo commands + preview property | LOW | | `ProfileManagementViewModel` | + client logo browse/preview/clear | LOW | | `SettingsView.xaml` | + branding section with logo preview + buttons | LOW | | `ProfileManagementDialog.xaml` | + client logo fields | LOW | | `UserAccessAuditView.xaml` | + mode toggle + browse panel in left column | MEDIUM | | `App.xaml.cs RegisterServices()` | + 3 new registrations | LOW | Total modified files: 17 --- ## Build Order (Dependency-Aware) The two features are independent of each other. Phases can run in parallel if worked by two developers; solo they should follow top-to-bottom order. ### Phase A — Data Models (no dependencies) 1. `Core/Models/BrandingSettings.cs` (new) 2. `Core/Models/ReportBranding.cs` (new) 3. `Core/Models/PagedUserResult.cs` (new) 4. `Core/Models/TenantProfile.cs` — add nullable logo props (modification) All files are POCOs/records. Unit-testable in isolation. No risk. ### Phase B — Persistence + Service Layer 5. `Infrastructure/Persistence/BrandingRepository.cs` (new) — depends on BrandingSettings 6. `Services/BrandingService.cs` (new) — depends on BrandingRepository 7. `Services/IGraphUserDirectoryService.cs` (new) — depends on PagedUserResult 8. `Services/GraphUserDirectoryService.cs` (new) — depends on GraphClientFactory (already exists) Unit tests for BrandingService (mock repository) and GraphUserDirectoryService (mock Graph client) can be written at this phase. ### Phase C — HTML Export Service Extensions 9. All 5 `Services/Export/*HtmlExportService.cs` modifications — add optional `ReportBranding?` param These are independent of each other. Tests: verify that passing `null` branding produces identical HTML to current output (regression), and that passing a branding record injects the expected `` tags. ### Phase D — ViewModel Integration (branding) 10. `SettingsViewModel.cs` — add MSP logo commands + preview 11. `ProfileManagementViewModel.cs` — add client logo commands + preview 12. `PermissionsViewModel.cs` — add BrandingService injection, use in ExportHtmlAsync 13. `StorageViewModel.cs` — same 14. `SearchViewModel.cs` — same 15. `DuplicatesViewModel.cs` — same 16. `App.xaml.cs` — register BrandingRepository, BrandingService Steps 12-15 follow an identical pattern and can be batched together. ### Phase E — ViewModel Integration (directory browse) 17. `UserAccessAuditViewModel.cs` — add IGraphUserDirectoryService injection, browse mode state/commands Note: UserAccessAuditViewModel also gets BrandingService at this phase (from Phase D pattern). Do both together to avoid touching the constructor twice. ### Phase F — View Layer (branding UI) 18. `SettingsView.xaml` — add MSP branding section 19. `ProfileManagementDialog.xaml` — add client logo fields Requires a base64-to-BitmapSource `IValueConverter` (add to `Views/Converters/`). This is a common WPF pattern — implement once, reuse in both views. ### Phase G — View Layer (directory browse UI) 20. `UserAccessAuditView.xaml` — add mode toggle + browse panel 21. `UserAccessAuditView.xaml.cs` — add SelectionChanged handler for directory ListView This is the highest-risk UI change: the left panel is being restructured. Do this last, after all ViewModel behavior is proven by unit tests. --- ## Anti-Patterns to Avoid ### Storing Logo Images as Separate Files **Why bad:** Breaks the single-data-folder design. Reports become non-self-contained if they reference external paths. Atomic save semantics break. **Instead:** Base64-encode into JSON. Logo thumbnails are typically 10-200KB. Base64 overhead (~33%) is negligible. ### Adding an `IHtmlExportService` Interface Just for Branding **Why bad:** The existing pattern is 5 concrete classes with no interfaces, consumed directly by ViewModels. Adding an interface for a parameter change creates ceremony without value. **Instead:** Add `ReportBranding? branding = null` as optional parameter. Existing callers compile unchanged. ### Loading All Tenant Users at Once **Why bad:** Enterprise tenants regularly have 20,000-100,000 users. A full load blocks the UI for 30+ seconds and allocates hundreds of MB. **Instead:** `PagedUserResult` pattern — page 1 on mode toggle, "Load more" button, server-side filter applied to DirectoryFilter text. ### Async in ViewModel Constructor **Why bad:** DI constructs ViewModels synchronously on the UI thread. Async work in constructors requires fire-and-forget which loses exceptions. **Instead:** `partial void OnIsBrowseModeActiveChanged` fires `LoadDirectoryCommand` when browse mode activates. Constructor only wires up commands and state. ### Client Logo in `AppSettings` or `BrandingSettings` **Why bad:** Client logos are per-tenant. `AppSettings` and `BrandingSettings` are global. Mixing them makes per-profile deletion awkward and serialization structure unclear. **Instead:** `ClientLogoBase64` + `ClientLogoMimeType` directly on `TenantProfile` (serialized in `profiles.json`). MSP logo goes in `branding.json` via `BrandingRepository`. ### Changing `BuildHtml` Signatures to Required Parameters **Why bad:** All 5 HTML export services currently have callers without branding. Making the parameter required is a breaking change forcing simultaneous updates across 5 VMs. **Instead:** `ReportBranding? branding = null` is optional. Inject only where branding is desired. Existing call sites remain unchanged. --- ## Scalability Considerations | Concern | Impact | Mitigation | |---------|--------|------------| | Logo storage size in JSON | PNG logos base64-encoded: 10-200KB per logo. `profiles.json` grows by at most that per tenant | Acceptable — config files, not bulk data | | HTML report file size | +2-10KB per logo (base64 inline) | Negligible — reports are already 100-500KB | | Directory browse load time | 100-user pages from Graph: ~200-500ms per page | Loading indicator, pagination. Acceptable UX. | | Large tenants (50k+ users) | Full load would take minutes and exceed memory budgets | Pagination via `PagedUserResult` prevents this entirely | | ViewModel constructor overhead | BrandingService adds one lazy JSON read at first export | Not at construction — no startup impact | --- ## Sources All findings are based on direct inspection of the codebase at `C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox/`. No external research needed — this is an integration architecture document for a known codebase. Key files examined: - `Core/Models/TenantProfile.cs`, `AppSettings.cs` - `Infrastructure/Persistence/ProfileRepository.cs`, `SettingsRepository.cs` - `Infrastructure/Auth/GraphClientFactory.cs` - `Services/SettingsService.cs`, `ProfileService.cs` - `Services/GraphUserSearchService.cs`, `IGraphUserSearchService.cs` - `Services/Export/HtmlExportService.cs`, `UserAccessHtmlExportService.cs`, `StorageHtmlExportService.cs` - `ViewModels/FeatureViewModelBase.cs`, `MainWindowViewModel.cs` - `ViewModels/Tabs/UserAccessAuditViewModel.cs`, `SettingsViewModel.cs` - `Views/Tabs/UserAccessAuditView.xaml`, `SettingsView.xaml` - `App.xaml.cs`