24 KiB
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,...<img>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
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
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
public class BrandingService
{
public Task<BrandingSettings> 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:
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 <body> open and <h1>:
<div class="brand-header" style="display:flex;align-items:center;gap:16px;padding:16px 24px 0;">
<!-- only rendered if logo present -->
<img src="data:{mimeType};base64,{base64}" style="max-height:60px;max-width:200px;" alt="MSP" />
<img src="data:{mimeType};base64,{base64}" style="max-height:60px;max-width:200px;" alt="Client" />
</div>
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(twoBuildHtmloverloads —PermissionEntryandSimplifiedPermissionEntry)UserAccessHtmlExportServiceStorageHtmlExportService(twoBuildHtmloverloads — with and withoutFileTypeMetric)SearchHtmlExportServiceDuplicatesHtmlExportService
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:
[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:
<Image>bound toMspLogoPreviewBase64via 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:
[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 <img> 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
public record PagedUserResult(
IReadOnlyList<GraphUserResult> Users,
string? NextPageToken); // null = last page
Services/IGraphUserDirectoryService.cs
public interface IGraphUserDirectoryService
{
Task<PagedUserResult> 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:
[ObservableProperty] private bool _isBrowseModeActive;
[ObservableProperty] private ObservableCollection<GraphUserResult> _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<IList<GraphUserResult>> 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:
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
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
RadioButtons orToggleButtons bound toIsBrowseModeActive - "Search" panel: existing
GroupBoxshown whenIsBrowseModeActive == false - "Browse" panel: new
GroupBoxshown whenIsBrowseModeActive == true, containing:- Filter
TextBoxbound toDirectoryFilter ListViewwithSelectionMode="Extended"bound toDirectoryUsers,SelectionChangedhandler in code-behind- "Add Selected"
Button→AddDirectoryUsersCommand - "Load more"
Buttonshown whenHasMoreDirectoryPages == true→LoadMoreDirectoryCommand - Loading indicator (existing
IsSearchingpattern, but forIsLoadingDirectory)
- Filter
- Show/hide panels via
DataTriggeronIsBrowseModeActive
Views/Tabs/UserAccessAuditView.xaml.cs — Add SelectionChanged handler to pass ListView.SelectedItems (as IList<GraphUserResult>) 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)
Core/Models/BrandingSettings.cs(new)Core/Models/ReportBranding.cs(new)Core/Models/PagedUserResult.cs(new)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
Infrastructure/Persistence/BrandingRepository.cs(new) — depends on BrandingSettingsServices/BrandingService.cs(new) — depends on BrandingRepositoryServices/IGraphUserDirectoryService.cs(new) — depends on PagedUserResultServices/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
- All 5
Services/Export/*HtmlExportService.csmodifications — add optionalReportBranding?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 <img> tags.
Phase D — ViewModel Integration (branding)
SettingsViewModel.cs— add MSP logo commands + previewProfileManagementViewModel.cs— add client logo commands + previewPermissionsViewModel.cs— add BrandingService injection, use in ExportHtmlAsyncStorageViewModel.cs— sameSearchViewModel.cs— sameDuplicatesViewModel.cs— sameApp.xaml.cs— register BrandingRepository, BrandingService
Steps 12-15 follow an identical pattern and can be batched together.
Phase E — ViewModel Integration (directory browse)
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)
SettingsView.xaml— add MSP branding sectionProfileManagementDialog.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)
UserAccessAuditView.xaml— add mode toggle + browse panelUserAccessAuditView.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.csInfrastructure/Persistence/ProfileRepository.cs,SettingsRepository.csInfrastructure/Auth/GraphClientFactory.csServices/SettingsService.cs,ProfileService.csServices/GraphUserSearchService.cs,IGraphUserSearchService.csServices/Export/HtmlExportService.cs,UserAccessHtmlExportService.cs,StorageHtmlExportService.csViewModels/FeatureViewModelBase.cs,MainWindowViewModel.csViewModels/Tabs/UserAccessAuditViewModel.cs,SettingsViewModel.csViews/Tabs/UserAccessAuditView.xaml,SettingsView.xamlApp.xaml.cs