docs: complete v2.3 project research (STACK, FEATURES, ARCHITECTURE, PITFALLS)

Research covers all five v2.3 features: automated app registration, app removal,
auto-take ownership, group expansion in HTML reports, and report consolidation toggle.
No new NuGet packages required. Build order and phase implications documented.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-09 10:58:58 +02:00
parent 9318bb494d
commit 853f47c4a6
4 changed files with 1314 additions and 480 deletions

View File

@@ -1,443 +1,438 @@
# 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
**Project:** SharePoint Toolbox v2.3 — Tenant Management & Report Enhancements
**Researched:** 2026-04-09
**Scope:** Integration of four new features into the existing MVVM/DI architecture
---
## Existing Architecture (Baseline)
The app uses a clean layered architecture. Understanding the layers is prerequisite to placing new features correctly.
```
Core/
Models/ — TenantProfile, AppSettings, domain records (all POCOs/records)
Messages/ — WeakReferenceMessenger value message types
Helpers/ — Static utility classes
Models/ Pure data records and enums (no dependencies)
Helpers/ — Static utility methods
Messages/ — WeakReferenceMessenger message types
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)
Auth/ — MsalClientFactory, GraphClientFactory, SessionManager wiring
Persistence/ JSON-backed repositories (ProfileRepository, BrandingRepository, etc.)
Services/
Export/Concrete HTML/CSV export services per domain (no interface, consumed directly)
*.cs Domain services with IXxx interfaces
*.cs Interface + implementation pairs (feature business logic)
Export/HTML and CSV export services per feature area
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
FeatureViewModelBase — Abstract base: RunCommand, CancelCommand, progress, WeakReferenceMessenger
Tabs/ — One ViewModel per tab
ProfileManagementViewModel — Tenant profile CRUD + logo management
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
Tabs/ — XAML views, pure DataBinding
Dialogs/ — Modal dialogs (ProfileManagementDialog, SitePickerDialog, etc.)
```
### Key Patterns Already Established
### Key Architectural Invariants (must not be broken)
| 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 |
1. **SessionManager is the sole holder of ClientContext.** All services receive it via constructor injection; none store it.
2. **GraphClientFactory.CreateClientAsync(clientId)** produces a GraphServiceClient scoped to a specific tenant's PCA from MsalClientFactory.
3. **FeatureViewModelBase** provides RunCommand/CancelCommand/progress wiring. All tab VMs extend it.
4. **WeakReferenceMessenger** carries cross-cutting signals: `TenantSwitchedMessage`, `GlobalSitesChangedMessage`. VMs react in `OnTenantSwitched` / `OnGlobalSitesChanged`.
5. **BulkOperationRunner.RunAsync** is the shared continue-on-error runner for all multi-item operations.
6. **HTML export services** are independent per-feature classes under `Services/Export/`; they receive `ReportBranding?` and call `BrandingHtmlHelper.BuildBrandingHeader()`.
7. **DI registration** is in `App.xaml.cs RegisterServices`. New services register there.
---
## Feature 1: Report Branding (MSP/Client Logos in HTML Reports)
## Feature 1: App Registration via Graph API
### What It Needs
### What It Does
During profile create/edit, attempt to register a new Azure AD app on the target tenant (auto path), or instruct the user through manual steps (guided fallback path).
- **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
### Graph API Constraint (HIGH confidence)
Creating an application registration via `POST /applications` requires the caller to hold `Application.ReadWrite.All`. This is an admin-consent-required delegated permission. The existing GraphClientFactory uses `.default` scope, which only acquires permissions already pre-consented on the PCA's app registration. This means:
### New Components (create from scratch)
- **The Toolbox's own client app registration (the one the MSP registered to run this tool) must have `Application.ReadWrite.All` delegated and admin-consented** before the auto path can work.
- If that permission is absent, the Graph call returns 403. The auto path must catch `ODataError` with status 403 and fall through to guided fallback automatically.
- The guided fallback shows the MSP admin step-by-step instructions for creating the app registration manually in the Azure portal and entering the resulting ClientId.
### New Service: `IAppRegistrationService` / `AppRegistrationService`
**Location:** `Services/AppRegistrationService.cs` + `Services/IAppRegistrationService.cs`
**Responsibilities:**
- `RegisterAppAsync(GraphServiceClient, string tenantName, CancellationToken)` — Creates the app registration and optional service principal on the target tenant. Returns `AppRegistrationResult` (success + new ClientId, or failure reason).
- `RemoveAppAsync(GraphServiceClient, string clientId, CancellationToken)` — Deletes the app object by clientId. Also cleans up service principal.
**Required Graph calls (inside `AppRegistrationService`):**
1. `POST /applications` — create the app with required `requiredResourceAccess` (SharePoint delegated scopes)
2. `POST /servicePrincipals` — create service principal for the new app so it can receive admin consent
3. `DELETE /applications/{id}` for removal
4. `DELETE /servicePrincipals/{id}` for service principal cleanup
### New Model: `AppRegistrationResult`
**`Core/Models/BrandingSettings.cs`**
```csharp
public class BrandingSettings
{
public string? MspLogoBase64 { get; set; }
public string? MspLogoMimeType { get; set; } // "image/png", "image/jpeg", etc.
}
// Core/Models/AppRegistrationResult.cs
public record AppRegistrationResult(
bool Success,
string? ClientId, // set when Success=true
string? ApplicationId, // object ID, needed for deletion
string? FailureReason // set when Success=false
);
```
Belongs in Core/Models alongside AppSettings. Kept separate — branding may grow independently of general app settings.
**`Core/Models/ReportBranding.cs`**
### Integration Point: `ProfileManagementViewModel`
This is the only ViewModel that changes. `ProfileManagementViewModel` already receives `GraphClientFactory`. Add:
- `IAppRegistrationService` injected via constructor
- `RegisterAppCommand` (IAsyncRelayCommand) — triggers auto-registration, falls back to guided mode on 403
- `RemoveAppCommand` (IAsyncRelayCommand) — available when `SelectedProfile != null && SelectedProfile.ClientId != null`
- `IsRegistering` observable bool for busy state
- `AppRegistrationStatus` observable string for feedback
**Data flow:**
```
ProfileManagementViewModel.RegisterAppCommand
→ GraphClientFactory.CreateClientAsync(currentMspClientId) // uses MSP's own clientId
→ AppRegistrationService.RegisterAppAsync(graphClient, tenantName)
→ POST /applications, POST /servicePrincipals
→ returns AppRegistrationResult
→ on success: populate NewClientId, surface "Copy ClientId" affordance
→ on 403: set guided fallback mode (show instructions panel)
→ on other error: set ValidationMessage
```
No new ViewModel is needed. The guided fallback is a conditional UI panel in `ProfileManagementDialog.xaml` controlled by a new `IsGuidedFallbackVisible` bool property on `ProfileManagementViewModel`.
### DI Registration (App.xaml.cs)
```csharp
public record ReportBranding(
string? MspLogoBase64,
string? MspLogoMimeType,
string? ClientLogoBase64,
string? ClientLogoMimeType);
services.AddTransient<IAppRegistrationService, AppRegistrationService>();
```
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`.
`ProfileManagementViewModel` registration remains `AddTransient`; the new interface is added to its constructor.
**`Services/BrandingService.cs`**
---
## Feature 2: Auto-Take Ownership on Access Denied
### What It Does
A global toggle in Settings. When enabled, if any SharePoint operation returns an access-denied error, the app automatically adds the authenticated account as a site collection administrator using the tenant admin API, then retries the operation.
### Tenant Admin API Mechanism (HIGH confidence from PnP Framework source)
PnP Framework's `Tenant` class (in `Microsoft.Online.SharePoint.TenantAdministration`) exposes site management. The pattern already used in `SiteListService` (which clones to the `-admin` URL) is exactly the right entry point.
To add self as admin:
```csharp
public class BrandingService
{
public Task<BrandingSettings> GetBrandingAsync();
public Task SetMspLogoAsync(string filePath); // reads file, detects MIME, converts to base64, saves
public Task ClearMspLogoAsync();
}
var tenant = new Tenant(adminCtx);
tenant.SetSiteAdmin(siteUrl, loginName, isAdmin: true);
adminCtx.ExecuteQueryAsync();
```
Thin orchestration, same pattern as `SettingsService`. MSP logo only — client logo is managed via `ProfileService` (it belongs to `TenantProfile`).
This does NOT require having access to the site — only SharePoint Admin role on the tenant, which the interactive login flow already acquires.
### Modified Components
### New Setting Property: `AppSettings.AutoTakeOwnership`
**`Core/Models/TenantProfile.cs`** — Add two nullable string properties:
```csharp
public string? ClientLogoBase64 { get; set; }
public string? ClientLogoMimeType { get; set; }
// Core/Models/AppSettings.cs — ADD property
public bool AutoTakeOwnership { get; set; } = false;
```
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>`:
This persists in `settings.json` automatically via `SettingsRepository`.
### New Service: `ISiteOwnershipService` / `SiteOwnershipService`
**Location:** `Services/SiteOwnershipService.cs` + `Services/ISiteOwnershipService.cs`
**Responsibility:** One method:
```csharp
Task AddCurrentUserAsSiteAdminAsync(
TenantProfile profile,
string siteUrl,
CancellationToken ct);
```
Uses `SessionManager` to get the authenticated context, clones to the admin URL (same pattern as `SiteListService.DeriveAdminUrl`), constructs `Tenant`, and calls `SetSiteAdmin`.
### Integration Point: `ExecuteQueryRetryHelper` or Caller Wrap
Rather than modifying `ExecuteQueryRetryHelper` (which is stateless and generic), the retry-with-ownership logic belongs in a per-operation wrapper:
1. Calls the operation
2. Catches `ServerException` with "Access Denied" message
3. If `AppSettings.AutoTakeOwnership == true`, calls `SiteOwnershipService.AddCurrentUserAsSiteAdminAsync`
4. Retries exactly once
5. If retry also fails, propagates the error with a message indicating ownership was attempted
**Recommended placement:** A new static helper `SiteAccessRetryHelper` in `Core/Helpers/`, wrapping CSOM executeQuery invocations in `PermissionsService`, `UserAccessAuditService`, and `SiteListService`. Each of these services already has an `IProgress<OperationProgress>` parameter and `CancellationToken` — the helper signature matches naturally.
### SettingsViewModel Changes
- Add `AutoTakeOwnership` observable bool property
- Wire to new `SettingsService.SetAutoTakeOwnershipAsync(bool)` method
- Bind to a checkbox in `SettingsView.xaml`
### DI Registration
```csharp
services.AddTransient<ISiteOwnershipService, SiteOwnershipService>();
```
---
## Feature 3: Expand Groups in HTML Reports
### What It Does
In the permissions HTML report, SharePoint group entries (where `PrincipalType == "SharePointGroup"`) currently show the group name as a single user pill. When expanded (click on the group), the report shows the individual group members.
### Data Model Change
`PermissionEntry` is a `record`. Group member data must be captured at scan time because the HTML report is self-contained offline — no live API calls from the browser are possible.
**Approach: Resolve at scan time.** During `PermissionsService.ExtractPermissionsAsync`, when `principalType == "SharePointGroup"`, load group members via CSOM and store them in a new optional field on `PermissionEntry`.
**Model change — additive, backward-compatible:**
```csharp
public record PermissionEntry(
// ... all existing parameters unchanged ...
string? GroupMembers = null // semicolon-joined login names; null when not a group or not expanded
);
```
Using a default parameter keeps all existing constructors and test data valid without changes.
### New Scan Option
```csharp
// Core/Models/ScanOptions.cs — ADD parameter with default
public record ScanOptions(
bool IncludeInherited,
bool ScanFolders,
int FolderDepth,
bool IncludeSubsites,
bool ExpandGroupMembers = false // NEW — defaults off
);
```
### Service Changes: `PermissionsService`
In `ExtractPermissionsAsync`, when `principalType == "SharePointGroup"` and `options.ExpandGroupMembers == true`:
```csharp
ctx.Load(ra.Member, m => m.Users.Include(u => u.LoginName, u => u.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var groupMembers = string.Join(";", ra.Member.Users.Select(u => u.LoginName));
```
This adds one CSOM round-trip per SharePoint group entry. Performance note: default is `false`.
### HTML Export Changes: `HtmlExportService`
When rendering user pills for an entry with `GroupMembers != null`, render the group name as an HTML5 `<details>/<summary>` expandable block. The `<details>/<summary>` element requires zero JavaScript, is self-contained, and is universally supported in all modern browsers (Chrome, Edge, Firefox, Safari) since 2016.
```html
<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>
<details class="group-expand">
<summary class="user-pill group-pill">Members Group Name</summary>
<div class="group-members">
<span class="user-pill">alice@contoso.com</span>
<span class="user-pill">bob@contoso.com</span>
</div>
</details>
```
When `branding` is null (existing callers) the block is omitted entirely. No behavior change for callers that do not pass branding.
`UserAccessHtmlExportService` gets the same treatment in the "Granted Through" column where group access is reported.
Affected services (all in `Services/Export/`):
- `HtmlExportService` (two `BuildHtml` overloads — `PermissionEntry` and `SimplifiedPermissionEntry`)
- `UserAccessHtmlExportService`
- `StorageHtmlExportService` (two `BuildHtml` overloads — with and without `FileTypeMetric`)
- `SearchHtmlExportService`
- `DuplicatesHtmlExportService`
### ViewModel Changes: `PermissionsViewModel`
**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:
- `<Image>` 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 <img> data URIs in header when base64 is non-null
→ writes HTML file
```
Add `ExpandGroupMembers` observable bool. Include in `ScanOptions` construction in `RunOperationAsync`. Add checkbox to `PermissionsView.xaml`.
---
## Feature 2: User Directory Browse Mode
## Feature 4: Report Entry Consolidation Toggle
### What It Needs
### What It Does
When a user appears in multiple SharePoint groups that all have access to the same object, they generate multiple `PermissionEntry` rows. The consolidation toggle merges rows for the same (Object, User) combination, joining permission levels and grant sources.
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`.
### Where Consolidation Lives
This is purely additive. The underlying audit logic (`IUserAccessAuditService`, `RunOperationAsync`, `SelectedUsers` collection, export commands) is completely unchanged.
This is a pure post-processing transformation on the already-collected `IReadOnlyList<PermissionEntry>`. It requires no new service, no CSOM calls, no Graph calls.
### New Components (create from scratch)
**Location:** New static helper class in `Core/Helpers/`:
**`Core/Models/PagedUserResult.cs`**
```csharp
public record PagedUserResult(
IReadOnlyList<GraphUserResult> Users,
string? NextPageToken); // null = last page
```
**`Services/IGraphUserDirectoryService.cs`**
```csharp
public interface IGraphUserDirectoryService
// Core/Helpers/PermissionConsolidator.cs
public static class PermissionConsolidator
{
Task<PagedUserResult> GetUsersPageAsync(
string clientId,
string? filter = null,
string? pageToken = null,
int pageSize = 100,
CancellationToken ct = default);
public static IReadOnlyList<PermissionEntry> Consolidate(
IReadOnlyList<PermissionEntry> entries);
}
```
**`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).
**Consolidation key:** `(ObjectType, Title, Url, UserLogin)` — one row per (object, user) pair across all login tokens in a semicolon-delimited `UserLogins` field.
### Modified Components
**Merge logic:**
- `PermissionLevels`: union of distinct values (semicolon-joined)
- `GrantedThrough`: all distinct grant sources joined (e.g., "Direct Permissions; SharePoint Group: X")
- `HasUniquePermissions`: true if any source entry has it true
- `Users`, `UserLogins`: from the first occurrence (same person)
- `PrincipalType`: from the first occurrence
**`ViewModels/Tabs/UserAccessAuditViewModel.cs`** — Add browse mode state:
`PermissionEntry` is a `record``PermissionConsolidator.Consolidate()` produces new instances, never mutates. Consistent with the existing pattern in `PermissionsViewModel` where `Results` is replaced wholesale.
**For `SimplifiedPermissionEntry`:** Consolidation applies to `PermissionEntry` first; `SimplifiedPermissionEntry.WrapAll()` then operates on the consolidated list. No changes to `SimplifiedPermissionEntry` needed.
### ViewModel Changes: `PermissionsViewModel`
Add `ConsolidateEntries` observable bool property. In `RunOperationAsync`, after collecting `allEntries`:
```csharp
[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; }
if (ConsolidateEntries)
allEntries = PermissionConsolidator.Consolidate(allEntries).ToList();
```
`partial void OnIsBrowseModeActiveChanged(bool value)` → when `value == true`, fire `LoadDirectoryCommand` to populate page 1.
The export commands (`ExportCsvCommand`, `ExportHtmlCommand`) already consume `Results`, so consolidated data flows into all export formats automatically. No export service changes required for this feature.
`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<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 `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<GraphUserResult>`) to `AddDirectoryUsersCommand`. Follow the existing `SearchResultsListBox_SelectionChanged` pattern.
### Data Flow: Directory Browse Mode
## Component Dependency Map
```
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
NEW COMPONENT DEPENDS ON (existing unless marked new)
──────────────────────────────────────────────────────────────────────────
AppRegistrationResult (model) — none
AppSettings.AutoTakeOwnership AppSettings (existing model)
ScanOptions.ExpandGroupMembers ScanOptions (existing model)
PermissionEntry.GroupMembers PermissionEntry (existing record)
User types in DirectoryFilter
→ debounce 300ms
→ LoadDirectoryCommand re-fires with filter
→ DirectoryUsers replaced with filtered page 1
PermissionConsolidator PermissionEntry (existing)
User selects users in ListView + clicks "Add Selected"
→ AddDirectoryUsersCommand(selectedItems)
→ for each: if not already in SelectedUsers by UPN → SelectedUsers.Add(user)
IAppRegistrationService —
AppRegistrationService GraphServiceClient (existing via GraphClientFactory)
Microsoft.Graph SDK (existing)
User clicks "Load more"
→ LoadMoreDirectoryCommand
→ GetUsersPageAsync(clientId, currentFilter, _directoryNextPageToken, 100, ct)
→ DirectoryUsers items appended (not replaced)
→ _directoryNextPageToken updated
ISiteOwnershipService —
SiteOwnershipService SessionManager (existing)
TenantProfile (existing)
Tenant CSOM class (existing via PnP Framework)
SiteListService.DeriveAdminUrl pattern (existing)
SettingsService (modified) AppSettings (existing + new field)
PermissionsService (modified) ScanOptions.ExpandGroupMembers (new field)
ExecuteQueryRetryHelper (existing)
HtmlExportService (modified) PermissionEntry.GroupMembers (new field)
BrandingHtmlHelper (existing)
ProfileManagementViewModel (mod) IAppRegistrationService (new)
PermissionsViewModel (modified) ExpandGroupMembers, ConsolidateEntries, PermissionConsolidator
SettingsViewModel (modified) AutoTakeOwnership, SettingsService new method
```
---
## Component Boundary Summary
## Suggested Build Order
### New Components (create)
Dependencies flow upward; each step can be tested before the next begins.
| 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 |
### Step 1: Model additions
No external dependencies. All existing tests continue to pass.
- `AppRegistrationResult` record (new file)
- `AppSettings.AutoTakeOwnership` bool property (default false)
- `ScanOptions.ExpandGroupMembers` bool parameter (default false)
- `PermissionEntry.GroupMembers` optional string parameter (default null)
Total new files: 7
### Step 2: Pure-logic helper
Fully unit-testable with no services.
- `PermissionConsolidator` in `Core/Helpers/`
### Modified Components (extend)
### Step 3: New services
Depend only on existing infrastructure (SessionManager, GraphClientFactory).
- `ISiteOwnershipService` + `SiteOwnershipService`
- `IAppRegistrationService` + `AppRegistrationService`
| 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 |
### Step 4: SettingsService extension
Thin method addition, no structural change.
- `SetAutoTakeOwnershipAsync(bool)` on existing `SettingsService`
Total modified files: 17
### Step 5: PermissionsService modification
- Group member CSOM load in `ExtractPermissionsAsync` (guarded by `ExpandGroupMembers`)
- Access-denied retry using `SiteOwnershipService` (guarded by `AutoTakeOwnership`)
### Step 6: Export service modifications
- `HtmlExportService.BuildHtml`: `<details>/<summary>` rendering for `GroupMembers`
- `UserAccessHtmlExportService.BuildHtml`: same for group access entries
### Step 7: ViewModel modifications
- `SettingsViewModel`: `AutoTakeOwnership` property wired to `SettingsService`
- `PermissionsViewModel`: `ExpandGroupMembers`, `ConsolidateEntries`, updated `ScanOptions`
- `ProfileManagementViewModel`: `IAppRegistrationService` injection, `RegisterAppCommand`, `RemoveAppCommand`, guided fallback state
### Step 8: View/XAML additions
- `SettingsView.xaml`: AutoTakeOwnership checkbox
- `PermissionsView.xaml`: ExpandGroupMembers checkbox, ConsolidateEntries checkbox
- `ProfileManagementDialog.xaml`: Register App button, Remove App button, guided fallback panel
### Step 9: DI wiring (App.xaml.cs)
- Register `IAppRegistrationService`, `ISiteOwnershipService`
- `ProfileManagementViewModel` constructor change is picked up automatically (AddTransient)
---
## Build Order (Dependency-Aware)
## New vs. Modified Summary
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.
| Component | Status | Layer |
|-----------|--------|-------|
| `AppRegistrationResult` | NEW | Core/Models |
| `AppSettings.AutoTakeOwnership` | MODIFIED | Core/Models |
| `ScanOptions.ExpandGroupMembers` | MODIFIED | Core/Models |
| `PermissionEntry.GroupMembers` | MODIFIED | Core/Models |
| `PermissionConsolidator` | NEW | Core/Helpers |
| `IAppRegistrationService` | NEW | Services |
| `AppRegistrationService` | NEW | Services |
| `ISiteOwnershipService` | NEW | Services |
| `SiteOwnershipService` | NEW | Services |
| `SettingsService.SetAutoTakeOwnershipAsync` | MODIFIED | Services |
| `PermissionsService.ExtractPermissionsAsync` | MODIFIED | Services |
| `HtmlExportService.BuildHtml` | MODIFIED | Services/Export |
| `UserAccessHtmlExportService.BuildHtml` | MODIFIED | Services/Export |
| `ProfileManagementViewModel` | MODIFIED | ViewModels |
| `PermissionsViewModel` | MODIFIED | ViewModels/Tabs |
| `SettingsViewModel` | MODIFIED | ViewModels/Tabs |
| `ProfileManagementDialog.xaml` | MODIFIED | Views/Dialogs |
| `PermissionsView.xaml` | MODIFIED | Views/Tabs |
| `SettingsView.xaml` | MODIFIED | Views/Tabs |
| `App.xaml.cs RegisterServices` | MODIFIED | Root |
### 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 `<img>` 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.
**No new tabs. No new XAML files. No new dialog windows required.** All four features extend existing surfaces.
---
## Anti-Patterns to Avoid
## Critical Integration Notes
### 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.
### App Registration: Permission Prerequisite
The auto-registration path requires `Application.ReadWrite.All` to be granted and admin-consented on the MSP's own client app registration. The tool cannot bootstrap this permission itself. The guided fallback path is the safe default — auto path is an enhancement for pre-prepared deployments. Catch `ODataError` with `ResponseStatusCode == 403` to trigger the fallback automatically.
### 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.
### Auto-Ownership: Retry Once, Not Infinitely
Retry exactly once per site. If the second attempt fails (account lacks tenant admin rights), propagate the original error with a clear message indicating that ownership take-over was attempted. Log both attempts via `ILogger`.
### 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.
### Group Expansion: Scan Performance Impact
Loading group members adds one CSOM round-trip per unique SharePoint group encountered. The `ExpandGroupMembers` toggle must default to `false` and be labeled clearly in the UI (e.g., "Expand group members in report (slower scan)"). On tenants with many groups across many sites, this could multiply scan time significantly.
### 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.
### Consolidation: Records Are Immutable
`PermissionEntry` is a `record`. `PermissionConsolidator.Consolidate()` produces new record instances — no mutation. Consistent with how `Results` is already replaced wholesale in `PermissionsViewModel`.
### 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`.
### HTML `<details>/<summary>` Compatibility
Self-contained HTML reports target any modern browser. `<details>/<summary>` is fully supported without JavaScript since 2016 across all major browsers. This is the correct choice over adding onclick JS toggle logic.
### 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 |
### No Breaking Changes to Existing Tests
All model changes use optional parameters with defaults. Existing test data and constructors remain valid. `PermissionConsolidator` and `SiteOwnershipService` are new testable units that can use the existing `InternalsVisibleTo` pattern for test access.
---
## 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`
- Microsoft Graph permissions reference: https://learn.microsoft.com/en-us/graph/permissions-reference
- Graph API grant permissions programmatically: https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph
- PnP Core SDK site security (SetSiteCollectionAdmins): https://pnp.github.io/pnpcore/using-the-sdk/admin-sharepoint-security.html
- PnP Framework TenantExtensions source: https://github.com/pnp/PnP-Sites-Core/blob/master/Core/OfficeDevPnP.Core/Extensions/TenantExtensions.cs