diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index b66edab..c6a8565 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -8,10 +8,18 @@ A C#/WPF desktop application for IT administrators and MSPs to audit and manage Administrators can audit and manage SharePoint/Teams permissions and storage across multiple client tenants from a single, reliable desktop application. +## Current Milestone: v2.2 Report Branding & User Directory + +**Goal:** Add customizable logos to HTML reports and a full user directory browse mode in the user access audit tab. + +**Target features:** +- HTML report branding with MSP logo (global) and client logo (per tenant — pull from tenant or import) +- User directory browse mode as alternative to search in user access audit tab + ## Current State **Shipped:** v1.1 Enhanced Reports (2026-04-08) -**Status:** Feature-complete for v1.1; no active milestone +**Status:** Active milestone v2.2 **v1.1 shipped features:** - Global multi-site selection in toolbar (pick sites once, all tabs use them) @@ -40,6 +48,11 @@ Distribution: 200 MB self-contained EXE (win-x64) - [x] Simplified permissions reports (plain language, summary views) (SIMP-01/02/03) — v1.1 - [x] Storage metrics graph by file type (pie/donut and bar chart, toggleable) (VIZZ-01/02/03) — v1.1 +### Active + +- [ ] HTML report branding with MSP logo (global) and client logo (per tenant) +- [ ] User directory browse mode in user access audit tab + ### Out of Scope - Cross-platform support (Mac/Linux) — WPF is Windows-only; not justified for current user base @@ -80,4 +93,4 @@ Distribution: 200 MB self-contained EXE (win-x64) | Wave 0 scaffold pattern | Models + interfaces + test stubs before implementation | ✓ Good — all phases had test targets from day 1 | --- -*Last updated: 2026-04-08 after v1.1 milestone shipped* +*Last updated: 2026-04-08 after v2.2 milestone started* diff --git a/.planning/STATE.md b/.planning/STATE.md index cd83474..8ca3c31 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,72 +1,38 @@ --- gsd_state_version: 1.0 -milestone: v1.1 -milestone_name: v1.1 Enhanced Reports -status: shipped -stopped_at: Milestone archived +milestone: v2.2 +milestone_name: v2.2 Report Branding & User Directory +status: defining-requirements +stopped_at: Defining requirements last_updated: "2026-04-08T00:00:00Z" -last_activity: 2026-04-08 — v1.1 milestone archived and tagged +last_activity: 2026-04-08 — Milestone v2.2 started progress: - total_phases: 4 - completed_phases: 4 - total_plans: 25 - completed_plans: 25 + total_phases: 0 + completed_phases: 0 + total_plans: 0 + completed_plans: 0 --- # Project State ## Project Reference -See: .planning/PROJECT.md (updated 2026-04-07) +See: .planning/PROJECT.md (updated 2026-04-08) **Core value:** Administrators can audit and manage SharePoint/Teams permissions and storage across multiple client tenants from a single, reliable desktop application. -**Current focus:** v1.1 Enhanced Reports — global site selection, user access audit, simplified permissions, storage visualization +**Current focus:** v2.2 Report Branding & User Directory — HTML report logos, user directory browse mode ## Current Position -Phase: 9 — Storage Visualization -Plan: 4 of 4 -Status: Plan 09-04 complete — StorageViewModel chart unit tests -Last activity: 2026-04-07 — Completed 09-04 (StorageViewModel chart unit tests) +Phase: Not started (defining requirements) +Plan: — +Status: Defining requirements +Last activity: 2026-04-08 — Milestone v2.2 started ``` -v1.1 Progress: [██████████] 100% -Phase 6 [x] → Phase 7 [x] → Phase 8 [x] → Phase 9 [x] +v2.2 Progress: [░░░░░░░░░░] 0% ``` -## Performance Metrics - -| Metric | v1.0 | v1.1 (running) | -|--------|------|----------------| -| Phases | 5 | 4 planned | -| Plans | 36 | TBD | -| Commits | 164 | 0 | -| Tests | 134 pass / 22 skip | — | -| Phase 06-global-site-selection P02 | 8 | 1 tasks | 1 files | -| Phase 06-global-site-selection P01 | 2 | 2 tasks | 3 files | -| Phase 06-global-site-selection P03 | 2 | 3 tasks | 5 files | -| Phase 06-global-site-selection P04 | 2 | 3 tasks | 6 files | -| Phase 06-global-site-selection P05 | 2 | 1 tasks | 1 files | -| Phase 07-user-access-audit P01 | 5 | 2 tasks | 3 files | -| Phase 07-user-access-audit P03 | 2 | 1 tasks | 1 files | -| Phase 07-user-access-audit P02 | 1 | 1 tasks | 1 files | -| Phase 07-user-access-audit P06 | 2 | 2 tasks | 2 files | -| Phase 07-user-access-audit P04 | 2 | 1 tasks | 1 files | -| Phase 07-user-access-audit P05 | 4 | 2 tasks | 2 files | -| Phase 07-user-access-audit P07 | 8 | 3 tasks | 7 files | -| Phase 07-user-access-audit P08 | 2 | 2 tasks | 4 files | -| Phase 07-user-access-audit P09 | 6 | 1 tasks | 1 files | -| Phase 07-user-access-audit P10 | 5 | 1 tasks | 1 files | -| Phase 08 P02 | 84 | 1 tasks | 1 files | -| Phase 08 P03 | 77 | 1 tasks | 2 files | -| Phase 08 P04 | 2 | 2 tasks | 2 files | -| Phase 08 P05 | 2 | 2 tasks | 4 files | -| Phase 08 P06 | 2 | 2 tasks | 3 files | -| Phase 09 P01 | 1 | 2 tasks | 3 files | -| Phase 09 P02 | 1 | 1 tasks | 1 files | -| Phase 09 P03 | 573 | 2 tasks | 5 files | -| Phase 09 P04 | 146 | 1 tasks | 2 files | - ## Accumulated Context ### Decisions @@ -78,47 +44,10 @@ Decisions are logged in PROJECT.md Key Decisions table. - Per-tab override (SITE-02) means each `FeatureViewModelBase` subclass stores a nullable local site override; null means "use global". - Storage Visualization (Phase 9) requires a WPF charting NuGet (LiveCharts2 recommended — actively maintained, WPF-native, self-contained friendly). Wire chart data binding to the existing storage scan result model. - Self-contained EXE constraint: charting library must not require runtime DLLs outside the publish output. -- [Phase 06-02]: MainWindowViewModel uses Func? factory for SitePickerDialog and broadcasts GlobalSitesChangedMessage via WeakReferenceMessenger on collection change -- [Phase 06-01]: GlobalSitesChangedMessage uses IReadOnlyList (snapshot, not ObservableCollection) so receivers cannot mutate sender state -- [Phase 06-01]: FeatureViewModelBase.OnGlobalSitesReceived (private) updates GlobalSites then calls OnGlobalSitesChanged (protected virtual) — separates storage from derived class hooks -- [Phase 06-03]: Added using SharepointToolbox.Core.Models to MainWindow.xaml.cs for TenantProfile in SitePickerDialog factory lambda -- [Phase 06-03]: toolbar.selectSites.tooltipDisabled added to resources but not wired in XAML — WPF Button disabled tooltip requires style trigger (deferred) -- [Phase 06-global-site-selection]: PermissionsViewModel uses _hasLocalSiteOverride guard for SelectedSites; site picker sets flag, tenant switch resets it -- [Phase 06-global-site-selection]: Single-site VMs use partial void OnSiteUrlChanged to detect local typing; clearing field reverts to global -- [Phase 06-global-site-selection]: BulkMembersViewModel confirmed excluded: no SiteUrl field, CSV-driven per-row site URLs -- [Phase 06-global-site-selection]: Test 8 asserts override-reset via next global sites message (not SiteUrl='' — OnSiteUrlChanged re-applies global immediately when cleared) -- [Phase 06-global-site-selection]: Used reflection to set _hasLocalSiteOverride in PermissionsViewModel test — avoids needing a real SitePickerDialog -- [Phase 07-01]: UserAccessEntry is fully denormalized (one row = one user + one object + one permission) for direct DataGrid binding -- [Phase 07-01]: IsHighPrivilege and IsExternalUser pre-computed at scan time; GraphUserResult co-located with IGraphUserSearchService interface -- [Phase 07-03]: Minimum 2-character query guard prevents overly broad Graph API requests -- [Phase 07-03]: OData single-quote escaping (replace apostrophe with two apostrophes) prevents injection in startsWith filter -- [Phase 07-03]: ConsistencyLevel=eventual and Count=true both required for startsWith on Graph directory objects -- [Phase 07-user-access-audit]: TenantProfile.ClientId empty in service — session pre-authenticated at ViewModel level; SessionManager returns cached context by URL key -- [Phase 07-user-access-audit]: Bidirectional contains matching for user login — handles both plain email and full SharePoint claim formats -- [Phase 07-user-access-audit]: UserAccessCsvExportService has two write modes: WriteAsync (per-user files to directory) and WriteSingleFileAsync (combined for SaveFileDialog) -- [Phase 07-user-access-audit]: HTML sortTable() scoped per group so sorting in by-user view keeps each user's rows together -- [Phase 07-04]: CollectionViewSource bound at construction; ApplyGrouping() swaps PropertyGroupDescription between UserLogin/SiteUrl on IsGroupByUser toggle -- [Phase 07-04]: ExportCsvAsync uses WriteSingleFileAsync (combined file) not WriteAsync (per-user directory) to match SaveFileDialog single-path UX -- [Phase 07-05]: Autocomplete ListBox visibility managed via code-behind CollectionChanged — WPF DataTrigger cannot compare to non-zero Count without converter -- [Phase 07-05]: Simple ListBox autocomplete (not Popup) following plan's recommended simpler alternative — avoids Popup placement issues -- [Phase 07-user-access-audit]: Dialog factory wiring in MainWindow.xaml.cs by casting auditView.DataContext to UserAccessAuditViewModel — matches PermissionsView pattern -- [Phase 07-user-access-audit]: UserAccessAuditView created inline (Rule 3) when 07-05 found missing — follows 07-05 spec with two-panel layout -- [Phase 07-user-access-audit]: Used internal TestRunOperationAsync for ViewModel tests; Application.Current null in tests lets else branch run synchronously -- [Phase 07-user-access-audit]: WeakReferenceMessenger.Default.Reset() in test constructor prevents cross-test contamination from message registrations -- [Phase 07-09]: Guest badge (orange pill) and warning icon (⚠) use DataTrigger-driven Visibility on DataGridTemplateColumn cells — collapsed by default, visible only when IsExternalUser/IsHighPrivilege=True -- [Phase 07-10]: Extended CreateViewModel to 3-tuple (vm, auditMock, graphMock) so debounce test can verify SearchUsersAsync calls -- [Phase 08]: ActiveItemsSource returns Results or SimplifiedResults based on IsSimplifiedMode -- View binds to single property -- [Phase 08]: InvertBoolConverter in Core/Converters namespace for reuse; summary cards use WrapPanel; row color triggers only match SimplifiedPermissionEntry -- [Phase 08]: FR translations use XML entities for accented chars matching existing resx convention -- [Phase 09-01]: LiveChartsCore.SkiaSharpView.WPF 2.0.0-rc5.4 added as charting library; SkiaSharp backend for self-contained EXE compatibility -- [Phase 09-01]: FileTypeMetric record uses Extension (with dot), TotalSizeBytes (long), FileCount (int), DisplayLabel (computed) matching existing model patterns -- [Phase 09-01]: CollectFileTypeMetricsAsync omits StorageScanOptions since file-type scan covers all non-hidden libraries without folder depth filtering -- [Phase 09-02]: Added System.IO using explicitly -- WPF project implicit usings do not include System.IO for Path.GetExtension -- [Phase 09]: Used wrapper Grid elements with MultiDataTrigger for LiveCharts2 chart visibility -- more reliable than styling third-party controls directly ### Pending Todos -1. Add global multi-site selection option (ui) — `todos/pending/2026-04-07-add-global-multi-site-selection-option.md` — **addressed by Phase 6** +None. ### Blockers/Concerns @@ -126,6 +55,6 @@ None. ## Session Continuity -Last session: 2026-04-07T13:40:30Z -Stopped at: Completed 09-04-PLAN.md +Last session: 2026-04-08 +Stopped at: Milestone v2.2 started — defining requirements Resume file: None diff --git a/.planning/phases/06-global-site-selection/06-CONTEXT.md b/.planning/phases/06-global-site-selection/06-CONTEXT.md new file mode 100644 index 0000000..13bb1e2 --- /dev/null +++ b/.planning/phases/06-global-site-selection/06-CONTEXT.md @@ -0,0 +1,131 @@ +# Phase 6: Global Site Selection - Context + +**Gathered:** 2026-04-07 +**Status:** Ready for planning + + +## Phase Boundary + +Administrators can select one or more target sites once from the toolbar and have every feature tab use that selection by default — eliminating the need to re-enter site URLs on each tab. Individual tabs can override the global selection without clearing the global state. + +Requirements: SITE-01, SITE-02 + +Success Criteria: +1. A multi-site picker control is visible in the main toolbar at all times, regardless of which tab is active +2. Selecting sites in the toolbar causes all feature tabs to default to those sites when an operation is run +3. A user can override the global selection on any individual tab without clearing the global state +4. The global site selection persists across tab switches within the same session + + + + +## Implementation Decisions + +### Toolbar site picker placement +- Add a "Select Sites" button to the existing ToolBar (after the Clear Session button, separated by a Separator) +- Next to the button, show a summary label: "3 site(s) selected" or "No sites selected" +- Clicking the button opens the existing SitePickerDialog pattern (reuse from PermissionsViewModel) +- The picker requires a connected tenant (button disabled when no profile is connected) + +### Global selection broadcast +- Create a new `GlobalSitesChangedMessage` (ValueChangedMessage>) sent via WeakReferenceMessenger when the toolbar selection changes +- `MainWindowViewModel` owns the global site selection state: `ObservableCollection GlobalSelectedSites` +- On tenant switch, clear the global selection (sites belong to a tenant) + +### Tab consumption of global selection +- `FeatureViewModelBase` registers for `GlobalSitesChangedMessage` in `OnActivated()` and stores the global sites in a protected property `IReadOnlyList GlobalSites` +- Each tab's `RunOperationAsync` checks: if local override sites exist, use those; else if GlobalSites is non-empty, use those; else fall back to the SiteUrl text box +- The SiteUrl TextBox on each tab shows a placeholder/hint when global sites are active (e.g., "Using 3 globally selected sites" as watermark text) + +### Local override behavior +- Tabs that already have per-tab site pickers (like Permissions) keep them +- When a user picks sites locally on a tab, that overrides the global selection for that tab only +- A "Clear local selection" action resets the tab back to using global sites +- The global selection in the toolbar is never modified by per-tab overrides + +### Tabs that DO NOT consume global sites +- Settings tab: no site URL needed +- Bulk Sites tab: creates sites from CSV, does not target existing sites +- Templates tab (apply): creates a new site, does not target existing sites + +### Tabs that consume global sites (single-site) +- Storage, Search, Duplicates, Folder Structure: these currently take a single SiteUrl +- When global sites are selected, these tabs use the first site in the global list by default +- The SiteUrl TextBox is pre-filled with the first global site URL (user can change it = local override) + +### Tabs that consume global sites (multi-site) +- Permissions: already supports multi-site; global sites pre-populate its SelectedSites collection +- Transfer: source site pre-filled from first global site + +### Claude's Discretion +- Exact XAML layout of the toolbar site picker button and label +- Whether to refactor SitePickerDialog or reuse as-is from MainWindow code-behind +- Internal naming of properties and helper methods +- Whether to add a chip/tag display for selected sites or keep it as a count label +- Localization key names for new strings + + + + +## Existing Code Insights + +### Reusable Assets +- `SitePickerDialog` (Views/Dialogs/): Filterable checkbox list of sites with Select All/Deselect All — loads from `ISiteListService.GetSitesAsync()`. Currently only wired from PermissionsView; needs to be wired from MainWindow toolbar too. +- `SiteInfo(string Url, string Title)` record (Core/Models/): Already used by SitePickerDialog and PermissionsViewModel +- `ISiteListService.GetSitesAsync(TenantProfile, progress, ct)`: Enumerates all sites in a tenant. Already registered in DI. +- `TenantSwitchedMessage`: Broadcast pattern for tenant changes — global site selection follows the same pattern +- `WeakReferenceMessenger`: Already used for TenantSwitched and ProgressUpdated messages +- `FeatureViewModelBase.OnActivated()`: Already registers for TenantSwitchedMessage — extend to also register for GlobalSitesChangedMessage + +### Established Patterns +- Dialog factories set on ViewModels as `Func?` from View code-behind (keeps Window refs out of VMs) +- `[ObservableProperty]` for bindable state +- `ObservableCollection` for list-bound UI elements +- Tab content resolved from DI in MainWindow.xaml.cs +- Localization via `TranslationSource.Instance["key"]` with Strings.resx / Strings.fr.resx + +### Integration Points +- `MainWindow.xaml`: Add site picker button + label to ToolBar +- `MainWindowViewModel.cs`: Add GlobalSelectedSites, OpenGlobalSitePickerCommand, GlobalSitesChangedMessage broadcast +- `MainWindow.xaml.cs`: Wire SitePickerDialog factory for the toolbar (same pattern as PermissionsView) +- `FeatureViewModelBase.cs`: Register for GlobalSitesChangedMessage, add GlobalSites property +- `Core/Messages/`: New GlobalSitesChangedMessage class +- Each tab ViewModel: Update RunOperationAsync to check GlobalSites before falling back to SiteUrl +- `Strings.resx` / `Strings.fr.resx`: New localization keys for toolbar site picker +- `App.xaml.cs`: No new DI registrations needed (SitePickerDialog factory and ISiteListService already registered) + +### Key Files +| File | Role | +|------|------| +| `MainWindow.xaml` | Toolbar XAML — add site picker controls | +| `MainWindowViewModel.cs` | Global selection state + command | +| `MainWindow.xaml.cs` | Wire SitePickerDialog factory for toolbar | +| `FeatureViewModelBase.cs` | Base class — receive global sites message | +| `Core/Messages/TenantSwitchedMessage.cs` | Pattern reference for new message | +| `Views/Dialogs/SitePickerDialog.xaml.cs` | Reuse as-is | +| `ViewModels/Tabs/PermissionsViewModel.cs` | Already has multi-site pattern — adapt to consume global sites | +| `ViewModels/Tabs/StorageViewModel.cs` | Single-site pattern — adapt to consume global sites | + + + + +## Specific Ideas + +- The toolbar site count label should update live when sites are selected/deselected +- When no tenant is connected, the "Select Sites" button should be disabled with a tooltip explaining why +- Clearing the session (Clear Session button) should also clear the global site selection +- The global selection should survive tab switching (it lives on MainWindowViewModel, not on any tab) + + + + +## Deferred Ideas + +None — all items are within phase scope + + + +--- + +*Phase: 06-global-site-selection* +*Context gathered: 2026-04-07* diff --git a/.planning/phases/07-user-access-audit/07-09-PLAN.md b/.planning/phases/07-user-access-audit/07-09-PLAN.md new file mode 100644 index 0000000..706ec29 --- /dev/null +++ b/.planning/phases/07-user-access-audit/07-09-PLAN.md @@ -0,0 +1,163 @@ +--- +phase: 07-user-access-audit +plan: 09 +type: execute +wave: 6 +depends_on: ["07-05"] +files_modified: + - SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml +autonomous: true +requirements: + - UACC-01 + - UACC-02 +gap_closure: true +source_gaps: + - "Gap 1: Missing DataGrid visual indicators (guest badge + warning icon)" + - "Gap 2: Missing ObjectType column in DataGrid" +must_haves: + truths: + - "High-privilege entries show a warning icon (⚠) in the Permission Level column cell template" + - "External users show a guest badge (👤 Guest) in the User column cell template when IsExternalUser is true" + - "DataGrid columns include Object Type bound to ObjectType between Object and Permission Level" + artifacts: + - path: "SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml" + provides: "DataGrid with visual indicators for high-privilege/external users and ObjectType column" + contains: "IsExternalUser DataTrigger, IsHighPrivilege warning icon, ObjectType column" + key_links: + - from: "SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml" + to: "SharepointToolbox/Core/Models/UserAccessEntry.cs" + via: "Bindings on IsExternalUser, IsHighPrivilege, ObjectType properties" + pattern: "DataTrigger Binding" +--- + + +Add missing visual indicators and ObjectType column to the UserAccessAuditView DataGrid. + +Purpose: Close verification gaps 1 and 2 — the XAML currently lacks per-row guest badges for external users, warning icons for high-privilege entries, and the ObjectType column. +Output: Updated UserAccessAuditView.xaml with all three additions. + + + +@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/dev/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/07-user-access-audit/07-CONTEXT.md +@.planning/phases/07-user-access-audit/07-05-SUMMARY.md +@.planning/phases/07-user-access-audit/07-VERIFICATION.md + + + +From SharepointToolbox/Core/Models/UserAccessEntry.cs: +```csharp +public record UserAccessEntry( + string UserDisplayName, string UserLogin, + string SiteUrl, string SiteTitle, + string ObjectType, string ObjectTitle, string ObjectUrl, + string PermissionLevel, AccessType AccessType, string GrantedThrough, + bool IsHighPrivilege, bool IsExternalUser); +``` + + +Current columns: User (UserLogin), Site (SiteTitle), Object (ObjectTitle), Permission Level (PermissionLevel), Access Type (template), Granted Through (GrantedThrough). +Missing: ObjectType column, guest badge in User column, warning icon in Permission Level column. + + + + + + + Task 1: Add guest badge, warning icon, and ObjectType column to DataGrid + SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml + + Modify the DataGrid columns section (lines 219-249) with three changes: + + **Change 1 — Convert User column to DataGridTemplateColumn with guest badge:** + Replace the plain `DataGridTextColumn Header="User"` with a `DataGridTemplateColumn`: + ```xml + + + + + + + + + + + + + + + + ``` + + **Change 2 — Convert Permission Level column to DataGridTemplateColumn with warning icon:** + Replace the plain `DataGridTextColumn Header="Permission Level"` with a `DataGridTemplateColumn`: + ```xml + + + + + + + + + + + + + + + ``` + + **Change 3 — Add ObjectType column between Object and Permission Level:** + ```xml + + ``` + + Insert this column after the "Object" column and before the "Permission Level" column. + + Final column order: User (with guest badge), Site, Object, Object Type, Permission Level (with ⚠ icon), Access Type, Granted Through. + + + cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore 2>&1 | tail -5 + + DataGrid now shows: guest badge on external user rows (orange "Guest" pill), warning icon (⚠) on high-privilege permission levels, and ObjectType column showing Site Collection/Site/List/Folder distinction. + + + + + +- `dotnet build SharepointToolbox/SharepointToolbox.csproj` — XAML compiles without errors +- Visual inspection: DataGrid columns order is User (with guest badge), Site, Object, Object Type, Permission Level (with ⚠), Access Type, Granted Through +- Guest badge visible only when IsExternalUser=true +- Warning icon visible only when IsHighPrivilege=true + + + +The DataGrid shows guest badges for external users, warning icons for high-privilege entries, and the ObjectType column — closing verification gaps 1 and 2. + + + +After completion, create `.planning/phases/07-user-access-audit/07-09-SUMMARY.md` + diff --git a/.planning/phases/07-user-access-audit/07-10-PLAN.md b/.planning/phases/07-user-access-audit/07-10-PLAN.md new file mode 100644 index 0000000..edf2091 --- /dev/null +++ b/.planning/phases/07-user-access-audit/07-10-PLAN.md @@ -0,0 +1,171 @@ +--- +phase: 07-user-access-audit +plan: 10 +type: execute +wave: 6 +depends_on: ["07-08"] +files_modified: + - SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelTests.cs +autonomous: true +requirements: + - UACC-01 +gap_closure: true +source_gaps: + - "Gap 3: Debounced search test absent (Plan 08 truth partially unmet)" +must_haves: + truths: + - "A unit test verifies that setting SearchQuery to a value of length >= 2 triggers IGraphUserSearchService.SearchUsersAsync after the debounce delay" + artifacts: + - path: "SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelTests.cs" + provides: "Debounced search unit test" + contains: "SearchQuery_debounced_calls_SearchUsersAsync" + key_links: + - from: "SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelTests.cs" + to: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs" + via: "Tests SearchQuery property change → DebounceSearchAsync → SearchUsersAsync" + pattern: "SearchUsersAsync" +--- + + +Add a unit test for the debounced search path in UserAccessAuditViewModel. + +Purpose: Close verification gap 3 — plan 08 required "ViewModel tests verify: debounced search triggers service" but no such test exists. +Output: One new test method added to UserAccessAuditViewModelTests.cs. + + + +@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/dev/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/07-user-access-audit/07-CONTEXT.md +@.planning/phases/07-user-access-audit/07-08-SUMMARY.md +@.planning/phases/07-user-access-audit/07-VERIFICATION.md + + + +```csharp +// Line 281-290: OnSearchQueryChanged triggers DebounceSearchAsync +partial void OnSearchQueryChanged(string value) +{ + _searchCts?.Cancel(); + _searchCts?.Dispose(); + _searchCts = new CancellationTokenSource(); + var ct = _searchCts.Token; + _ = DebounceSearchAsync(value, ct); +} + +// Line 406-458: DebounceSearchAsync waits 300ms then calls SearchUsersAsync +private async Task DebounceSearchAsync(string query, CancellationToken ct) +{ + await Task.Delay(300, ct); + // ... guard: query null/whitespace or < 2 chars → clear and return + var clientId = _currentProfile?.ClientId ?? string.Empty; + var results = await _graphUserSearchService.SearchUsersAsync(clientId, query, 10, ct); + // ... dispatches results to SearchResults collection +} +``` + + +```csharp +// Uses Moq + xUnit. CreateViewModel helper returns (vm, auditMock). +// mockGraph is Mock created inside CreateViewModel. +// The test needs access to mockGraph — may need to extend CreateViewModel to return it. +``` + + +```csharp +public interface IGraphUserSearchService +{ + Task> SearchUsersAsync( + string clientId, string query, int maxResults, CancellationToken ct); +} +``` + + + + + + + Task 1: Add debounced search unit test + SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelTests.cs + + **Step 1**: Extend the `CreateViewModel` helper to also return the `Mock` so tests can set up expectations and verify calls on it. Change the return tuple from `(vm, auditMock)` to `(vm, auditMock, graphMock)`. Update all 8 existing test calls to destructure the third element (use `_` discard). + + **Step 2**: Add the following test method after Test 8: + + ```csharp + // ── Test 9: Debounced search triggers SearchUsersAsync ────────────── + + [Fact] + public async Task SearchQuery_debounced_calls_SearchUsersAsync() + { + var graphResults = new List + { + new("Alice Smith", "alice@contoso.com", "alice@contoso.com") + }; + + var (vm, _, graphMock) = CreateViewModel(); + + graphMock + .Setup(s => s.SearchUsersAsync( + It.IsAny(), + It.Is(q => q == "Ali"), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(graphResults); + + // Set a TenantProfile so _currentProfile is non-null + var profile = new TenantProfile + { + Name = "Test", + TenantUrl = "https://contoso.sharepoint.com", + ClientId = "test-client-id" + }; + WeakReferenceMessenger.Default.Send(new TenantSwitchedMessage(profile)); + + // Act: set SearchQuery which triggers OnSearchQueryChanged → DebounceSearchAsync + vm.SearchQuery = "Ali"; + + // Wait longer than 300ms debounce to allow async fire-and-forget to complete + await Task.Delay(600); + + // Assert: SearchUsersAsync was called with the query + graphMock.Verify( + s => s.SearchUsersAsync( + It.IsAny(), + "Ali", + It.IsAny(), + It.IsAny()), + Times.Once); + } + ``` + + **Important notes:** + - The `DebounceSearchAsync` method uses `Application.Current?.Dispatcher` which will be null in tests. The else branch (lines 438-442) handles this by adding directly to SearchResults — this is the test-safe path. + - The 600ms delay in the test ensures the 300ms debounce + async execution has time to complete. + - The TenantSwitchedMessage sets `_currentProfile` so that `_currentProfile?.ClientId` is non-null. + + + cd "C:\Users\dev\Documents\projets\Sharepoint" && dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~UserAccessAuditViewModelTests" 2>&1 | tail -10 + + Debounced search test passes: setting SearchQuery to "Ali" triggers SearchUsersAsync after the 300ms debounce. All 9 ViewModel tests pass with no regressions. + + + + + +- `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~UserAccessAuditViewModelTests"` — all 9 tests pass +- `dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj` — no regressions + + + +The debounced search path (SearchQuery → 300ms delay → SearchUsersAsync) has unit test coverage, closing verification gap 3. + + + +After completion, create `.planning/phases/07-user-access-audit/07-10-SUMMARY.md` + diff --git a/.planning/research/ARCHITECTURE.md b/.planning/research/ARCHITECTURE.md index 775a7a5..320e9c9 100644 --- a/.planning/research/ARCHITECTURE.md +++ b/.planning/research/ARCHITECTURE.md @@ -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, 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,...` `` 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 _results = new(); +**`Services/BrandingService.cs`** +```csharp +public class BrandingService +{ + public Task GetBrandingAsync(); + public Task SetMspLogoAsync(string filePath); // reads file, detects MIME, converts to base64, saves + public Task ClearMspLogoAsync(); +} +``` +Thin orchestration, same pattern as `SettingsService`. MSP logo only — client logo is managed via `ProfileService` (it belongs to `TenantProfile`). - 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(msg => StatusMessage = msg)); - Results = new ObservableCollection(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 `` open and `

