docs: start milestone v2.2 Report Branding & User Directory
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,581 +1,443 @@
|
||||
# Architecture Research
|
||||
# Architecture Patterns
|
||||
|
||||
**Domain:** C#/WPF SharePoint Online Administration Desktop Tool
|
||||
**Researched:** 2026-04-02
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Standard Architecture
|
||||
|
||||
### System Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ PRESENTATION LAYER │
|
||||
│ ┌──────────────┐ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ MainWindow │ │ Feature Views (XAML) │ │
|
||||
│ │ Shell.xaml │ │ Permissions │ Storage │ Search │ Templates │ │
|
||||
│ │ │ │ Duplicates │ Bulk │ Reports │ Settings │ │
|
||||
│ └──────┬───────┘ └──────────────────────┬────────────────────┘ │
|
||||
│ │ DataContext binding │ DataContext binding │
|
||||
├─────────┴─────────────────────────────────┴────────────────────────┤
|
||||
│ VIEWMODEL LAYER │
|
||||
│ ┌─────────────┐ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ MainWindow │ │ Feature ViewModels │ │
|
||||
│ │ ViewModel │ │ PermissionsVM │ StorageVM │ SearchVM │ │
|
||||
│ │ (nav/shell)│ │ TemplatesVM │ BulkOpsVM │ DuplicatesVM │ │
|
||||
│ └──────┬──────┘ └───────────────────────┬──────────────────────┘ │
|
||||
│ │ ICommand, ObservableProperty │ AsyncRelayCommand │
|
||||
├─────────┴─────────────────────────────────┴────────────────────────┤
|
||||
│ SERVICE LAYER │
|
||||
│ ┌────────────────┐ ┌─────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ AuthService │ │ SharePoint │ │ Cross-Cutting │ │
|
||||
│ │ SessionManager │ │ Feature Services │ │ Services │ │
|
||||
│ │ TenantSession │ │ PermissionsService│ │ ReportExportService │ │
|
||||
│ │ │ │ StorageService │ │ LocalizationService │ │
|
||||
│ │ │ │ SearchService │ │ DialogService │ │
|
||||
│ │ │ │ TemplateService │ │ SettingsService │ │
|
||||
│ └────────┬───────┘ └────────┬────────┘ └──────────────────────┘ │
|
||||
│ │ ClientContext │ IProgress<T>, CancellationToken │
|
||||
├───────────┴────────────────────┴────────────────────────────────────┤
|
||||
│ INFRASTRUCTURE / INTEGRATION LAYER │
|
||||
│ ┌──────────────────┐ ┌───────────────────┐ ┌──────────────────┐ │
|
||||
│ │ PnP Framework │ │ Microsoft Graph │ │ Local Storage │ │
|
||||
│ │ AuthManager │ │ GraphServiceClient │ │ JSON Files │ │
|
||||
│ │ ClientContext │ │ (Graph operations) │ │ Profiles │ │
|
||||
│ │ (CSOM ops) │ │ │ │ Templates │ │
|
||||
│ └──────────────────┘ └───────────────────┘ └──────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
| Component | Responsibility | Typical Implementation |
|
||||
|-----------|----------------|------------------------|
|
||||
| MainWindow Shell | Tab navigation, tenant selector, app chrome, log panel | XAML with TabControl or navigation frame |
|
||||
| Feature Views | User input forms, result grids, progress indicators | UserControl XAML, zero code-behind |
|
||||
| Feature ViewModels | Commands, observable state, orchestrates services | ObservableObject subclass, AsyncRelayCommand |
|
||||
| AuthService / SessionManager | Multi-tenant session lifecycle, token cache, active tenant state | Singleton, MSAL token cache per tenant |
|
||||
| TenantSession | Per-tenant PnP ClientContext + auth token | Immutable record, created by AuthService |
|
||||
| SharePoint Feature Services | Domain logic that calls PnP Framework or Graph | Stateless class, injectable, cancellable |
|
||||
| ReportExportService | HTML/CSV generation from result models | Stateless, template-based string builder |
|
||||
| LocalizationService | Key-based EN/FR translation, dynamic language switch | Singleton, loads lang/*.json, INotifyPropertyChanged |
|
||||
| SettingsService | Read/write JSON settings, profiles, templates | Singleton, file I/O wrapped in async |
|
||||
| DialogService | Open files, show message boxes, pick folders | Interface + WPF implementation, testable |
|
||||
**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
|
||||
|
||||
---
|
||||
|
||||
## Recommended Project Structure
|
||||
## Existing Architecture (Baseline)
|
||||
|
||||
```
|
||||
SharepointToolbox/
|
||||
├── App.xaml # Application entry, DI container bootstrap
|
||||
├── App.xaml.cs # Host builder, service registration
|
||||
│
|
||||
├── Core/ # Domain models — no WPF dependencies
|
||||
│ ├── Models/
|
||||
│ │ ├── PermissionEntry.cs
|
||||
│ │ ├── StorageMetrics.cs
|
||||
│ │ ├── SiteTemplate.cs
|
||||
│ │ ├── TenantProfile.cs
|
||||
│ │ └── SearchResult.cs
|
||||
│ ├── Interfaces/
|
||||
│ │ ├── IAuthService.cs
|
||||
│ │ ├── IPermissionsService.cs
|
||||
│ │ ├── IStorageService.cs
|
||||
│ │ ├── ISearchService.cs
|
||||
│ │ ├── ITemplateService.cs
|
||||
│ │ ├── IBulkOpsService.cs
|
||||
│ │ ├── IDuplicateService.cs
|
||||
│ │ ├── IReportExportService.cs
|
||||
│ │ ├── ISettingsService.cs
|
||||
│ │ ├── ILocalizationService.cs
|
||||
│ │ └── IDialogService.cs
|
||||
│ └── Exceptions/
|
||||
│ ├── SharePointConnectionException.cs
|
||||
│ └── AuthenticationException.cs
|
||||
│
|
||||
├── Services/ # Business logic + infrastructure
|
||||
│ ├── Auth/
|
||||
│ │ ├── AuthService.cs # PnP AuthenticationManager wrapper
|
||||
│ │ ├── SessionManager.cs # Multi-tenant session store
|
||||
│ │ └── TenantSession.cs # Per-tenant PnP ClientContext holder
|
||||
│ ├── SharePoint/
|
||||
│ │ ├── PermissionsService.cs # Recursive permission scanning
|
||||
│ │ ├── StorageService.cs # Storage metric traversal
|
||||
│ │ ├── SearchService.cs # KQL-based search via PnP/Graph
|
||||
│ │ ├── TemplateService.cs # Capture & apply site templates
|
||||
│ │ ├── DuplicateService.cs # File/folder duplicate detection
|
||||
│ │ └── BulkOpsService.cs # Transfer, site creation, member add
|
||||
│ ├── Reporting/
|
||||
│ │ ├── HtmlReportService.cs # Self-contained HTML + JS reports
|
||||
│ │ └── CsvExportService.cs # CSV export
|
||||
│ ├── LocalizationService.cs # EN/FR key-value translations
|
||||
│ ├── SettingsService.cs # JSON profiles, templates, settings
|
||||
│ └── DialogService.cs # WPF dialog abstractions
|
||||
│
|
||||
├── ViewModels/ # WPF-aware but UI-framework-agnostic
|
||||
│ ├── MainWindowViewModel.cs # Shell nav, tenant switcher, log
|
||||
│ ├── Permissions/
|
||||
│ │ └── PermissionsViewModel.cs
|
||||
│ ├── Storage/
|
||||
│ │ └── StorageViewModel.cs
|
||||
│ ├── Search/
|
||||
│ │ └── SearchViewModel.cs
|
||||
│ ├── Templates/
|
||||
│ │ └── TemplatesViewModel.cs
|
||||
│ ├── Duplicates/
|
||||
│ │ └── DuplicatesViewModel.cs
|
||||
│ ├── BulkOps/
|
||||
│ │ └── BulkOpsViewModel.cs
|
||||
│ └── Settings/
|
||||
│ └── SettingsViewModel.cs
|
||||
│
|
||||
├── Views/ # XAML — no business logic
|
||||
│ ├── MainWindow.xaml
|
||||
│ ├── Permissions/
|
||||
│ │ └── PermissionsView.xaml
|
||||
│ ├── Storage/
|
||||
│ │ └── StorageView.xaml
|
||||
│ ├── Search/
|
||||
│ │ └── SearchView.xaml
|
||||
│ ├── Templates/
|
||||
│ │ └── TemplatesView.xaml
|
||||
│ ├── Duplicates/
|
||||
│ │ └── DuplicatesView.xaml
|
||||
│ ├── BulkOps/
|
||||
│ │ └── BulkOpsView.xaml
|
||||
│ └── Settings/
|
||||
│ └── SettingsView.xaml
|
||||
│
|
||||
├── Controls/ # Reusable WPF controls
|
||||
│ ├── TenantSelectorControl.xaml
|
||||
│ ├── LogPanelControl.xaml
|
||||
│ ├── ProgressOverlayControl.xaml
|
||||
│ └── StorageChartControl.xaml # LiveCharts2 wrapper
|
||||
│
|
||||
├── Converters/ # IValueConverter implementations
|
||||
│ ├── BytesToStringConverter.cs
|
||||
│ ├── BoolToVisibilityConverter.cs
|
||||
│ └── PermissionColorConverter.cs
|
||||
│
|
||||
├── Resources/ # Styles, brushes, theme
|
||||
│ ├── Styles.xaml
|
||||
│ └── Colors.xaml
|
||||
│
|
||||
├── Lang/ # Language files
|
||||
│ ├── en.json
|
||||
│ └── fr.json
|
||||
│
|
||||
└── Infrastructure/
|
||||
└── Behaviors/ # XAML attached behaviors (no code-behind workaround)
|
||||
└── ScrollToBottomBehavior.cs
|
||||
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
|
||||
```
|
||||
|
||||
### Structure Rationale
|
||||
### Key Patterns Already Established
|
||||
|
||||
- **Core/**: Pure C# — no WPF references. Interfaces here make services testable. Models are plain data classes.
|
||||
- **Services/**: All domain logic and I/O. Injected via constructor DI. No static state.
|
||||
- **ViewModels/**: Mirror the feature structure. Depend on service interfaces, never on concrete implementations.
|
||||
- **Views/**: XAML-only. No logic. `DataContext` set by DI or ViewModelLocator pattern at startup.
|
||||
- **Controls/**: Reusable UI widgets that encapsulate chart, log, and progress concerns.
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## Architectural Patterns
|
||||
## Feature 1: Report Branding (MSP/Client Logos in HTML Reports)
|
||||
|
||||
### Pattern 1: ObservableObject + AsyncRelayCommand (CommunityToolkit.Mvvm)
|
||||
### What It Needs
|
||||
|
||||
**What:** Use `ObservableObject` as base class for all ViewModels. Use `[ObservableProperty]` source-gen attribute for bindable properties. Use `AsyncRelayCommand` (with `CancellationToken`) for all SharePoint operations.
|
||||
- **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
|
||||
|
||||
**When to use:** All ViewModels. This is the standard pattern for .NET 8 + WPF.
|
||||
### New Components (create from scratch)
|
||||
|
||||
**Trade-offs:** Source generators require C# 10+. Generated partial class syntax is unfamiliar at first but eliminates 80% of boilerplate.
|
||||
|
||||
**Example:**
|
||||
**`Core/Models/BrandingSettings.cs`**
|
||||
```csharp
|
||||
public partial class PermissionsViewModel : ObservableObject
|
||||
public class BrandingSettings
|
||||
{
|
||||
private readonly IPermissionsService _permissionsService;
|
||||
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.
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isRunning;
|
||||
**`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.
|
||||
|
||||
[ObservableProperty]
|
||||
private string _statusMessage = string.Empty;
|
||||
**`Infrastructure/Persistence/BrandingRepository.cs`**
|
||||
Same pattern as `SettingsRepository`: constructor takes `string filePath`, atomic write-then-replace, `SemaphoreSlim` write lock. File: `%AppData%\SharepointToolbox\branding.json`.
|
||||
|
||||
[ObservableProperty]
|
||||
private ObservableCollection<PermissionEntry> _results = new();
|
||||
**`Services/BrandingService.cs`**
|
||||
```csharp
|
||||
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`).
|
||||
|
||||
public IAsyncRelayCommand RunReportCommand { get; }
|
||||
### Modified Components
|
||||
|
||||
public PermissionsViewModel(IPermissionsService permissionsService)
|
||||
{
|
||||
_permissionsService = permissionsService;
|
||||
RunReportCommand = new AsyncRelayCommand(RunReportAsync, allowConcurrentExecutions: false);
|
||||
}
|
||||
**`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.
|
||||
|
||||
private async Task RunReportAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
IsRunning = true;
|
||||
StatusMessage = "Scanning permissions...";
|
||||
try
|
||||
{
|
||||
var results = await _permissionsService.ScanAsync(
|
||||
SiteUrl, cancellationToken,
|
||||
new Progress<string>(msg => StatusMessage = msg));
|
||||
Results = new ObservableCollection<PermissionEntry>(results);
|
||||
}
|
||||
finally { IsRunning = false; }
|
||||
}
|
||||
**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>`:
|
||||
|
||||
```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>
|
||||
```
|
||||
|
||||
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:
|
||||
- `<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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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<GraphUserResult> Users,
|
||||
string? NextPageToken); // null = last page
|
||||
```
|
||||
|
||||
**`Services/IGraphUserDirectoryService.cs`**
|
||||
```csharp
|
||||
public interface IGraphUserDirectoryService
|
||||
{
|
||||
Task<PagedUserResult> GetUsersPageAsync(
|
||||
string clientId,
|
||||
string? filter = null,
|
||||
string? pageToken = null,
|
||||
int pageSize = 100,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Multi-Tenant Session Manager
|
||||
**`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).
|
||||
|
||||
**What:** A singleton `SessionManager` holds a dictionary of `TenantSession` objects keyed by tenant URL. When the user selects a tenant profile, the session is reused if still valid (MSAL token cache handles token refresh). No re-authentication unless the token is expired and silent refresh fails.
|
||||
### Modified Components
|
||||
|
||||
**When to use:** Every SharePoint service operation resolves `IAuthService.GetSessionAsync(tenantUrl)` before calling PnP Framework.
|
||||
**`ViewModels/Tabs/UserAccessAuditViewModel.cs`** — Add browse mode state:
|
||||
|
||||
**Trade-offs:** MSAL token cache must be persisted across app restarts for seamless reconnect. For interactive login, MSAL `PublicClientApplicationBuilder` with `WithParentActivityOrWindow` is required on Windows to avoid a blank browser window.
|
||||
|
||||
**Example:**
|
||||
```csharp
|
||||
public class SessionManager
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, TenantSession> _sessions = new();
|
||||
[ObservableProperty] private bool _isBrowseModeActive;
|
||||
[ObservableProperty] private ObservableCollection<GraphUserResult> _directoryUsers = new();
|
||||
[ObservableProperty] private string _directoryFilter = string.Empty;
|
||||
[ObservableProperty] private bool _isLoadingDirectory;
|
||||
[ObservableProperty] private bool _hasMoreDirectoryPages;
|
||||
|
||||
public async Task<TenantSession> GetOrCreateSessionAsync(
|
||||
TenantProfile profile, CancellationToken ct)
|
||||
{
|
||||
if (_sessions.TryGetValue(profile.TenantUrl, out var session)
|
||||
&& !session.IsExpired)
|
||||
return session;
|
||||
private string? _directoryNextPageToken;
|
||||
|
||||
var authManager = new PnP.Framework.AuthenticationManager(
|
||||
profile.ClientId,
|
||||
openBrowserCallback: url => Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }));
|
||||
|
||||
var ctx = await authManager.GetContextAsync(profile.TenantUrl);
|
||||
var newSession = new TenantSession(profile, ctx, authManager);
|
||||
_sessions[profile.TenantUrl] = newSession;
|
||||
return newSession;
|
||||
}
|
||||
}
|
||||
public IAsyncRelayCommand LoadDirectoryCommand { get; }
|
||||
public IAsyncRelayCommand LoadMoreDirectoryCommand { get; }
|
||||
public RelayCommand<IList<GraphUserResult>> AddDirectoryUsersCommand { get; }
|
||||
```
|
||||
|
||||
### Pattern 3: IProgress\<T\> + CancellationToken for All Long Operations
|
||||
`partial void OnIsBrowseModeActiveChanged(bool value)` → when `value == true`, fire `LoadDirectoryCommand` to populate page 1.
|
||||
|
||||
**What:** Every service method that calls SharePoint accepts `IProgress<OperationProgress>` and `CancellationToken`. The ViewModel creates `Progress<T>` (which marshals callbacks to the UI thread automatically) and `CancellationTokenSource`.
|
||||
`partial void OnDirectoryFilterChanged(string value)` → debounce 300ms (same pattern as `OnSearchQueryChanged`), re-fire `LoadDirectoryCommand` with new filter, clear `_directoryNextPageToken`.
|
||||
|
||||
**When to use:** All SharePoint service methods. This replaces the PowerShell runspace + timer polling pattern from the existing app.
|
||||
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.
|
||||
|
||||
**Trade-offs:** `Progress<T>` captures SynchronizationContext on creation — must be created on the UI thread (i.e., inside the ViewModel, not inside the service).
|
||||
|
||||
**Example:**
|
||||
**`App.xaml.cs RegisterServices()`** — Add:
|
||||
```csharp
|
||||
// In ViewModel (UI thread context):
|
||||
var cts = new CancellationTokenSource();
|
||||
CancelCommand = new RelayCommand(() => cts.Cancel());
|
||||
var progress = new Progress<OperationProgress>(p => StatusMessage = p.Message);
|
||||
|
||||
// In Service (any thread):
|
||||
public async Task<IList<PermissionEntry>> ScanAsync(
|
||||
string siteUrl,
|
||||
CancellationToken ct,
|
||||
IProgress<OperationProgress> progress)
|
||||
{
|
||||
progress.Report(new OperationProgress("Connecting..."));
|
||||
using var ctx = await _sessionManager.GetOrCreateSessionAsync(..., ct);
|
||||
// ... recursive scanning ...
|
||||
ct.ThrowIfCancellationRequested();
|
||||
progress.Report(new OperationProgress($"Found {results.Count} entries"));
|
||||
return results;
|
||||
}
|
||||
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
|
||||
```
|
||||
The `UserAccessAuditViewModel` transient registration picks up the new injection automatically (DI resolves by type).
|
||||
|
||||
### Pattern 4: Messenger for Cross-ViewModel Events
|
||||
**`UserAccessAuditViewModel.OnTenantSwitched`** — Also clear `DirectoryUsers`, reset `_directoryNextPageToken`, `HasMoreDirectoryPages`, `IsLoadingDirectory`.
|
||||
|
||||
**What:** Use `CommunityToolkit.Mvvm.Messaging.WeakReferenceMessenger` for decoupled communication between ViewModels (e.g., "tenant switched" notifies all feature VMs to reset state, "log entry added" updates the log panel ViewModel).
|
||||
**`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`
|
||||
|
||||
**When to use:** When two ViewModels need to communicate without direct reference (shell ↔ feature VMs, service callbacks ↔ log panel).
|
||||
**`Views/Tabs/UserAccessAuditView.xaml.cs`** — Add `SelectionChanged` handler to pass `ListView.SelectedItems` (as `IList<GraphUserResult>`) to `AddDirectoryUsersCommand`. Follow the existing `SearchResultsListBox_SelectionChanged` pattern.
|
||||
|
||||
**Trade-offs:** Weak references mean recipients must be alive (held by DI container). Don't use for per-request data passing — use method return values for that.
|
||||
### Data Flow: Directory Browse Mode
|
||||
|
||||
### Pattern 5: Dependency Injection via Microsoft.Extensions.Hosting
|
||||
```
|
||||
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
|
||||
|
||||
**What:** Bootstrap the app with `Host.CreateDefaultBuilder()` in `App.xaml.cs`. Register all services, ViewModels, and the main window in the DI container. Use constructor injection everywhere — no service locator anti-pattern.
|
||||
User types in DirectoryFilter
|
||||
→ debounce 300ms
|
||||
→ LoadDirectoryCommand re-fires with filter
|
||||
→ DirectoryUsers replaced with filtered page 1
|
||||
|
||||
**Example:**
|
||||
```csharp
|
||||
// App.xaml.cs
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
_host = Host.CreateDefaultBuilder()
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
// Core services (singletons)
|
||||
services.AddSingleton<ISettingsService, SettingsService>();
|
||||
services.AddSingleton<ILocalizationService, LocalizationService>();
|
||||
services.AddSingleton<SessionManager>();
|
||||
services.AddSingleton<IAuthService, AuthService>();
|
||||
services.AddSingleton<IDialogService, DialogService>();
|
||||
User selects users in ListView + clicks "Add Selected"
|
||||
→ AddDirectoryUsersCommand(selectedItems)
|
||||
→ for each: if not already in SelectedUsers by UPN → SelectedUsers.Add(user)
|
||||
|
||||
// Feature services (transient — no shared state)
|
||||
services.AddTransient<IPermissionsService, PermissionsService>();
|
||||
services.AddTransient<IStorageService, StorageService>();
|
||||
services.AddTransient<ISearchService, SearchService>();
|
||||
|
||||
// ViewModels
|
||||
services.AddTransient<MainWindowViewModel>();
|
||||
services.AddTransient<PermissionsViewModel>();
|
||||
services.AddTransient<StorageViewModel>();
|
||||
|
||||
// Views
|
||||
services.AddSingleton<MainWindow>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
_host.Start();
|
||||
var mainWindow = _host.Services.GetRequiredService<MainWindow>();
|
||||
mainWindow.DataContext = _host.Services.GetRequiredService<MainWindowViewModel>();
|
||||
mainWindow.Show();
|
||||
}
|
||||
User clicks "Load more"
|
||||
→ LoadMoreDirectoryCommand
|
||||
→ GetUsersPageAsync(clientId, currentFilter, _directoryNextPageToken, 100, ct)
|
||||
→ DirectoryUsers items appended (not replaced)
|
||||
→ _directoryNextPageToken updated
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
## Component Boundary Summary
|
||||
|
||||
### SharePoint Operation Request Flow
|
||||
### New Components (create)
|
||||
|
||||
```
|
||||
User clicks "Run" button
|
||||
↓
|
||||
View command binding triggers AsyncRelayCommand.ExecuteAsync()
|
||||
↓
|
||||
ViewModel validates inputs → creates CancellationTokenSource + Progress<T>
|
||||
↓
|
||||
ViewModel calls IFeatureService.ScanAsync(params, ct, progress)
|
||||
↓
|
||||
Service calls SessionManager.GetOrCreateSessionAsync(profile, ct)
|
||||
↓
|
||||
SessionManager checks cache → reuses token or triggers interactive login
|
||||
↓
|
||||
Service executes PnP Framework / Graph SDK calls (async, awaited)
|
||||
↓
|
||||
Service reports incremental progress → Progress<T>.Report() → UI thread
|
||||
↓
|
||||
Service returns result collection to ViewModel
|
||||
↓
|
||||
ViewModel updates ObservableCollection → WPF binding refreshes DataGrid
|
||||
↓
|
||||
ViewModel sets IsRunning = false → progress overlay hides
|
||||
```
|
||||
| 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 |
|
||||
|
||||
### Authentication & Session Flow
|
||||
Total new files: 7
|
||||
|
||||
```
|
||||
User selects tenant profile from dropdown
|
||||
↓
|
||||
MainWindowViewModel calls SessionManager.SetActiveProfile(profile)
|
||||
↓
|
||||
SessionManager publishes TenantChangedMessage via WeakReferenceMessenger
|
||||
↓
|
||||
All feature ViewModels receive message → reset their state/results
|
||||
↓
|
||||
On first operation: SessionManager.GetOrCreateSessionAsync()
|
||||
↓
|
||||
[Cache hit: token valid] → return existing ClientContext immediately
|
||||
[Cache miss / expired] → PnP AuthManager.GetContextAsync()
|
||||
↓
|
||||
MSAL silent token refresh attempt
|
||||
↓
|
||||
[Silent fails] → open browser for interactive login
|
||||
↓
|
||||
User authenticates → token cached by MSAL
|
||||
↓
|
||||
ClientContext returned to caller
|
||||
```
|
||||
### Modified Components (extend)
|
||||
|
||||
### Report Export Flow
|
||||
| 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 |
|
||||
|
||||
```
|
||||
Service returns List<TModel> to ViewModel
|
||||
↓
|
||||
User clicks "Export CSV" or "Export HTML"
|
||||
↓
|
||||
ViewModel calls IReportExportService.ExportAsync(results, format, outputPath)
|
||||
↓
|
||||
ReportExportService generates file (string building, no blocking I/O on UI thread)
|
||||
↓
|
||||
ViewModel calls IDialogService.OpenFile(outputPath) to auto-open result
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
```
|
||||
AppState (DI-managed singletons):
|
||||
SessionManager → active profile, tenant sessions dict
|
||||
SettingsService → user prefs, data folder, profiles list
|
||||
LocalizationService → current language, translation dict
|
||||
|
||||
Per-Operation State (ViewModel-local):
|
||||
ObservableCollection<T> → bound to DataGrid
|
||||
CancellationTokenSource → cancel button binding
|
||||
IsRunning (bool) → progress overlay binding
|
||||
StatusMessage (string) → progress label binding
|
||||
```
|
||||
Total modified files: 17
|
||||
|
||||
---
|
||||
|
||||
## Component Boundaries
|
||||
## Build Order (Dependency-Aware)
|
||||
|
||||
### What Communicates With What
|
||||
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.
|
||||
|
||||
| Boundary | Communication Method | Direction | Notes |
|
||||
|----------|---------------------|-----------|-------|
|
||||
| View ↔ ViewModel | WPF data binding (two-way for inputs, one-way for results) | Both | No code-behind |
|
||||
| ViewModel ↔ Service | Constructor-injected interface, async method call | VM → Service | Services return Task\<T\> |
|
||||
| ViewModel ↔ ViewModel | WeakReferenceMessenger messages | Broadcast | Tenant switch, log events |
|
||||
| Service ↔ SessionManager | `GetOrCreateSessionAsync()` | Service → SessionMgr | Every SharePoint call |
|
||||
| SessionManager ↔ PnP Framework | `AuthenticationManager.GetContextAsync()` | SessionMgr → PnP | On cache miss only |
|
||||
| Service ↔ Graph SDK | `GraphServiceClient` method calls | Service → Graph | For Graph-only operations |
|
||||
| SettingsService ↔ FileSystem | `System.Text.Json` + `File.ReadAllText/WriteAllText` | Both | Async I/O |
|
||||
| LocalizationService ↔ Views | XAML binding to translated string properties | Service → View | Via singleton binding |
|
||||
### 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)
|
||||
|
||||
### What Must NOT Cross Boundaries
|
||||
All files are POCOs/records. Unit-testable in isolation. No risk.
|
||||
|
||||
- Views must not call services directly — all via ViewModel commands
|
||||
- Services must not reference any WPF types (`System.Windows.*`) — use `IProgress<T>` for UI feedback
|
||||
- ViewModels must not instantiate `ClientContext` or `AuthenticationManager` directly — only via `IAuthService`
|
||||
- SessionManager is the only class that holds `ClientContext` objects — services receive them per-operation
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## Build Order (Dependency Graph)
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
The following reflects the order components can be built because later items depend on earlier ones:
|
||||
### 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.
|
||||
|
||||
```
|
||||
Phase 1: Foundation
|
||||
└── Core/Models/* (no dependencies)
|
||||
└── Core/Interfaces/* (no dependencies)
|
||||
└── Core/Exceptions/* (no dependencies)
|
||||
### 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.
|
||||
|
||||
Phase 2: Infrastructure Services
|
||||
└── SettingsService (depends on Core models)
|
||||
└── LocalizationService (depends on lang files)
|
||||
└── DialogService (depends on WPF — implement last in phase)
|
||||
└── AuthService / SessionManager (depends on PnP Framework NuGet)
|
||||
### 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.
|
||||
|
||||
Phase 3: Feature Services (depend on Auth + Core)
|
||||
└── PermissionsService
|
||||
└── StorageService
|
||||
└── SearchService
|
||||
└── TemplateService
|
||||
└── DuplicateService
|
||||
└── BulkOpsService
|
||||
### 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.
|
||||
|
||||
Phase 4: Reporting (depends on Feature Services output models)
|
||||
└── HtmlReportService
|
||||
└── CsvExportService
|
||||
### 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`.
|
||||
|
||||
Phase 5: ViewModels (depend on service interfaces)
|
||||
└── MainWindowViewModel (shell, nav, tenant selector)
|
||||
└── Feature ViewModels (Permissions, Storage, Search, Templates, Duplicates, BulkOps)
|
||||
└── SettingsViewModel
|
||||
|
||||
Phase 6: Views + App Bootstrap (depend on ViewModels + DI)
|
||||
└── XAML Views (bind to ViewModels)
|
||||
└── Controls (TenantSelector, LogPanel, Charts)
|
||||
└── App.xaml.cs DI container wiring
|
||||
```
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## Scaling Considerations
|
||||
## Scalability Considerations
|
||||
|
||||
This is a local desktop tool with a single user. "Scaling" means handling larger SharePoint tenants, not more users.
|
||||
|
||||
| Concern | Approach |
|
||||
|---------|----------|
|
||||
| Large site collections (1000+ sites) | Async streaming with early cancellation; paginated PnP calls; virtual DataGrid |
|
||||
| Deep permission hierarchies | Configurable scan depth; user can limit scope to top-level only |
|
||||
| Large file search results | Server-side KQL filtering first, client-side regex only as secondary pass |
|
||||
| Multiple simultaneous operations | Each ViewModel has its own CancellationTokenSource; operations are isolated |
|
||||
| Session token expiry during long scan | MSAL silent refresh + retry on 401; surface error to user if re-auth needed |
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Anti-Pattern 1: `Dispatcher.Invoke` in Services
|
||||
|
||||
**What people do:** Call `Application.Current.Dispatcher.Invoke()` inside service classes to update UI state.
|
||||
**Why it's wrong:** Couples service layer to WPF, makes services untestable, causes deadlocks if called from wrong thread.
|
||||
**Do this instead:** Service accepts `IProgress<T>` parameter. `Progress<T>` marshals to UI thread automatically via the captured SynchronizationContext.
|
||||
|
||||
### Anti-Pattern 2: Giant "God ViewModel"
|
||||
|
||||
**What people do:** Create one MainViewModel with all feature logic, mirroring the monolithic PowerShell script.
|
||||
**Why it's wrong:** Replicates the exact problem being solved. Hard to navigate, hard to test, merge conflicts on every change.
|
||||
**Do this instead:** One ViewModel per feature tab. MainWindowViewModel owns only shell navigation, active tenant, and log state.
|
||||
|
||||
### Anti-Pattern 3: Storing ClientContext as a Long-Lived Static
|
||||
|
||||
**What people do:** Cache `ClientContext` in a static field for reuse.
|
||||
**Why it's wrong:** `ClientContext` is not thread-safe and has an auth token that expires. Static makes it impossible to manage per-tenant.
|
||||
**Do this instead:** `SessionManager` manages ClientContext lifetime. Services request a context per operation. PnP Framework handles token refresh.
|
||||
|
||||
### Anti-Pattern 4: Blocking Async on Sync Context
|
||||
|
||||
**What people do:** Call `.Result` or `.Wait()` on Tasks inside WPF event handlers to avoid `async void`.
|
||||
**Why it's wrong:** Deadlocks the WPF SynchronizationContext. The UI freezes permanently.
|
||||
**Do this instead:** Use `async void` only for top-level event handlers (acceptable in WPF), or bind all user actions to `AsyncRelayCommand`.
|
||||
|
||||
### Anti-Pattern 5: Silent Catch Blocks (porting the existing bug)
|
||||
|
||||
**What people do:** Wrap PnP calls in `catch {}` or `catch { /* ignore */ }` to prevent crashes.
|
||||
**Why it's wrong:** The existing PowerShell app has 38 such blocks — they produce silent failures, missing data, and phantom "success" states.
|
||||
**Do this instead:** Catch specific exceptions (`SharePointException`, `MicrosoftIdentityException`). Log with full stack trace via `ILogger`. Surface user-visible error message via ViewModel's `ErrorMessage` property.
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### External Services
|
||||
|
||||
| Service | Integration Pattern | Library | Notes |
|
||||
|---------|---------------------|---------|-------|
|
||||
| SharePoint Online (CSOM) | PnP Framework `ClientContext` | `PnP.Framework` NuGet | Use for permissions, storage, templates, bulk ops |
|
||||
| SharePoint Search | PnP Framework `SearchRequest` | `PnP.Framework` NuGet | KQL queries; paginated |
|
||||
| Microsoft Graph | `GraphServiceClient` | `Microsoft.Graph` NuGet | Use for user/group lookups, Teams data |
|
||||
| Azure AD / MSAL | `PublicClientApplication` via PnP `AuthenticationManager` | Built into `PnP.Framework` | Interactive browser login; token cache callback |
|
||||
| WPF Charts | `LiveCharts2` or `OxyPlot.Wpf` | NuGet | Storage metrics visualization; LiveCharts2 preferred for richer WPF binding |
|
||||
|
||||
### Internal Boundaries
|
||||
|
||||
| Boundary | Communication | Notes |
|
||||
|----------|---------------|-------|
|
||||
| SessionManager ↔ Feature Services | `TenantSession` passed per operation | Services do not store sessions |
|
||||
| LocalizationService ↔ XAML | Singleton bound via `StaticResource`; properties fire `INotifyPropertyChanged` on language switch | All UI text goes through this |
|
||||
| ReportExportService ↔ ViewModels | Called after operation completes; returns file path | Self-contained HTML with embedded JS/CSS |
|
||||
| SettingsService ↔ all singletons | Read at startup; written on change | JSON format must match existing `Sharepoint_Settings.json` schema for migration |
|
||||
| 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
|
||||
|
||||
- [Introduction to MVVM Toolkit - Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/) — HIGH confidence
|
||||
- [AsyncRelayCommand - CommunityToolkit](https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/asyncrelaycommand) — HIGH confidence
|
||||
- [PnP Framework AuthenticationManager API](https://pnp.github.io/pnpframework/api/PnP.Framework.AuthenticationManager.html) — HIGH confidence
|
||||
- [PnP Framework Getting Started](https://pnp.github.io/pnpframework/using-the-framework/readme.html) — HIGH confidence
|
||||
- [Acquire and cache tokens with MSAL - Microsoft Learn](https://learn.microsoft.com/en-us/entra/identity-platform/msal-acquire-cache-tokens) — HIGH confidence
|
||||
- [WPF Development Best Practices 2024 - MESCIUS](https://medium.com/mesciusinc/wpf-development-best-practices-for-2024-9e5062c71350) — MEDIUM confidence
|
||||
- [Modern WPF Development: MVVM and Prism - Einfochips](https://www.einfochips.com/blog/modern-wpf-development-leveraging-mvvm-and-prism-for-enterprise-app/) — MEDIUM confidence
|
||||
- [Async Programming Patterns for MVVM - Microsoft Learn](https://learn.microsoft.com/en-us/archive/msdn-magazine/2014/april/async-programming-patterns-for-asynchronous-mvvm-applications-commands) — HIGH confidence
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
*Architecture research for: C#/WPF SharePoint Online administration desktop tool*
|
||||
*Researched: 2026-04-02*
|
||||
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`
|
||||
|
||||
Reference in New Issue
Block a user