`: + +```html +
+ + MSP + Client +
+``` + +When `branding` is null (existing callers) the block is omitted entirely. No behavior change for callers that do not pass branding. + +Affected services (all in `Services/Export/`): +- `HtmlExportService` (two `BuildHtml` overloads — `PermissionEntry` and `SimplifiedPermissionEntry`) +- `UserAccessHtmlExportService` +- `StorageHtmlExportService` (two `BuildHtml` overloads — with and without `FileTypeMetric`) +- `SearchHtmlExportService` +- `DuplicatesHtmlExportService` + +**ViewModels that call HTML export** — All `ExportHtmlAsync` methods need to resolve branding before calling the export service. The ViewModel calls `BrandingService.GetBrandingAsync()` and reads `_currentProfile.ClientLogoBase64` to assemble a `ReportBranding`, then passes it to `BuildHtml`. + +Affected ViewModels: `PermissionsViewModel`, `UserAccessAuditViewModel`, `StorageViewModel`, `SearchViewModel`, `DuplicatesViewModel`. Each gets `BrandingService` injected via constructor. + +**`ViewModels/Tabs/SettingsViewModel.cs`** — Add MSP logo management: +```csharp +[ObservableProperty] private string? _mspLogoPreviewBase64; +public RelayCommand BrowseMspLogoCommand { get; } +public RelayCommand ClearMspLogoCommand { get; } +``` +On browse: open `OpenFileDialog` (filter: PNG, JPG, GIF) → call `BrandingService.SetMspLogoAsync(path)` → reload and refresh `MspLogoPreviewBase64`. + +**`Views/Tabs/SettingsView.xaml`** — Add a "Report Branding — MSP Logo" section: +- `` bound to `MspLogoPreviewBase64` via a base64-to-BitmapSource converter +- "Browse Logo" button → `BrowseMspLogoCommand` +- "Clear" button → `ClearMspLogoCommand` +- Note label: "Applies to all reports" + +**Client logo placement:** Client logo belongs to a `TenantProfile`, not to global settings. The natural place to manage it is `ProfileManagementDialog` (already handles profile CRUD). Add logo fields there rather than in SettingsView. + +**`ViewModels/ProfileManagementViewModel.cs`** — Add client logo management per profile: +```csharp +[ObservableProperty] private string? _clientLogoPreviewBase64; +public RelayCommand BrowseClientLogoCommand { get; } +public RelayCommand ClearClientLogoCommand { get; } +``` +On browse: read image bytes → base64 → set on the being-edited `TenantProfile` object before saving. Uses `ProfileService.AddProfileAsync` / rename pipeline that already exists. + +**`Views/Dialogs/ProfileManagementDialog.xaml`** — Add client logo fields to the add/edit profile form (same pattern as SettingsView branding section). + +### Data Flow: Report Branding + +``` +User picks MSP logo (SettingsView "Browse Logo" button) + → SettingsViewModel.BrowseMspLogoCommand + → OpenFileDialog in View code-behind or VM (follow existing BrowseFolder pattern) + → BrandingService.SetMspLogoAsync(path) + → File.ReadAllBytesAsync → Convert.ToBase64String + → detect MIME from extension (.png → image/png, .jpg/.jpeg → image/jpeg, .gif → image/gif) + → BrandingRepository.SaveAsync(BrandingSettings) + → ViewModel refreshes MspLogoPreviewBase64 + +User runs export (e.g. ExportHtmlCommand in UserAccessAuditViewModel) + → BrandingService.GetBrandingAsync() → BrandingSettings + → reads _currentProfile.ClientLogoBase64, _currentProfile.ClientLogoMimeType + → new ReportBranding(mspBase64, mspMime, clientBase64, clientMime) + → UserAccessHtmlExportService.BuildHtml(entries, branding) + → injects data URIs in header when base64 is non-null + → writes HTML file +``` + +--- + +## Feature 2: User Directory Browse Mode + +### What It Needs + +The existing `UserAccessAuditView` has a people-picker: search box → Graph API `startsWith` filter → autocomplete dropdown → add to `SelectedUsers`. Directory browse mode is an alternative to the search box: show a paginated, filterable list of all tenant users, allow multi-select, bulk-add to `SelectedUsers`. + +This is purely additive. The underlying audit logic (`IUserAccessAuditService`, `RunOperationAsync`, `SelectedUsers` collection, export commands) is completely unchanged. + +### New Components (create from scratch) + +**`Core/Models/PagedUserResult.cs`** +```csharp +public record PagedUserResult( + IReadOnlyList Users, + string? NextPageToken); // null = last page +``` + +**`Services/IGraphUserDirectoryService.cs`** +```csharp +public interface IGraphUserDirectoryService +{ + Task GetUsersPageAsync( + string clientId, + string? filter = null, + string? pageToken = null, + int pageSize = 100, + CancellationToken ct = default); } ``` -### 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 _sessions = new(); +[ObservableProperty] private bool _isBrowseModeActive; +[ObservableProperty] private ObservableCollection _directoryUsers = new(); +[ObservableProperty] private string _directoryFilter = string.Empty; +[ObservableProperty] private bool _isLoadingDirectory; +[ObservableProperty] private bool _hasMoreDirectoryPages; - public async Task 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> AddDirectoryUsersCommand { get; } ``` -### Pattern 3: IProgress\ + 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` and `CancellationToken`. The ViewModel creates `Progress` (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` 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(p => StatusMessage = p.Message); - -// In Service (any thread): -public async Task> ScanAsync( - string siteUrl, - CancellationToken ct, - IProgress 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(); ``` +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`) 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(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); +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(); - services.AddTransient(); - services.AddTransient(); - - // ViewModels - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - // Views - services.AddSingleton(); - }) - .Build(); - - _host.Start(); - var mainWindow = _host.Services.GetRequiredService(); - mainWindow.DataContext = _host.Services.GetRequiredService(); - 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 - ↓ -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.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 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 → 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\ | -| 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` 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 `` 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` parameter. `Progress` 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` diff --git a/.planning/research/FEATURES.md b/.planning/research/FEATURES.md index 0a2017b..d0e5dc9 100644 --- a/.planning/research/FEATURES.md +++ b/.planning/research/FEATURES.md @@ -1,192 +1,211 @@ -# Feature Research +# Feature Landscape -**Domain:** SharePoint Online administration and auditing desktop tool (MSP / IT admin) -**Researched:** 2026-04-02 -**Confidence:** MEDIUM (competitive landscape from web sources; no Context7 for SaaS tools; Microsoft docs HIGH confidence) +**Domain:** MSP IT admin desktop tool — SharePoint audit report branding + user directory browse +**Milestone:** v2.2 — Report Branding & User Directory +**Researched:** 2026-04-08 +**Overall confidence:** HIGH (verified via official Graph API docs + direct codebase inspection) -## Feature Landscape +--- -### Table Stakes (Users Expect These) +## Scope Boundary -Features that IT admins and MSPs assume exist in any SharePoint admin tool. Missing these makes the product feel broken or incomplete. +This file covers only the two net-new features in v2.2: +1. HTML report branding (MSP logo + client logo per tenant) +2. User directory browse mode in the user access audit tab + +Everything else is already shipped. Dependencies on existing code are called out explicitly. + +--- + +## Feature 1: HTML Report Branding + +### Table Stakes + +Features an MSP admin expects without being asked. If missing, the reports feel unfinished and +unprofessional to hand to a client. | Feature | Why Expected | Complexity | Notes | |---------|--------------|------------|-------| -| Permissions report (site-level) | Every audit tool has this; admins must prove who has access where | MEDIUM | Must show owners, members, guests, external users, and broken inheritance | -| Export to CSV | Standard workflow — admins paste into tickets, compliance reports, Excel | LOW | Already in current app; keep for all reports | -| Multi-site permissions scan | Admins manage dozens of sites; per-site-only scan is unusable at scale | HIGH | Requires batching Graph API calls; throttling management needed | -| Storage metrics per site | Native M365 admin center only shows tenant-level; per-site is expected | MEDIUM | Already in current app; retain and improve | -| Interactive login / Azure AD OAuth | No client secret storage expected; browser-based auth is the norm | MEDIUM | Already implemented; new version adds session caching | -| Site template management | Re-using structure across client sites is a core MSP workflow | MEDIUM | Already in current app; port to C# | -| File search across sites | Finding content across a tenant is a day-1 admin task | MEDIUM | Already in current app; Graph driveItem search | -| Bulk operations (user add/remove, site creation) | Manual one-by-one is unacceptable at MSP scale | HIGH | Already in current app; async required to avoid UI freeze | -| Error reporting (not silent failures) | Admins need to know when scans fail partially | LOW | Current app has 38 silent catch blocks — critical fix | -| Localization (EN + FR) | Already exists; removing it would break existing users | LOW | Key-based translation system already in place | -| Export to interactive HTML | Shareable reports without requiring recipients to have the tool | MEDIUM | Already in current app; retain embedded JS for sorting/filtering | +| MSP global logo in report header | Every white-label MSP tool shows the MSP's own brand on deliverables | Low | Single image stored in AppSettings or a dedicated branding settings section | +| Client (per-tenant) logo in report header | MSP reports are client-facing; client should see their own logo next to the MSP's | Medium | Stored in TenantProfile; 2 sources: import from file or pull from tenant | +| Logo renders in self-contained HTML (no external URL) | Reports are often emailed or archived; external URLs break offline | Low | Base64-encode and embed as `data:image/...;base64,...` inline in `` block entirely when no logo is set | +| Consistent placement across all HTML export types | App already ships 5+ HTML exporters; logos must appear in all of them | Medium | Extract a shared header-builder method or inject a branding context into each export service | -### Differentiators (Competitive Advantage) +### Differentiators -Features that are not universally provided, or are done poorly by competitors, where this tool can create genuine advantage. +Features not expected by default, but add meaningful value once table stakes are covered. | Feature | Value Proposition | Complexity | Notes | |---------|-------------------|------------|-------| -| Multi-tenant session caching | MSPs switch between 10-30 client tenants daily; re-auth per client wastes 2-3 min each | HIGH | Token cache per tenant profile; MSAL token cache serialization; core MSP differentiator | -| User access export across selected sites | "Show me everything User X can access across these 15 sites" — native M365 can't do this for arbitrary site subsets | HIGH | Requires enumerating group memberships, direct assignments, and inherited access across n sites; high Graph API volume | -| Simplified permissions view (plain language) | Compliance reports today require admins to translate "Contribute" to "can edit files" — untrained staff can't read them | MEDIUM | Jargon-free labels, summary counts, color coding; configurable detail level | -| Storage graph by file type (pie + bar toggle) | Native admin center shows totals only; file-type breakdown identifies what's consuming quota (videos, backups, etc.) | MEDIUM | Requires Graph driveItem enumeration with file extension grouping; recharts-style WPF chart control | -| Duplicate file detection | Reduces storage waste; no native Microsoft tool provides this simply | HIGH | Hash-based (SHA256/MD5) or name+size matching; large tenant = Graph throttling challenge | -| Folder structure provisioning | Create standardized folder trees on new sites from a template — critical for MSPs onboarding clients | MEDIUM | Already in current app; differentiating because competitors (ShareGate) don't focus on this | -| Offline profile / tenant registry | Store tenant URLs, display names, notes locally — instant context switching without re-entering URLs | LOW | JSON-backed, local only — simple but missing from all SaaS tools by design | -| Operation progress and cancellation | SaaS tools run jobs server-side; desktop tool must show real-time progress and allow cancel mid-scan | MEDIUM | CancellationToken throughout async operations; progress reporting via IProgress | +| Auto-pull client logo from Microsoft Entra tenant branding | Zero-config for tenants that already have a banner logo set in Entra ID | Medium | Graph API: `GET /organization/{id}/branding/localizations/default/bannerLogo` returns raw image bytes. Least-privileged scope is `User.Read` (delegated, already in use). Returns empty body or 404 when not configured — must handle gracefully. | +| Report timestamp and tenant display name in header | Contextualizes archived reports without needing to inspect the filename | Low | TenantProfile.TenantUrl already available; display name derivable from domain | -### Anti-Features (Commonly Requested, Often Problematic) +### Anti-Features -Features that seem valuable but create disproportionate complexity, maintenance burden, or scope creep for this tool's purpose. +Do not build these. They add scope without proportionate MSP value. -| Feature | Why Requested | Why Problematic | Alternative | -|---------|---------------|-----------------|-------------| -| Permission change alerts / real-time monitoring | Admins want to know when permissions change | Requires persistent background service, webhook registration in Azure, certificate lifecycle management — turns a desktop tool into a service | Run scheduled audit scans manually or via Windows Task Scheduler; export diffs between runs | -| Automated remediation (auto-revoke permissions) | "Fix it for me" saves time | One wrong rule destroys access for a client's entire org; liability risk; requires undo capability and audit trail that equals a full compliance system | Surface recommendations, let admin click to apply one at a time | -| SQLite or database storage | Faster queries on large datasets | Adds install dependency, schema migration complexity, and breaks the "single EXE" distribution model | JSON with chunked loading; lazy evaluation; paginated display | -| Cloud sync / shared tenant registry | Team of admins sharing tenant configs | Requires auth system, conflict resolution, server infrastructure — out of scope for local tool | Export/import JSON profiles; share config files manually | -| AI-powered governance recommendations | Microsoft is adding this to native admin center (SharePoint Admin Agent, Copilot-licensed) | Requires Copilot license, Graph calls with high latency, and competes directly with Microsoft's own roadmap | Focus on raw data accuracy and export quality; let Microsoft handle AI summaries | -| Cross-platform (Mac/Linux) support | Some admins use Macs | WPF is Windows-only; rewrite to MAUI/Avalonia is a full project — not justified for current user base | Confirmed out of scope in PROJECT.md | -| Version history management / rollback | Admins sometimes need to see version bloat | Version management is a deep separate problem; Graph API pagination for versions is complex and slow at scale | Surface version storage totals in storage metrics; flag libraries with high version counts | -| SharePoint content migration | Admins ask to move content between tenants or sites | Migration is a fully separate product category (ShareGate, AvePoint); competing here is a multi-year investment | Refer to ShareGate or native SharePoint migration for content moves | +| Anti-Feature | Why Avoid | What to Do Instead | +|--------------|-----------|-------------------| +| Color theme / CSS customization per tenant | Complexity explodes — per-tenant CSS is a design system problem, not an admin tool feature | Stick to a single professional neutral theme; logo is sufficient branding | +| PDF export with embedded logo | PDF generation requires a third-party library (iTextSharp, QuestPDF, etc.) adding binary size to the 200 MB EXE | Document in release notes that users can print-to-PDF from browser | +| Animated or SVG logo support | MIME handling complexity; SVG in data-URIs introduces XSS risk | Support PNG/JPG/GIF only; reject SVG at import time | +| Logo URL field (hotlinked) | Reports break when URL becomes unavailable; creates external dependency for a local-first tool | Force file import with base64 embedding | -## Feature Dependencies +### Feature Dependencies ``` -Multi-tenant session caching - └──requires──> Tenant profile registry (JSON-backed) - └──required by──> All features (auth gate) - -User access export across selected sites - └──requires──> Multi-site permissions scan - └──requires──> Multi-tenant session caching - -Simplified permissions view - └──enhances──> Permissions report (site-level) - └──enhances──> User access export across selected sites - -Storage graph by file type - └──requires──> Storage metrics per site - └──requires──> Graph driveItem enumeration (file extension data) - -Duplicate file detection - └──requires──> File search across sites (file enumeration infrastructure) - └──conflicts──> Automated remediation (deletion without undo = data loss risk) - -Bulk operations - └──requires──> Operation progress and cancellation - └──requires──> Error reporting (not silent failures) - -Export (CSV / HTML) - └──enhances──> All report features - └──required by──> Compliance audit workflows - -Folder structure provisioning - └──requires──> Site template management +AppSettings + MspLogoBase64 (string?, nullable) +TenantProfile + ClientLogoBase64 (string?, nullable) + + ClientLogoSource (enum: None | Imported | AutoPulled) +Shared branding helper → called by HtmlExportService, UserAccessHtmlExportService, + StorageHtmlExportService, DuplicatesHtmlExportService, + SearchHtmlExportService +Auto-pull code path → Graph API call via existing GraphClientFactory +Logo import UI → WPF OpenFileDialog -> File.ReadAllBytes -> Convert.ToBase64String + -> stored in profile JSON via existing ProfileRepository ``` -### Dependency Notes +**Key existing code note:** All 5+ HTML export services currently build their `` independently +with no shared header. Branding requires one of: +- (a) a `ReportBrandingContext` record passed into each exporter's `BuildHtml` method, or +- (b) a `HtmlReportHeaderBuilder` static/injectable helper all exporters call. -- **Multi-tenant session caching requires Tenant profile registry:** Without a registry of tenant URLs and display names, the session cache has nothing to key against. The tenant profile JSON must exist before any feature can authenticate. -- **User access export requires multi-site permissions scan:** The "all accesses for user X" feature is essentially a filtered multi-site permissions scan. The scanning infrastructure must exist first. -- **Simplified permissions view enhances reports:** This is a presentation layer on top of raw permissions data — it cannot exist without the underlying data model. -- **Storage graph by file type requires Graph driveItem enumeration:** The native Graph storage reports do not include file type breakdown. This requires enumerating files with their extensions, which is a heavier Graph operation than summary-only calls. -- **Duplicate detection requires file enumeration infrastructure:** The file search feature already enumerates files; duplicate detection reuses that path but adds hash computation or name+size matching on top. -- **Bulk operations require cancellation support:** Long-running bulk operations that cannot be cancelled will freeze or force-kill the app. CancellationToken must be threaded through before bulk ops are exposed to users. -- **Duplicate detection conflicts with automated remediation:** Surfacing duplicates is safe; auto-deleting them without undo is not. Keep these concerns separate. +Option (b) is lower risk — it does not change method signatures that existing unit tests already call. -## MVP Definition +### Complexity Assessment -### Launch With (v1) +| Sub-task | Complexity | Reason | +|----------|------------|--------| +| AppSettings + TenantProfile model field additions | Low | Trivial nullable-string fields; JSON serialization already in place | +| Settings UI: MSP logo upload + preview | Low | WPF OpenFileDialog + BitmapImage from base64, standard pattern | +| ProfileManagementDialog: client logo upload per tenant | Low | Same pattern as MSP logo | +| Shared HTML header builder with logo injection | Low-Medium | One helper; replaces duplicated header HTML in 5 exporters | +| Auto-pull from Entra `bannerLogo` endpoint | Medium | Async Graph call; must handle 404, empty stream, no branding configured | +| Localization keys EN/FR for new labels | Low | ~6-10 new keys; 220+ already managed | -Minimum viable product — sufficient to replace the existing PowerShell tool completely. +--- -- [ ] Tenant profile registry with multi-tenant session caching — without this, no feature works -- [ ] Permissions report (site-level) with CSV + HTML export — core audit use case -- [ ] Storage metrics per site — currently used daily -- [ ] File search across sites — currently used daily -- [ ] Bulk operations (member add, site creation, transfer) with progress + cancel — currently used; async required -- [ ] Site template management — core MSP provisioning workflow -- [ ] Folder structure provisioning — paired with templates -- [ ] Duplicate file detection — currently used for storage cleanup -- [ ] Error reporting (no silent failures) — current app's biggest reliability issue -- [ ] Localization (EN/FR) — existing users depend on this +## Feature 2: User Directory Browse Mode -### Add After Validation (v1.x) +### Table Stakes -Features to add once core parity is confirmed working. +Features an admin expects when a "browse all users" mode is offered alongside the existing search. -- [ ] User access export across selected sites — new feature; high value for MSP audits; add once multi-site scan is stable -- [ ] Simplified permissions view (plain language) — presentation enhancement; add after raw data model is solid -- [ ] Storage graph by file type (pie + bar toggle) — visualization enhancement on top of existing storage metrics +| Feature | Why Expected | Complexity | Notes | +|---------|--------------|------------|-------| +| Full directory listing (all member users, paginated) | Browse implies seeing everyone, not just name-search hits | Medium | Graph `GET /users` with `$top=100`, follow `@odata.nextLink` until null. Max page size is 999 but 100 pages give better progress feedback | +| Searchable/filterable within the loaded list | Once loaded, admins filter locally without re-querying | Low | In-memory filter on DisplayName, UPN, Mail — same pattern used in PermissionsView DataGrid | +| Sortable columns (Name, UPN) | Standard expectation for any directory table | Low | WPF DataGrid column sorting, already used in other tabs | +| Select user from list to run access audit | The whole point — browse replaces the people-picker for users the admin cannot spell | Low | Bind selected item; reuse the existing IUserAccessAuditService pipeline unchanged | +| Loading indicator with progress count | Large tenants (5k+ users) take several seconds to page through | Low | Existing OperationProgress pattern; show "Loaded X users..." counter | +| Toggle between Browse mode and Search (people-picker) mode | Search is faster for known users; browse is for discovery | Low | RadioButton or ToggleButton in the tab toolbar; visibility-toggle two panels | -### Future Consideration (v2+) +### Differentiators -Features to defer until product-market fit is established. +| Feature | Value Proposition | Complexity | Notes | +|---------|-------------------|------------|-------| +| Filter by account type (member vs guest) | MSPs care about guest proliferation; helps scope audit targets | Low | Graph returns `userType` field; add a toggle filter. Include in `$select` | +| Department / Job Title columns | Helps identify the right user in large tenants with common names | Low-Medium | Include `department`, `jobTitle` in `$select`; optional columns in DataGrid | +| Session-scoped directory cache | Avoids re-fetching full tenant list on every tab visit | Medium | Store list in ViewModel or session-scoped service; invalidate on TenantSwitchedMessage | -- [ ] Scheduled scan runs via Windows Task Scheduler integration — requires stable CLI/headless mode first -- [ ] Permission comparison between two points in time (diff report) — useful for compliance but requires snapshot storage -- [ ] Export to XLSX (full Excel format, not just CSV) — requested but not critical; CSV opens in Excel adequately +### Anti-Features -## Feature Prioritization Matrix +| Anti-Feature | Why Avoid | What to Do Instead | +|--------------|-----------|-------------------| +| Eager load on tab open | Large tenants (10k+ users) block UI and risk Graph throttling on every tab navigation | Lazy-load on explicit "Load Directory" button click; show a clear affordance | +| Delta query / incremental sync | Delta queries are for maintaining a local replica over time; wrong pattern for a one-time audit session | Single paginated GET per session; add a Refresh button | +| Multi-user bulk select for simultaneous audit | The audit pipeline is per-user by design; multi-user requires a fundamentally different results model | Out of scope; single-user selection only | +| Export the user directory to CSV | That is an identity reporting feature (AdminDroid et al.), not an access audit feature | Out of scope for this milestone | +| Show disabled accounts by default | Disabled users do not have active SharePoint access; pollutes the list for audit purposes | Default `$filter=accountEnabled eq true`; optionally expose a toggle | -| Feature | User Value | Implementation Cost | Priority | -|---------|------------|---------------------|----------| -| Tenant profile registry + session caching | HIGH | MEDIUM | P1 | -| Permissions report (site-level) | HIGH | MEDIUM | P1 | -| Storage metrics per site | HIGH | MEDIUM | P1 | -| File search across sites | HIGH | MEDIUM | P1 | -| Bulk operations with progress/cancel | HIGH | HIGH | P1 | -| Error reporting (no silent failures) | HIGH | LOW | P1 | -| Site template management | HIGH | MEDIUM | P1 | -| Folder structure provisioning | MEDIUM | MEDIUM | P1 | -| Duplicate file detection | MEDIUM | HIGH | P1 | -| Localization (EN/FR) | MEDIUM | LOW | P1 | -| User access export across selected sites | HIGH | HIGH | P2 | -| Simplified permissions view | HIGH | MEDIUM | P2 | -| Storage graph by file type | MEDIUM | MEDIUM | P2 | -| Permission diff / snapshot comparison | MEDIUM | HIGH | P3 | -| XLSX export | LOW | LOW | P3 | -| Scheduled scans (headless/CLI) | LOW | HIGH | P3 | +### Feature Dependencies -**Priority key:** -- P1: Must have for v1 launch (parity with existing PowerShell tool) -- P2: Should have — add after v1 validated; new features from PROJECT.md active requirements -- P3: Nice to have, future consideration +``` +New IGraphDirectoryService + GraphDirectoryService + → GET /users?$select=displayName,userPrincipalName,mail,jobTitle,department,userType + &$filter=accountEnabled eq true + &$top=100 + → Follow @odata.nextLink in a loop until null + → Uses existing GraphClientFactory (DI, unchanged) -## Competitor Feature Analysis +UserAccessAuditViewModel additions: + + IsBrowseMode (bool property, toggle) + + DirectoryUsers (ObservableCollection or new DirectoryUserEntry model) + + DirectoryFilterText (string, filters in-memory) + + LoadDirectoryCommand (async, cancellable) + + IsDirectoryLoading (bool) + + SelectedDirectoryUser → feeds into existing audit execution path -| Feature | ShareGate | ManageEngine SharePoint Manager Plus | AdminDroid | Our Approach | -|---------|-----------|---------------------------------------|------------|--------------| -| Permissions matrix report | Yes — visual matrix, CSV export | Yes — granular permission level reports | Yes — site users/groups report | Yes — with plain-language layer on top | -| Multi-tenant management | Yes — SaaS, per-tenant login | Yes — web-based | Yes — cloud SaaS | Yes — local session cache, instant switch, offline profiles | -| Storage reporting | Basic | Basic tenant-level | Basic | Enhanced — file-type breakdown, pie/bar toggle | -| Duplicate detection | No | No | No | Yes — differentiator | -| Folder structure provisioning | No | No | No | Yes — differentiator | -| Site templates | Migration focus | No | No | Yes — admin provisioning focus | -| Bulk operations | Yes — migration-focused | Limited | No | Yes — admin-operations focus (not migration) | -| User access export (cross-site) | Partial — site-by-site | Partial | Partial | Yes — arbitrary site subset, single export | -| Plain language permissions | No | No | No | Yes — differentiator for untrained users | -| Local desktop app (no SaaS) | No — cloud | No — cloud | No — cloud | Yes — core constraint and privacy advantage | -| Offline / no internet needed | No | No | No | Yes (after auth token cached) | -| Price | ~$6K/year | Subscription | Subscription | Tool cost (one-time dev, distributed free or licensed) | +TenantSwitchedMessage handler in ViewModel: clear DirectoryUsers, reset IsBrowseMode + +UserAccessAuditView.xaml: + + Toolbar toggle (Search | Browse) + + Visibility-collapsed people-picker panel when in browse mode + + New DataGrid panel for browse mode +``` + +**Key existing code note:** `GraphUserSearchService` does filtered search only (`startsWith` filter + +`ConsistencyLevel: eventual`). Directory listing is a different call pattern — no filter, plain +pagination without `ConsistencyLevel`. A separate `GraphDirectoryService` is cleaner than extending +the existing service; search and browse have different cancellation and retry needs. + +### Complexity Assessment + +| Sub-task | Complexity | Reason | +|----------|------------|--------| +| IGraphDirectoryService + GraphDirectoryService (pagination loop) | Low-Medium | Standard Graph paging; same GraphClientFactory in DI | +| ViewModel additions (browse toggle, load command, filter, loading state) | Medium | New async command with progress, cancellation on tenant switch | +| View XAML: toggle + browse DataGrid panel | Medium | Visibility-toggle two panels; DataGrid column definitions | +| In-memory filter + column sort | Low | DataGrid pattern already used in PermissionsView | +| Loading indicator integration | Low | OperationProgress + IsLoading used by every tab | +| Localization keys EN/FR | Low | ~8-12 new keys | +| Unit tests for GraphDirectoryService | Low | Same mock pattern as GraphUserSearchService tests | +| Unit tests for ViewModel browse mode | Medium | Async load command, pagination mock, filter behavior | + +--- + +## Cross-Feature Dependencies + +Both features touch the same data models. Changes must be coordinated: + +``` +TenantProfile model — gains fields for branding (ClientLogoBase64, ClientLogoSource) +AppSettings model — gains MspLogoBase64 +ProfileRepository — serializes/deserializes new TenantProfile fields (JSON, backward-compat) +SettingsRepository — serializes/deserializes new AppSettings field +GraphClientFactory — used by both features (no changes needed) +TenantSwitchedMessage — consumed by UserAccessAuditViewModel to clear directory cache +``` + +Neither feature requires new NuGet packages. The Graph SDK, MSAL, and System.Text.Json are +already present. No new binary dependencies means no EXE size increase. + +--- + +## MVP Recommendation + +Build in this order, each independently releasable: + +1. **MSP logo in HTML reports** — highest visible impact, lowest complexity. AppSettings field + Settings UI upload + shared header builder. +2. **Client logo in HTML reports (import from file)** — completes the co-branding pattern. TenantProfile field + ProfileManagementDialog upload UI. +3. **User directory browse (load + select + filter)** — core browse UX. Toggle, paginated load, in-memory filter, pipe into existing audit. +4. **Auto-pull client logo from Entra branding** — differentiator, zero-config polish. Build after manual import works so the fallback path is proven. +5. **Directory: guest filter + department/jobTitle columns** — low-effort differentiators; add after core browse is stable. + +Defer to a later milestone: +- Directory session caching across tab switches — a Refresh button is sufficient for v2.2. +- Logo on CSV exports — CSV has no image support; not applicable. + +--- ## Sources -- [ShareGate SharePoint audit tool feature page](https://sharegate.com/sharepoint-audit-tool) — MEDIUM confidence (marketing page) -- [ManageEngine SharePoint Manager Plus permissions auditing](https://www.manageengine.com/sharepoint-management-reporting/sharepoint-permission-auditing-tool.html) — MEDIUM confidence -- [Microsoft Data access governance reports — site permissions for users](https://learn.microsoft.com/en-us/sharepoint/data-access-governance-site-permissions-users-report) — HIGH confidence -- [Microsoft SharePoint Advanced Management overview](https://learn.microsoft.com/en-us/sharepoint/advanced-management) — HIGH confidence -- [sprobot.io: 9 must-have features for SharePoint storage reporting](https://www.sprobot.io/blog/how-to-choose-the-right-sharepoint-storage-reporting-tool-9-must-have-features) — MEDIUM confidence -- [AdminDroid SharePoint Online auditing](https://admindroid.com/microsoft-365-sharepoint-online-auditing) — MEDIUM confidence -- [CIAOPS: Best ways to monitor and audit permissions across SharePoint M365](https://blog.ciaops.com/2025/04/27/best-ways-to-monitor-and-audit-permissions-across-a-sharepoint-environment-in-microsoft-365/) — MEDIUM confidence -- [ShareGate: How to generate a SharePoint user permissions report](https://sharegate.com/blog/build-the-perfect-sharepoint-permissions-report) — MEDIUM confidence -- [Microsoft SharePoint storage reports admin center](https://learn.microsoft.com/en-us/microsoft-365/admin/activity-reports/sharepoint-storage-reports?view=o365-worldwide) — HIGH confidence - ---- -*Feature research for: SharePoint Online administration/auditing desktop tool (C#/WPF, MSP/IT admin)* -*Researched: 2026-04-02* +- Graph API List Users (v1.0 official): https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0 — HIGH confidence +- Graph API Get organizationalBranding (v1.0 official): https://learn.microsoft.com/en-us/graph/api/organizationalbranding-get?view=graph-rest-1.0 — HIGH confidence +- Graph API bannerLogo stream: `GET /organization/{id}/branding/localizations/default/bannerLogo` — HIGH confidence (verified in official docs) +- Graph pagination concepts: https://learn.microsoft.com/en-us/graph/paging — HIGH confidence +- ControlMap co-branding (MSP + client logo pattern): https://help.controlmap.io/hc/en-us/articles/24174398424347 — MEDIUM confidence +- ManageEngine ServiceDesk Plus MSP per-account branding: https://www.manageengine.com/products/service-desk-msp/rebrand.html — MEDIUM confidence +- SolarWinds MSP report customization: http://allthings.solarwindsmsp.com/2013/06/customize-your-branding-on-client.html — MEDIUM confidence +- Direct codebase inspection: HtmlExportService.cs, GraphUserSearchService.cs, AppSettings.cs, TenantProfile.cs — HIGH confidence diff --git a/.planning/research/PITFALLS.md b/.planning/research/PITFALLS.md index b4cfe60..ed5a87b 100644 --- a/.planning/research/PITFALLS.md +++ b/.planning/research/PITFALLS.md @@ -381,3 +381,422 @@ File I/O is not inherently thread-safe. `System.Text.Json`'s `JsonSerializer.Ser *Pitfalls research for: C#/WPF SharePoint Online administration desktop tool (PowerShell-to-C# rewrite)* *Researched: 2026-04-02* + +--- +--- + +# v2.2 Pitfalls: Report Branding & User Directory + +**Milestone:** v2.2 — HTML report branding (MSP/client logos) + user directory browse mode +**Researched:** 2026-04-08 +**Confidence:** HIGH for logo handling and Graph pagination (multiple authoritative sources); MEDIUM for print CSS specifics (verified via MDN/W3C but browser rendering varies) + +These pitfalls are specific to adding logo branding to the existing HTML export services and replacing the people-picker search with a full directory browse mode. They complement the v1.0 foundation pitfalls above. + +--- + +## Critical Pitfalls (v2.2) + +### Pitfall v2.2-1: Base64 Logo Encoding Bloats Every Report File + +**What goes wrong:** +The five existing HTML export services (`HtmlExportService`, `UserAccessHtmlExportService`, `StorageHtmlExportService`, `SearchHtmlExportService`, `DuplicatesHtmlExportService`) are self-contained by design — no external dependencies. The natural instinct is to embed logos as inline `data:image/...;base64,...` strings in the `