Compare commits

..

14 Commits

Author SHA1 Message Date
Dev e3ff27a673 docs: create milestone v2.3 roadmap (5 phases, 15-19)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:31:54 +02:00
Dev d967a8bb65 docs: define milestone v2.3 requirements (12 requirements)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:11:25 +02:00
Dev 4ad5f078c9 docs: synthesize v2.3 research summary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:00:02 +02:00
Dev 853f47c4a6 docs: complete v2.3 project research (STACK, FEATURES, ARCHITECTURE, PITFALLS)
Research covers all five v2.3 features: automated app registration, app removal,
auto-take ownership, group expansion in HTML reports, and report consolidation toggle.
No new NuGet packages required. Build order and phase implications documented.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 10:58:58 +02:00
Dev 9318bb494d docs: start milestone v2.3 Tenant Management & Report Enhancements
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:43:56 +02:00
Dev f41dbd333e chore: archive v2.2 Report Branding & User Directory milestone
Release SharePoint Toolbox v2 / release (push) Failing after 14s
5 phases (10-14), 14 plans, 11/11 requirements complete.
Key features: HTML report branding with MSP/client logos, user directory
browse mode with paginated load and member/guest filtering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:27:33 +02:00
Dev b9511bd2b0 docs(14): mark phase 14 plan checkboxes complete in roadmap
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:31:52 +02:00
Dev febb67ab64 docs(14-02): complete directory browse UI plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:31:08 +02:00
Dev 1a1e83cfad feat(14-02): add directory browse mode UI with mode toggle, DataGrid, and loading UX
- Mode toggle (Search/Browse) RadioButtons at top of left panel
- Search panel uses DataTrigger inverse visibility (collapses when IsBrowseMode=true)
- Browse panel with Load/Cancel buttons, IncludeGuests checkbox, filter TextBox, status/count
- Directory DataGrid with 5 columns (Name, Email, Department, Job Title, Type)
- Guest users highlighted in orange via DataTrigger on UserType
- SelectedUsers extracted to shared section visible in both modes
- DataGrid wired to DirectoryDataGrid_MouseDoubleClick handler
- Scan Options and Run/Export buttons remain always visible

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:29:59 +02:00
Dev f11bfefe52 docs(14-01): complete directory UI infrastructure plan
- SUMMARY.md with 3 tasks, 4 commits, 5 files modified
- STATE.md updated with position and decisions
- ROADMAP.md updated with phase 14 progress (1/2 plans)
- REQUIREMENTS.md: UDIR-05 marked complete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:27:44 +02:00
Dev d1282cea5d feat(14-01): add DirectoryDataGrid_MouseDoubleClick code-behind handler
- Extracts GraphDirectoryUser from DataGrid.SelectedItem on double-click
- Invokes SelectDirectoryUserCommand to add user to audit pipeline
- Using added for SharepointToolbox.Core.Models

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:26:41 +02:00
Dev e6ba2d8146 feat(14-01): add SelectDirectoryUserCommand bridging directory to audit pipeline
- RelayCommand<GraphDirectoryUser> converts to GraphUserResult and adds to SelectedUsers
- Duplicate UPN check prevents adding same user twice
- Initialized in both DI and test constructors
- 4 new tests pass (add, skip duplicate, null, auditable)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:26:12 +02:00
Dev 381081da18 test(14-01): add failing tests for SelectDirectoryUserCommand
- Test 17: adds user to SelectedUsers
- Test 18: skips duplicates
- Test 19: null does nothing
- Test 20: user is auditable after selection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:25:18 +02:00
Dev 70e8d121fd feat(14-01): add 14 localization keys for directory browse UI (EN + FR)
- audit.mode.search, audit.mode.browse for mode toggle labels
- directory.grp.browse, directory.btn.load, directory.btn.cancel
- directory.filter.placeholder, directory.chk.guests, directory.status.count
- directory.hint.doubleclick, directory.col.name/upn/department/jobtitle/type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 09:24:54 +02:00
76 changed files with 3162 additions and 701 deletions
+45 -18
View File
@@ -8,28 +8,46 @@ 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:** Active milestone v2.2
**Shipped:** v2.2 Report Branding & User Directory (2026-04-09)
**Status:** Active — v2.3 Tenant Management & Report Enhancements
## Current Milestone: v2.3 Tenant Management & Report Enhancements
**Goal:** Streamline tenant onboarding with automated app registration, add self-healing ownership for access-denied sites, and enhance report output with group expansion and entry consolidation.
**Target features:**
- App registration on target tenant (auto via Graph API + guided fallback) during profile create/edit
- App removal from target tenant
- Auto-take ownership of SharePoint sites on access denied (global toggle)
- Expand groups in HTML reports (clickable to show members)
- Report consolidation toggle (merge duplicate user entries across locations)
<details>
<summary>v2.2 shipped features</summary>
- HTML report branding with MSP logo (global) and client logo (per tenant)
- Auto-pull client logo from Entra branding API
- Logo validation (PNG/JPG, 512 KB limit) with auto-compression
- User directory browse mode in user access audit tab with paginated load
- Member/guest filter and department/job title columns
- Directory user selection triggers existing audit pipeline
</details>
<details>
<summary>v1.1 shipped features</summary>
**v1.1 shipped features:**
- Global multi-site selection in toolbar (pick sites once, all tabs use them)
- User access audit tab with Graph API people-picker, direct/group/inherited access distinction
- Simplified permissions with plain-language labels, color-coded risk levels, detail-level toggle
- Storage visualization with LiveCharts2 pie/donut and bar charts by file type
</details>
Tech stack: C# / WPF / .NET 10 / PnP Framework / Microsoft Graph SDK / MSAL / Serilog / CommunityToolkit.Mvvm / LiveCharts2
Tests: 205 automated (xUnit), 22 skipped (require live SharePoint tenant)
Tests: 285 automated (xUnit), 26 skipped (require live SharePoint tenant)
Distribution: 200 MB self-contained EXE (win-x64)
LOC: ~16,900 C#
## Requirements
@@ -48,10 +66,18 @@ 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
### Shipped in v2.2
- [ ] HTML report branding with MSP logo (global) and client logo (per tenant)
- [ ] User directory browse mode in user access audit tab
- [x] HTML report branding with MSP and client logos (BRAND-01/02/03/04/05/06) — v2.2
- [x] User directory browse mode in user access audit tab (UDIR-01/02/03/04/05) — v2.2
### Active in v2.3
- [ ] Automated app registration on target tenant with guided fallback
- [ ] App removal from target tenant
- [ ] Auto-take ownership of sites on access denied (global toggle)
- [ ] Expand groups in HTML reports
- [ ] Report consolidation toggle (merge duplicate entries)
### Out of Scope
@@ -68,8 +94,9 @@ Distribution: 200 MB self-contained EXE (win-x64)
- **v1.0 shipped** with full feature parity: permissions, storage, search, duplicates, bulk operations, templates, folder provisioning
- **v1.1 shipped** with enhanced reports: user access audit, simplified permissions, storage charts, global site selection
- **Localization:** 220+ EN/FR keys, full parity verified
- **Architecture:** 120+ C# files + 17 XAML files across Core/Infrastructure/Services/ViewModels/Views layers
- **v2.2 shipped** with report branding (logos in HTML exports) and user directory browse mode
- **Localization:** 230+ EN/FR keys, full parity verified
- **Architecture:** 140+ C# files + 17 XAML files across Core/Infrastructure/Services/ViewModels/Views layers
## Constraints
@@ -93,4 +120,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 v2.2 milestone started*
*Last updated: 2026-04-09 after v2.3 milestone started*
+44 -46
View File
@@ -1,73 +1,71 @@
# Requirements: SharePoint Toolbox v2.2
# Requirements: SharePoint Toolbox v2.3
**Defined:** 2026-04-08
**Defined:** 2026-04-09
**Core Value:** Administrators can audit and manage SharePoint/Teams permissions and storage across multiple client tenants from a single, reliable desktop application.
## v2.2 Requirements
## v2.3 Requirements
Requirements for v2.2 Report Branding & User Directory. Each maps to roadmap phases.
Requirements for v2.3 Tenant Management & Report Enhancements. Each maps to roadmap phases.
### Report Branding
### App Registration
- [x] **BRAND-01**: User can import an MSP logo in application settings (global, persisted across sessions)
- [x] **BRAND-02**: User can preview the imported MSP logo in settings UI
- [x] **BRAND-03**: User can import a client logo per tenant profile
- [x] **BRAND-04**: User can auto-pull client logo from tenant's Entra branding API
- [x] **BRAND-05**: All five HTML report types display MSP and client logos in a consistent header
- [x] **BRAND-06**: Logo import validates format (PNG/JPG) and enforces 512 KB size limit
- [ ] **APPREG-01**: User can register the app on a target tenant from the profile create/edit dialog
- [ ] **APPREG-02**: App auto-detects if user has Global Admin permissions before attempting registration
- [ ] **APPREG-03**: App creates Azure AD application + service principal + grants required permissions atomically (with rollback on failure)
- [ ] **APPREG-04**: User sees guided fallback instructions when auto-registration is not possible (insufficient permissions)
- [ ] **APPREG-05**: User can remove the app registration from a target tenant
- [ ] **APPREG-06**: App clears cached tokens and sessions when app registration is removed
### User Directory
### Site Ownership
- [x] **UDIR-01**: User can toggle between search mode and directory browse mode in user access audit tab
- [x] **UDIR-02**: User can browse full tenant user directory with pagination (handles 999+ users)
- [x] **UDIR-03**: User can filter directory by user type (member vs guest)
- [x] **UDIR-04**: User can see department and job title columns in directory list
- [ ] **UDIR-05**: User can select one or more users from directory to run the access audit
- [ ] **OWN-01**: User can enable/disable auto-take-ownership in application settings (global toggle, OFF by default)
- [ ] **OWN-02**: App automatically takes site collection admin ownership when encountering access denied during scans (when toggle is ON)
### Report Enhancements
- [ ] **RPT-01**: User can expand SharePoint groups in HTML reports to see group members
- [ ] **RPT-02**: Group member resolution uses transitive membership to include nested group members
- [ ] **RPT-03**: User can enable/disable entry consolidation per export (toggle in export settings)
- [ ] **RPT-04**: Consolidated reports merge rows for the same user with identical access levels across multiple locations into a single row
## Future Requirements
### Report Branding (Deferred)
### Site Ownership (deferred)
- **BRAND-F01**: PDF export with embedded logos
- **BRAND-F02**: Custom report title/footer text per tenant
### User Directory (Deferred)
- **UDIR-F01**: Session-scoped directory cache (avoid re-fetching on tab switch)
- **UDIR-F02**: Export user directory list to CSV
- **OWN-03**: Persistent cleanup-pending list tracking sites where ownership was elevated
- **OWN-04**: Startup warning when stale ownership entries exist from previous sessions
## Out of Scope
| Feature | Reason |
|---------|--------|
| CSV report branding | CSV is data-only format; logos don't apply |
| Logo in application title bar | Not a report branding concern; separate UX decision |
| User directory as standalone tab | Directory browse is a mode within existing user access audit tab |
| Real-time directory sync | One-time load with manual refresh is sufficient for audit workflows |
| Auto-revoke permissions | Liability risk — read-only auditing tool, not remediation |
| Real-time ownership monitoring | Requires background service, beyond scope of desktop tool |
| Group expansion in CSV reports | CSV format doesn't support expandable sections; consolidation covers the dedup need |
| Custom permission scope selection for app registration | Fixed scope set covers all Toolbox features; custom scopes add complexity without value |
## Traceability
Which phases cover which requirements. Updated during roadmap creation.
| Requirement | Phase | Status |
|-------------|-------|--------|
| BRAND-01 | Phase 10 | Complete |
| BRAND-03 | Phase 10 | Complete |
| BRAND-06 | Phase 10 | Complete |
| BRAND-05 | Phase 11 | Complete |
| BRAND-04 | Phase 11 | Complete |
| BRAND-02 | Phase 12 | Complete |
| UDIR-01 | Phase 13 | Complete |
| UDIR-02 | Phase 13 | Complete |
| UDIR-03 | Phase 13 | Complete |
| UDIR-04 | Phase 13 | Complete |
| UDIR-05 | Phase 14 | Pending |
| APPREG-01 | Phase 19 | Pending |
| APPREG-02 | Phase 19 | Pending |
| APPREG-03 | Phase 19 | Pending |
| APPREG-04 | Phase 19 | Pending |
| APPREG-05 | Phase 19 | Pending |
| APPREG-06 | Phase 19 | Pending |
| OWN-01 | Phase 18 | Pending |
| OWN-02 | Phase 18 | Pending |
| RPT-01 | Phase 17 | Pending |
| RPT-02 | Phase 17 | Pending |
| RPT-03 | Phase 16 | Pending |
| RPT-04 | Phase 15 | Pending |
**Coverage:**
- v2.2 requirements: 11 total
- Mapped to phases: 11
- v2.3 requirements: 12 total
- Mapped to phases: 12
- Unmapped: 0
---
*Requirements defined: 2026-04-08*
*Last updated: 2026-04-08 after roadmap creation — all 11 requirements mapped to Phases 10-14*
*Requirements defined: 2026-04-09*
*Last updated: 2026-04-09 after roadmap created*
+70 -73
View File
@@ -4,7 +4,8 @@
-**v1.0 MVP** — Phases 1-5 (shipped 2026-04-07) — [archive](milestones/v1.0-ROADMAP.md)
-**v1.1 Enhanced Reports** — Phases 6-9 (shipped 2026-04-08) — [archive](milestones/v1.1-ROADMAP.md)
- 🔄 **v2.2 Report Branding & User Directory** — Phases 10-14 (active)
- **v2.2 Report Branding & User Directory** — Phases 10-14 (shipped 2026-04-09) — [archive](milestones/v2.2-ROADMAP.md)
- 🔄 **v2.3 Tenant Management & Report Enhancements** — Phases 15-19 (in progress)
## Phases
@@ -29,86 +30,81 @@
</details>
### v2.2 Report Branding & User Directory (Phases 10-14)
<details>
<summary>✅ v2.2 Report Branding & User Directory (Phases 10-14) — SHIPPED 2026-04-09</summary>
- [x] **Phase 10: Branding Data Foundation** — Models, repository, and services for logo storage and user directory enumeration (completed 2026-04-08)
- [x] **Phase 11: HTML Export Branding + ViewModel Integration** — Inject logos into all 5 HTML report types; wire branding into export-triggering ViewModels and logo management commands (completed 2026-04-08)
- [x] **Phase 12: Branding UI Views** — Settings and profile dialog logo sections with live preview; auto-pull client logo from Entra branding API (completed 2026-04-08)
- [x] **Phase 13: User Directory ViewModel** — Browse mode state, paginated directory load, member/guest filter, and department/job title columns (completed 2026-04-08)
- [ ] **Phase 14: User Directory View** — Toggle panel in UserAccessAuditView, user selection to trigger existing audit pipeline
- [x] Phase 10: Branding Data Foundation (3/3 plans) — completed 2026-04-08
- [x] Phase 11: HTML Export Branding + ViewModel Integration (4/4 plans) — completed 2026-04-08
- [x] Phase 12: Branding UI Views (3/3 plans) — completed 2026-04-08
- [x] Phase 13: User Directory ViewModel (2/2 plans) — completed 2026-04-08
- [x] Phase 14: User Directory View (2/2 plans) — completed 2026-04-09
</details>
### v2.3 Tenant Management & Report Enhancements (Phases 15-19)
- [ ] **Phase 15: Consolidation Data Model** — PermissionConsolidator service and merged-row model; zero API calls, pure data shapes
- [ ] **Phase 16: Report Consolidation Toggle** — Export settings toggle wired to PermissionConsolidator; first user-visible consolidation behavior
- [ ] **Phase 17: Group Expansion in HTML Reports** — Clickable group expansion in HTML exports with transitive membership resolution
- [ ] **Phase 18: Auto-Take Ownership** — Global toggle and automatic site collection admin elevation on access denied
- [ ] **Phase 19: App Registration & Removal** — Automated Entra app registration with guided fallback and clean removal
## Phase Details
### Phase 10: Branding Data Foundation
**Goal**: The application can store, validate, and retrieve MSP and client logos as portable base64 strings in JSON, and can enumerate a full tenant user list with pagination.
**Depends on**: Nothing (additive to existing infrastructure)
**Requirements**: BRAND-01, BRAND-03, BRAND-06
### Phase 15: Consolidation Data Model
**Goal**: The data shape and merge logic for report consolidation exist and are fully testable in isolation before any UI touches them
**Depends on**: Nothing (no API calls, no UI dependencies)
**Requirements**: RPT-04
**Success Criteria** (what must be TRUE):
1. An MSP logo imported as a PNG or JPG file is persisted as a base64 string in `branding.json` and survives an application restart
2. A client logo imported per tenant profile is persisted as a base64 string inside the tenant's profile JSON and is not affected by other tenants' profiles
3. A file larger than 512 KB or not a valid PNG/JPG is rejected at import time with an error; no invalid data reaches the JSON store
4. `GraphUserDirectoryService.GetUsersAsync` returns all enabled member users for a tenant, following `@odata.nextLink` until exhausted, without truncating at 999
**Plans**: 3 plans
Plans:
- [x] 10-01-PLAN.md — Logo models, BrandingRepository, BrandingService with validation/compression
- [x] 10-02-PLAN.md — GraphUserDirectoryService with PageIterator pagination
- [x] 10-03-PLAN.md — DI registration in App.xaml.cs and full test suite gate
1. A `ConsolidatedPermissionEntry` model exists that represents a single user's merged access across multiple locations with identical access levels
2. A `PermissionConsolidator` service accepts a flat list of permission rows and returns a consolidated list where duplicate user+level rows are merged
3. Consolidation logic has unit test coverage — a known 10-row input with 3 duplicate pairs produces the expected 7-row output
4. Existing HTML export services compile and produce identical output when consolidation is not applied (opt-in, defaults off)
**Plans**: TBD
### Phase 11: HTML Export Branding + ViewModel Integration
**Goal**: All five HTML reports display MSP and client logos in a consistent header, and administrators can manage logos from Settings and the profile dialog without touching the View layer.
**Depends on**: Phase 10
**Requirements**: BRAND-05, BRAND-04
### Phase 16: Report Consolidation Toggle
**Goal**: Users can choose to merge duplicate permission entries per export through a toggle in the export settings dialog
**Depends on**: Phase 15
**Requirements**: RPT-03
**Success Criteria** (what must be TRUE):
1. Running any of the five HTML exports (Permissions, Storage, Search, Duplicates, User Access) produces an HTML file whose header contains the MSP logo `<img>` tag when an MSP logo is configured
2. When a client logo is configured for the active tenant, the same HTML export header contains both the MSP logo and the client logo side by side
3. When no logo is configured, the HTML export header contains no broken image placeholder and the report renders identically to the pre-branding output
4. SettingsViewModel exposes browse/clear commands for MSP logo; ProfileManagementViewModel exposes browse/clear commands for client logo — both commands are exercisable without opening any View
5. Auto-pulling the client logo from the tenant's Entra branding API stores the logo in the tenant profile and falls back silently when no Entra branding is configured
**Plans**: 4 plans
Plans:
- [ ] 11-01-PLAN.md — ReportBranding model + BrandingHtmlHelper static class with unit tests
- [ ] 11-02-PLAN.md — Add optional branding param to all 5 HTML export services
- [ ] 11-03-PLAN.md — Wire IBrandingService into all 5 export ViewModels
- [ ] 11-04-PLAN.md — Logo management commands (Settings + Profile) and Entra auto-pull
1. A consolidation toggle is visible in the export settings dialog (or export options panel) and defaults to OFF
2. When the toggle is OFF, the exported HTML report is byte-for-byte identical to the pre-v2.3 output
3. When the toggle is ON, the exported HTML report merges rows for the same user with identical access levels into a single row showing all affected locations
4. The toggle state is remembered for the session (does not reset between exports within the same session)
**Plans**: TBD
### Phase 12: Branding UI Views
**Goal**: Administrators can see, import, preview, and clear logos directly in the Settings and profile management dialogs.
**Depends on**: Phase 11
**Requirements**: BRAND-02, BRAND-04 (view layer for Entra pull)
### Phase 17: Group Expansion in HTML Reports
**Goal**: Users can expand SharePoint group entries in HTML reports to see the group's members, including members of nested groups
**Depends on**: Phase 16
**Requirements**: RPT-01, RPT-02
**Success Criteria** (what must be TRUE):
1. Opening Settings shows the MSP logo section: an import button, a live thumbnail preview of the current logo, and a clear button that removes the logo immediately
2. Opening a tenant profile dialog shows the client logo section with the same import/preview/clear controls
3. Importing a logo via the UI shows the thumbnail preview without requiring an application restart
4. Clicking "Pull from Entra" in the profile dialog fetches and displays the tenant's banner logo if one exists, and shows a clear user-facing message if none is configured
**Plans**: 3 plans
Plans:
- [x] 12-01-PLAN.md — Base64ToImageSourceConverter, localization keys, App.xaml registration, ClientLogoPreview property
- [x] 12-02-PLAN.md — SettingsView MSP logo section (preview, import, clear)
- [x] 12-03-PLAN.md — ProfileManagementDialog client logo section (preview, import, clear, Entra pull)
1. SharePoint group rows in the HTML report render as expandable — clicking a group name reveals its member list inline
2. Member resolution includes transitive membership: nested groups are recursively resolved so every leaf user is shown
3. Group expansion is triggered at export time via Graph API — the permission scan itself is unchanged
4. When Graph cannot resolve a group's members (throttled or insufficient scope), the report shows the group row with a "members unavailable" label rather than failing the export
**Plans**: TBD
### Phase 13: User Directory ViewModel
**Goal**: The UserAccessAuditViewModel supports a full directory browse mode with paginated load, member/guest filtering, and department/job title display, fully testable without the View.
**Depends on**: Phase 10
**Requirements**: UDIR-01, UDIR-02, UDIR-03, UDIR-04
### Phase 18: Auto-Take Ownership
**Goal**: Users can enable automatic site collection admin elevation so that access-denied sites during scans no longer block audit progress
**Depends on**: Phase 15
**Requirements**: OWN-01, OWN-02
**Success Criteria** (what must be TRUE):
1. `UserAccessAuditViewModel` exposes a toggle property that switches between Search mode (existing people-picker behavior) and Browse mode (directory list behavior), with no regression to Search mode behavior
2. Invoking the load-directory command fetches all enabled member users via `PageIterator`, updates a progress observable with the running user count, and supports cancellation mid-load
3. A "Members only / Include guests" toggle filters the displayed list in-memory without issuing a new Graph request
4. Each user row in the observable collection exposes DisplayName, UPN, Department, and JobTitle; Department and JobTitle columns are visible and sortable in the ViewModel's `ICollectionView`
**Plans**: 2 plans
Plans:
- [x] 13-01-PLAN.md — Extend GraphDirectoryUser with UserType + service includeGuests parameter
- [x] 13-02-PLAN.md — UserAccessAuditViewModel directory browse mode (toggle, load, filter, sort, tests)
1. A global "Auto-take ownership on access denied" toggle exists in application settings and defaults to OFF
2. When the toggle is OFF, access-denied sites produce the same error behavior as before v2.3 (no regression)
3. When the toggle is ON and a scan hits access denied on a site, the app automatically calls `Tenant.SetSiteAdmin` to elevate ownership and retries the site without interrupting the scan
4. The scan result for an auto-elevated site is visually distinguishable from a normally-scanned site (e.g., a flag or icon in the results)
**Plans**: TBD
### Phase 14: User Directory View
**Goal**: Administrators can toggle into directory browse mode from the user access audit tab, see the paginated user list with filters, and launch an access audit for a selected user.
**Depends on**: Phase 13
**Requirements**: UDIR-05, UDIR-01 (view layer)
### Phase 19: App Registration & Removal
**Goal**: Users can register and remove the Toolbox's Azure AD application on a target tenant directly from the profile dialog, with a guided fallback when permissions are insufficient
**Depends on**: Phase 18
**Requirements**: APPREG-01, APPREG-02, APPREG-03, APPREG-04, APPREG-05, APPREG-06
**Success Criteria** (what must be TRUE):
1. The user access audit tab shows a mode toggle control (e.g., radio buttons or segmented control) that visibly switches the left panel between the existing people-picker and the directory browse panel
2. In browse mode, selecting a user from the directory list and clicking Run Audit (or equivalent) launches the existing audit pipeline for that user, producing the same results as if the user had been found via search
3. While the directory is loading, the panel shows a "Loading... X users" counter and an active cancel button; the load button is disabled to prevent concurrent requests
4. When the directory load is cancelled or fails, the panel returns to a ready state with a clear status message and no broken UI
1. A "Register App" action is available in the profile create/edit dialog and is the recommended path for new tenant onboarding
2. Before attempting registration, the app checks for Global Admin role and surfaces a clear message if the signed-in user lacks the required permissions, then presents step-by-step manual registration instructions as a fallback
3. Registration creates the Azure AD application, service principal, and grants all required API permissions in a single atomic operation — if any step fails, all partial changes are rolled back and the user sees a specific error explaining what failed and why
4. A "Remove App" action in the profile dialog removes the Azure AD application registration from the target tenant
5. After removal, all cached MSAL tokens and session state for that tenant are cleared, and subsequent operations require re-authentication
**Plans**: TBD
## Progress
@@ -117,8 +113,9 @@ Plans:
|-------|-----------|-------|--------|-----------|
| 1-5 | v1.0 | 36/36 | Shipped | 2026-04-07 |
| 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 |
| 10. Branding Data Foundation | v2.2 | 3/3 | Complete | 2026-04-08 |
| 11. HTML Export Branding + ViewModel Integration | 4/4 | Complete | 2026-04-08 | — |
| 12. Branding UI Views | 3/3 | Complete | 2026-04-08 | — |
| 13. User Directory ViewModel | 2/2 | Complete | 2026-04-08 | — |
| 14. User Directory View | v2.2 | 0/? | Not started | — |
| 10-14 | v2.2 | 14/14 | Shipped | 2026-04-09 |
| 15. Consolidation Data Model | v2.3 | 0/? | Not started | — |
| 16. Report Consolidation Toggle | v2.3 | 0/? | Not started | — |
| 17. Group Expansion in HTML Reports | v2.3 | 0/? | Not started | — |
| 18. Auto-Take Ownership | v2.3 | 0/? | Not started | — |
| 19. App Registration & Removal | v2.3 | 0/? | Not started | — |
+42 -55
View File
@@ -1,83 +1,70 @@
---
gsd_state_version: 1.0
milestone: v2.2
milestone_name: Report Branding & User Directory
status: completed
stopped_at: Completed 13-02-PLAN.md
last_updated: "2026-04-08T14:08:49.579Z"
last_activity: 2026-04-08Phase 11 planning completed
milestone: v2.3
milestone_name: Tenant Management & Report Enhancements
status: roadmap-ready
stopped_at: roadmap created — ready for phase 15 planning
last_updated: "2026-04-09"
last_activity: 2026-04-09Roadmap created for v2.3 (phases 15-19)
progress:
total_phases: 5
completed_phases: 4
total_plans: 12
completed_plans: 12
completed_phases: 0
total_plans: 0
completed_plans: 0
---
# Project State
## Project Reference
See: .planning/PROJECT.md (updated 2026-04-08)
See: .planning/PROJECT.md (updated 2026-04-09)
**Core value:** Administrators can audit and manage SharePoint/Teams permissions and storage across multiple client tenants from a single, reliable desktop application.
**Current focus:** v2.2 Report Branding & User Directory — HTML report logos (Phases 10-12), user directory browse mode (Phases 13-14)
**Current focus:** v2.3 Tenant Management & Report Enhancements — Phase 15 next
## Current Position
Phase: 11 (planned, ready to execute)
Plan: 4 plans (11-01 through 11-04) in 3 waves
Status: Phase 10 complete, Phase 11 planned — ready to execute
Last activity: 2026-04-08Phase 11 planning completed
Phase: 15 — Consolidation Data Model (not started)
Plan:
Status: Roadmap approved — ready to plan Phase 15
Last activity: 2026-04-09Roadmap created for v2.3 (phases 15-19)
```
v2.2 Progress: [██░░░░░░░░] 20% (1/5 phases, 3/7 plans)
v2.3 Progress: ░░░░░░░░░░ 0% (0/5 phases)
```
## Shipped Milestones
- v1.0 MVP — Phases 1-5 (shipped 2026-04-07)
- v1.1 Enhanced Reports — Phases 6-9 (shipped 2026-04-08)
- v2.2 Report Branding & User Directory — Phases 10-14 (shipped 2026-04-09)
## v2.3 Phase Map
| Phase | Name | Requirements | Status |
|-------|------|--------------|--------|
| 15 | Consolidation Data Model | RPT-04 | Not started |
| 16 | Report Consolidation Toggle | RPT-03 | Not started |
| 17 | Group Expansion in HTML Reports | RPT-01, RPT-02 | Not started |
| 18 | Auto-Take Ownership | OWN-01, OWN-02 | Not started |
| 19 | App Registration & Removal | APPREG-01..06 | Not started |
## Accumulated Context
### Decisions
Decisions are logged in PROJECT.md Key Decisions table.
**v2.2 architectural decisions (locked at roadmap):**
- Logos stored as base64 strings in JSON (not file paths). `BrandingSettings.cs` holds MSP logo; `TenantProfile` holds client logo. File path is discarded after import. This decision is locked — all downstream phases depend on it.
- Client logo lives on `TenantProfile`, NOT in `BrandingSettings`. Per-tenant ownership; prevents serialization and deletion awkwardness.
- Export services use optional `ReportBranding? branding = null` parameter. All existing call sites compile unchanged. No new `IHtmlExportService` interface needed.
- `GraphUserDirectoryService` is a new service, separate from `GraphUserSearchService`. Different pagination model (`PageIterator`), different cancellation needs.
- Directory does NOT load automatically on tab open. Explicit "Load Directory" button required to avoid blocking UI on large tenants.
- SVG logo support: rejected. XSS risk in data-URIs. PNG/JPG only.
- No new NuGet packages for v2.2. All capabilities provided by existing stack (BCL, Microsoft.Graph 5.74.0, WPF PresentationCore).
**v1.1 architectural notes (carried forward):**
- Global site selection (Phase 6) changes the toolbar; all tabs bind to shared `GlobalSiteSelectionViewModel`. `WeakReferenceMessenger` for cross-tab site-changed notifications.
- Per-tab override (SITE-02): each `FeatureViewModelBase` subclass stores a nullable local site override; null means "use global".
- Storage Visualization (Phase 9): LiveCharts2, WPF-native, self-contained friendly.
- [Phase 10-branding-data-foundation]: No ConsistencyLevel header on equality filter for GetUsersAsync (unlike GraphUserSearchService startsWith which requires it)
- [Phase 10-branding-data-foundation]: MapUser extracted as internal static in GraphUserDirectoryService for direct unit testability without live Graph endpoint
- [Phase 10-branding-data-foundation]: Type alias AppGraphClientFactory used in GraphUserDirectoryService to disambiguate from Microsoft.Graph.GraphClientFactory
- [Phase 10-branding-data-foundation]: Used WPF PresentationCore (BitmapDecoder/TransformedBitmap/JpegBitmapEncoder) for image compression instead of System.Drawing.Bitmap — System.Drawing.Common is not available without a new NuGet package on .NET 10, and WPF PresentationCore is already in the stack
- [Phase 10-branding-data-foundation]: LogoData is a non-positional record with init properties (not positional constructor) to avoid System.Text.Json deserialization failure
- [Phase 10-branding-data-foundation]: No new using statements required for Phase 10 DI registrations — SharepointToolbox.Infrastructure.Persistence and SharepointToolbox.Services were already imported
- [Phase 11-html-export-branding]: BrandingHtmlHelper is internal — only used within Services.Export namespace, tests access via InternalsVisibleTo
- [Phase 11-html-export-branding]: InternalsVisibleTo added via MSBuild AssemblyAttribute ItemGroup in csproj
- [Phase 11-html-export-branding]: branding parameter placed AFTER CancellationToken ct in WriteAsync — existing positional callers unaffected
- [Phase 11-html-export-branding]: MakeBranding helper added locally to each test class — test files stay self-contained
- [Phase 11]: Test constructors on 3 ViewModels received optional IBrandingService? brandingService = null as last parameter to preserve all existing test call sites
- [Phase 11]: Guard clause (if _brandingService is not null) used for graceful degradation — branding = null fallback preserves backward compat
- [Phase 11]: No App.xaml.cs changes needed for ViewModel branding injection — IBrandingService already registered as singleton, ViewModel registrations auto-resolve
- [Phase 12]: Skipped BitmapImage creation test due to missing Xunit.StaFact; STA thread required for WPF BitmapImage
- [Phase 12]: Used Grid overlay with DataTrigger for logo/placeholder visibility toggle in SettingsView
- [Phase 12]: Label+StackPanel layout for logo section in ProfileManagementDialog, consistent with SettingsView pattern
- [Phase 13]: UserType added as last positional param for backward compat; includeGuests defaults false; userType always in Select
- [Phase 13]: Directory always fetches with includeGuests=true from Graph; member/guest filtering is in-memory via ICollectionView
- [Phase 13]: Separate _directoryCts for directory load cancellation (independent from base class _cts)
**v2.3 notable constraints:**
- Phase 19 has the highest blast radius (Entra changes) — must be last
- Phase 15 is zero-API-call foundation; unblocks Phase 16 (consolidation) and Phase 18 (ownership) independently
- Group expansion (Phase 17) calls Graph at export time, not at scan time — scan pipeline unchanged
- Auto-take ownership uses PnP `Tenant.SetSiteAdmin` — requires Tenant Admin scope
- App registration must be atomic with rollback; partial Entra state is worse than no state
### Pending Todos
- Confirm `$filter=accountEnabled eq true and userType eq 'Member'` behavior without `ConsistencyLevel: eventual` against a real tenant before Phase 13 planning.
- Verify Entra `bannerLogo` stream endpoint returns empty body (not HTTP 404) when no tenant branding is configured — determines error handling branch for BRAND-04 auto-pull.
- Decide report header layout before Phase 11: logos side-by-side (current spec: `display: flex; gap: 16px`, MSP left + client right).
- Decide "Load Directory" button placement before Phase 14: inside browse panel (recommended) or tab-level toolbar.
None.
### Blockers/Concerns
@@ -85,7 +72,7 @@ None.
## Session Continuity
Last session: 2026-04-08T14:08:49.577Z
Stopped at: Completed 13-02-PLAN.md
Last session: 2026-04-09
Stopped at: Roadmap created — ready to plan Phase 15
Resume file: None
Next step: `/gsd:execute-phase 11`
Next step: `/gsd:plan-phase 15`
+59
View File
@@ -0,0 +1,59 @@
# Requirements Archive: SharePoint Toolbox v2.2 Report Branding & User Directory
**Defined:** 2026-04-08
**Completed:** 2026-04-09
**Coverage:** 11/11 requirements complete
## Requirements
### Report Branding
- [x] **BRAND-01**: User can import an MSP logo in application settings (global, persisted across sessions)
- [x] **BRAND-02**: User can preview the imported MSP logo in settings UI
- [x] **BRAND-03**: User can import a client logo per tenant profile
- [x] **BRAND-04**: User can auto-pull client logo from tenant's Entra branding API
- [x] **BRAND-05**: All five HTML report types display MSP and client logos in a consistent header
- [x] **BRAND-06**: Logo import validates format (PNG/JPG) and enforces 512 KB size limit
### User Directory
- [x] **UDIR-01**: User can toggle between search mode and directory browse mode in user access audit tab
- [x] **UDIR-02**: User can browse full tenant user directory with pagination (handles 999+ users)
- [x] **UDIR-03**: User can filter directory by user type (member vs guest)
- [x] **UDIR-04**: User can see department and job title columns in directory list
- [x] **UDIR-05**: User can select one or more users from directory to run the access audit
## Traceability
| Requirement | Phase | Status | Notes |
|-------------|-------|--------|-------|
| BRAND-01 | Phase 10 | Complete | Base64 JSON persistence via BrandingRepository |
| BRAND-02 | Phase 12 | Complete | Base64ToImageSourceConverter + live preview |
| BRAND-03 | Phase 10 | Complete | Per-tenant logo on TenantProfile |
| BRAND-04 | Phase 11 | Complete | Entra bannerLogo stream endpoint |
| BRAND-05 | Phase 11 | Complete | BrandingHtmlHelper + optional param on all 5 services |
| BRAND-06 | Phase 10 | Complete | Magic-byte validation, 512 KB limit, auto-compression |
| UDIR-01 | Phase 13 | Complete | IsDirectoryBrowseMode toggle property |
| UDIR-02 | Phase 13 | Complete | PageIterator pagination via GraphUserDirectoryService |
| UDIR-03 | Phase 13 | Complete | In-memory ICollectionView filter |
| UDIR-04 | Phase 13 | Complete | Sortable Department/JobTitle columns |
| UDIR-05 | Phase 14 | Complete | SelectDirectoryUserCommand + double-click handler |
## Deferred to Future Milestones
- **BRAND-F01**: PDF export with embedded logos
- **BRAND-F02**: Custom report title/footer text per tenant
- **UDIR-F01**: Session-scoped directory cache (avoid re-fetching on tab switch)
- **UDIR-F02**: Export user directory list to CSV
## Out of Scope
| Feature | Reason |
|---------|--------|
| CSV report branding | CSV is data-only format; logos don't apply |
| Logo in application title bar | Not a report branding concern; separate UX decision |
| User directory as standalone tab | Directory browse is a mode within existing user access audit tab |
| Real-time directory sync | One-time load with manual refresh is sufficient for audit workflows |
---
*Archived: 2026-04-09*
+73
View File
@@ -0,0 +1,73 @@
# v2.2 Report Branding & User Directory — Milestone Archive
**Goal:** Add customizable logos to HTML reports and a full user directory browse mode in the user access audit tab
**Status:** Shipped 2026-04-09
**Timeline:** 2026-04-08 to 2026-04-09
## Stats
| Metric | Value |
|--------|-------|
| Phases | 5 (Phases 10-14) |
| Plans | 14 |
| Commits | 47 |
| C# LOC (total) | 16,916 |
| Tests | 285 pass / 26 skip |
| Requirements | 11/11 complete |
## Key Accomplishments
1. **Branding Data Foundation (Phase 10)** — Logo models with base64 JSON persistence, BrandingRepository, BrandingService with magic-byte validation (PNG/JPG) and auto-compression via WPF PresentationCore, GraphUserDirectoryService with PageIterator pagination for full tenant user enumeration.
2. **HTML Export Branding (Phase 11)** — BrandingHtmlHelper static class for consistent header generation, optional `ReportBranding` parameter added to all 5 HTML export services (Permissions, Storage, Search, Duplicates, User Access), ViewModel injection via IBrandingService, logo management commands (browse/clear) on Settings and Profile ViewModels, Entra branding API auto-pull for client logos.
3. **Branding UI Views (Phase 12)** — Base64ToImageSourceConverter for live logo preview, MSP logo section in SettingsView (import/preview/clear), client logo section in ProfileManagementDialog (import/preview/clear/Entra pull), Grid overlay with DataTrigger for placeholder visibility toggle.
4. **User Directory ViewModel (Phase 13)** — Browse mode toggle on UserAccessAuditViewModel, paginated directory load with cancellation via separate CancellationTokenSource, in-memory member/guest filter (fetches all users once, filters via ICollectionView), sortable columns for DisplayName, UPN, Department, JobTitle.
5. **User Directory View (Phase 14)** — Search/Browse RadioButton mode toggle, directory DataGrid with loading counter and cancel button, SelectDirectoryUserCommand bridging directory selection to existing audit pipeline, double-click code-behind handler, 14 localization keys (EN + FR).
## Phases
### Phase 10: Branding Data Foundation (3 plans)
- Logo models, BrandingRepository, BrandingService with validation/compression
- GraphUserDirectoryService with PageIterator pagination
- DI registration in App.xaml.cs and full test suite gate
### Phase 11: HTML Export Branding + ViewModel Integration (4 plans)
- ReportBranding model + BrandingHtmlHelper static class with unit tests
- Add optional branding param to all 5 HTML export services
- Wire IBrandingService into all 5 export ViewModels
- Logo management commands (Settings + Profile) and Entra auto-pull
### Phase 12: Branding UI Views (3 plans)
- Base64ToImageSourceConverter, localization keys, App.xaml registration, ClientLogoPreview property
- SettingsView MSP logo section (preview, import, clear)
- ProfileManagementDialog client logo section (preview, import, clear, Entra pull)
### Phase 13: User Directory ViewModel (2 plans)
- Extend GraphDirectoryUser with UserType + service includeGuests parameter
- UserAccessAuditViewModel directory browse mode (toggle, load, filter, sort, tests)
### Phase 14: User Directory View (2 plans)
- Localization keys (EN+FR), SelectDirectoryUserCommand, code-behind double-click handler
- XAML: mode toggle (Search/Browse RadioButtons), directory DataGrid, loading UX, shared SelectedUsers panel
## Requirements Covered
| Requirement | Description | Status |
|-------------|-------------|--------|
| BRAND-01 | Import MSP logo in application settings | Complete |
| BRAND-02 | Preview imported MSP logo in settings UI | Complete |
| BRAND-03 | Import client logo per tenant profile | Complete |
| BRAND-04 | Auto-pull client logo from Entra branding API | Complete |
| BRAND-05 | All 5 HTML reports display logos in consistent header | Complete |
| BRAND-06 | Logo validation (PNG/JPG, 512 KB limit) | Complete |
| UDIR-01 | Toggle between search and directory browse mode | Complete |
| UDIR-02 | Browse full tenant user directory with pagination | Complete |
| UDIR-03 | Filter directory by user type (member vs guest) | Complete |
| UDIR-04 | Department and job title columns in directory list | Complete |
| UDIR-05 | Select users from directory to run access audit | Complete |
---
*Archived: 2026-04-09*
@@ -0,0 +1,275 @@
---
phase: 14-user-directory-view
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- SharepointToolbox/Localization/Strings.resx
- SharepointToolbox/Localization/Strings.fr.resx
- SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
- SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml.cs
- SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs
autonomous: true
requirements:
- UDIR-05
- UDIR-01
must_haves:
truths:
- "SelectDirectoryUserCommand takes a GraphDirectoryUser, converts it to GraphUserResult, adds it to SelectedUsers via existing logic"
- "After SelectDirectoryUserCommand, the user appears in SelectedUsers and can be audited with RunCommand"
- "SelectDirectoryUserCommand does not add duplicates (same UPN check as existing AddUserCommand)"
- "Localization keys for directory UI exist in both EN and FR resource files"
- "Code-behind has a DirectoryDataGrid_MouseDoubleClick handler that invokes SelectDirectoryUserCommand"
artifacts:
- path: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
provides: "SelectDirectoryUserCommand bridging directory selection to audit pipeline"
contains: "SelectDirectoryUserCommand"
- path: "SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml.cs"
provides: "Event handler for directory DataGrid double-click"
contains: "DirectoryDataGrid_MouseDoubleClick"
- path: "SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs"
provides: "Tests for SelectDirectoryUserCommand"
contains: "SelectDirectoryUser"
key_links:
- from: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
to: "SharepointToolbox/Core/Models/GraphDirectoryUser.cs"
via: "command parameter type"
pattern: "GraphDirectoryUser"
- from: "SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml.cs"
to: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
via: "command invocation"
pattern: "SelectDirectoryUserCommand"
---
<objective>
Add localization keys for directory UI, the SelectDirectoryUserCommand that bridges directory selection to the audit pipeline, and a code-behind event handler for DataGrid double-click.
Purpose: Provides the infrastructure (localization, command, event handler) that Plan 14-02 needs to build the XAML view. SC2 requires selecting a directory user to trigger an audit — this command makes that possible.
Output: Localization keys (EN+FR), SelectDirectoryUserCommand with tests, code-behind event handler.
</objective>
<execution_context>
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/14-user-directory-view/14-RESEARCH.md
<interfaces>
<!-- Current ViewModel command pattern (AddUserCommand) -->
From SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs:
```csharp
public RelayCommand<GraphUserResult> AddUserCommand { get; }
private void ExecuteAddUser(GraphUserResult? user)
{
if (user == null) return;
if (!SelectedUsers.Any(u => u.UserPrincipalName == user.UserPrincipalName))
{
SelectedUsers.Add(user);
}
SearchQuery = string.Empty;
SearchResults.Clear();
}
```
<!-- GraphDirectoryUser record -->
From SharepointToolbox/Core/Models/GraphDirectoryUser.cs:
```csharp
public record GraphDirectoryUser(
string DisplayName, string UserPrincipalName,
string? Mail, string? Department, string? JobTitle, string? UserType);
```
<!-- GraphUserResult record -->
From SharepointToolbox/Services/IGraphUserSearchService.cs:
```csharp
public record GraphUserResult(string DisplayName, string UserPrincipalName, string? Mail);
```
<!-- Existing code-behind pattern -->
From SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml.cs:
```csharp
private void SearchResultsListBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is ListBox listBox && listBox.SelectedItem is GraphUserResult user)
{
var vm = (UserAccessAuditViewModel)DataContext;
if (vm.AddUserCommand.CanExecute(user))
vm.AddUserCommand.Execute(user);
listBox.SelectedItem = null;
}
}
```
<!-- Existing localization key pattern -->
From Strings.resx:
```xml
<data name="audit.grp.users" xml:space="preserve"><value>Select Users</value></data>
<data name="audit.btn.run" xml:space="preserve"><value>Run Audit</value></data>
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add localization keys for directory UI (EN + FR)</name>
<files>
SharepointToolbox/Localization/Strings.resx,
SharepointToolbox/Localization/Strings.fr.resx
</files>
<behavior>
- Both resx files contain matching keys for directory browse UI
</behavior>
<action>
1. Add to `Strings.resx` (EN):
- `audit.mode.search` = "Search"
- `audit.mode.browse` = "Browse Directory"
- `directory.grp.browse` = "User Directory"
- `directory.btn.load` = "Load Directory"
- `directory.btn.cancel` = "Cancel"
- `directory.filter.placeholder` = "Filter users..."
- `directory.chk.guests` = "Include guests"
- `directory.status.count` = "users"
- `directory.hint.doubleclick` = "Double-click a user to add to audit"
- `directory.col.name` = "Name"
- `directory.col.upn` = "Email"
- `directory.col.department` = "Department"
- `directory.col.jobtitle` = "Job Title"
- `directory.col.type` = "Type"
2. Add to `Strings.fr.resx` (FR):
- `audit.mode.search` = "Recherche"
- `audit.mode.browse` = "Parcourir l'annuaire"
- `directory.grp.browse` = "Annuaire utilisateurs"
- `directory.btn.load` = "Charger l'annuaire"
- `directory.btn.cancel` = "Annuler"
- `directory.filter.placeholder` = "Filtrer les utilisateurs..."
- `directory.chk.guests` = "Inclure les invités"
- `directory.status.count` = "utilisateurs"
- `directory.hint.doubleclick` = "Double-cliquez sur un utilisateur pour l'ajouter à l'audit"
- `directory.col.name` = "Nom"
- `directory.col.upn` = "Courriel"
- `directory.col.department` = "Département"
- `directory.col.jobtitle` = "Poste"
- `directory.col.type` = "Type"
</action>
<verify>
<automated>dotnet build --no-restore -warnaserror</automated>
</verify>
<done>14 localization keys present in both EN and FR resource files.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add SelectDirectoryUserCommand to ViewModel</name>
<files>
SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs,
SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs
</files>
<behavior>
- SelectDirectoryUserCommand is a RelayCommand<GraphDirectoryUser>
- It converts GraphDirectoryUser to GraphUserResult and adds to SelectedUsers
- Duplicate UPN check (same as AddUserCommand)
- Does NOT clear SearchQuery/SearchResults (not in search mode context)
- After execution, IsBrowseMode stays true — user can continue selecting from directory
</behavior>
<action>
1. Add command declaration in ViewModel:
```csharp
public RelayCommand<GraphDirectoryUser> SelectDirectoryUserCommand { get; }
```
2. Initialize in BOTH constructors:
```csharp
SelectDirectoryUserCommand = new RelayCommand<GraphDirectoryUser>(ExecuteSelectDirectoryUser);
```
3. Implement the command method:
```csharp
private void ExecuteSelectDirectoryUser(GraphDirectoryUser? dirUser)
{
if (dirUser == null) return;
var userResult = new GraphUserResult(dirUser.DisplayName, dirUser.UserPrincipalName, dirUser.Mail);
if (!SelectedUsers.Any(u => u.UserPrincipalName == userResult.UserPrincipalName))
{
SelectedUsers.Add(userResult);
}
}
```
4. Add tests to `UserAccessAuditViewModelDirectoryTests.cs`:
- Test: SelectDirectoryUserCommand adds user to SelectedUsers
- Test: SelectDirectoryUserCommand skips duplicates
- Test: SelectDirectoryUserCommand with null does nothing
- Test: After SelectDirectoryUser, user can be audited with RunCommand (integration: add user + check SelectedUsers.Count > 0)
</action>
<verify>
<automated>dotnet build --no-restore -warnaserror && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~UserAccessAuditViewModelDirectory" --no-build -q</automated>
</verify>
<done>SelectDirectoryUserCommand bridges directory selection to audit pipeline. Tests pass.</done>
</task>
<task type="auto">
<name>Task 3: Add code-behind event handler for directory DataGrid</name>
<files>
SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml.cs
</files>
<behavior>
- DirectoryDataGrid_MouseDoubleClick handler extracts the clicked GraphDirectoryUser
- Invokes SelectDirectoryUserCommand with the selected user
- Uses the same pattern as SearchResultsListBox_SelectionChanged
</behavior>
<action>
1. Add to `UserAccessAuditView.xaml.cs`:
```csharp
private void DirectoryDataGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (sender is DataGrid grid && grid.SelectedItem is GraphDirectoryUser user)
{
var vm = (UserAccessAuditViewModel)DataContext;
if (vm.SelectDirectoryUserCommand.CanExecute(user))
vm.SelectDirectoryUserCommand.Execute(user);
}
}
```
2. Add the required using statement if not present:
```csharp
using System.Windows.Controls; // Already present
using SharepointToolbox.Core.Models; // For GraphDirectoryUser
```
</action>
<verify>
<automated>dotnet build --no-restore -warnaserror</automated>
</verify>
<done>Code-behind event handler exists, ready to be wired in XAML (Plan 14-02).</done>
</task>
</tasks>
<verification>
```bash
dotnet build --no-restore -warnaserror
dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~UserAccessAuditViewModelDirectory" --no-build -q
```
Both must pass with zero failures.
</verification>
<success_criteria>
- 14 localization keys in both EN and FR resx files
- SelectDirectoryUserCommand converts GraphDirectoryUser → GraphUserResult → SelectedUsers
- Duplicate UPN check prevents adding same user twice
- Code-behind event handler for DataGrid double-click
- All tests pass, build clean
</success_criteria>
<output>
After completion, create `.planning/phases/14-user-directory-view/14-01-SUMMARY.md`
</output>
@@ -0,0 +1,103 @@
---
phase: 14-user-directory-view
plan: 01
subsystem: ui
tags: [wpf, localization, resx, relay-command, datagrid, directory]
# Dependency graph
requires:
- phase: 13-user-directory-data
provides: "GraphDirectoryUser model, IGraphUserDirectoryService, directory browse mode properties on ViewModel"
provides:
- "14 localization keys (EN+FR) for directory browse UI"
- "SelectDirectoryUserCommand bridging directory selection to audit pipeline"
- "DirectoryDataGrid_MouseDoubleClick code-behind event handler"
affects: [14-user-directory-view]
# Tech tracking
tech-stack:
added: []
patterns:
- "SelectDirectoryUserCommand follows same RelayCommand<T> + duplicate UPN check pattern as AddUserCommand"
- "Code-behind event handler pattern: extract model from DataGrid.SelectedItem, invoke ViewModel command"
key-files:
created: []
modified:
- "SharepointToolbox/Localization/Strings.resx"
- "SharepointToolbox/Localization/Strings.fr.resx"
- "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
- "SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml.cs"
- "SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs"
key-decisions:
- "SelectDirectoryUserCommand does not clear SearchQuery/SearchResults since it operates in browse mode context"
- "ExecuteSelectDirectoryUser placed alongside ExecuteAddUser/ExecuteRemoveUser in command implementations section"
patterns-established:
- "Directory-to-audit bridge: GraphDirectoryUser -> GraphUserResult conversion via SelectDirectoryUserCommand"
requirements-completed: [UDIR-05, UDIR-01]
# Metrics
duration: 3min
completed: 2026-04-09
---
# Phase 14 Plan 01: Directory UI Infrastructure Summary
**Localization keys (EN+FR), SelectDirectoryUserCommand bridging directory selection to audit pipeline, and DataGrid double-click code-behind handler**
## Performance
- **Duration:** 3 min
- **Started:** 2026-04-09T07:24:15Z
- **Completed:** 2026-04-09T07:27:00Z
- **Tasks:** 3
- **Files modified:** 5
## Accomplishments
- 14 localization keys added to both EN and FR resource files for directory browse UI
- SelectDirectoryUserCommand converts GraphDirectoryUser to GraphUserResult and adds to SelectedUsers with duplicate UPN check
- DirectoryDataGrid_MouseDoubleClick code-behind handler ready for XAML wiring in Plan 14-02
- 4 new tests added (20 total in directory test file), all passing
## Task Commits
Each task was committed atomically:
1. **Task 1: Add localization keys (EN + FR)** - `70e8d12` (feat)
2. **Task 2: Add SelectDirectoryUserCommand (TDD RED)** - `381081d` (test)
3. **Task 2: Add SelectDirectoryUserCommand (TDD GREEN)** - `e6ba2d8` (feat)
4. **Task 3: Add code-behind event handler** - `d1282ce` (feat)
## Files Created/Modified
- `SharepointToolbox/Localization/Strings.resx` - 14 EN localization keys for directory browse UI
- `SharepointToolbox/Localization/Strings.fr.resx` - 14 FR localization keys for directory browse UI
- `SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs` - SelectDirectoryUserCommand declaration, initialization in both constructors, ExecuteSelectDirectoryUser method
- `SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml.cs` - DirectoryDataGrid_MouseDoubleClick handler, using for Core.Models
- `SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs` - 4 new tests (17-20) for SelectDirectoryUserCommand
## Decisions Made
- SelectDirectoryUserCommand does not clear SearchQuery/SearchResults since it operates in browse mode context (unlike AddUserCommand which clears search state)
- ExecuteSelectDirectoryUser placed in command implementations section alongside ExecuteAddUser/ExecuteRemoveUser for code locality
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- All infrastructure for Plan 14-02 (XAML view) is in place
- Localization keys ready for binding
- SelectDirectoryUserCommand ready for DataGrid double-click binding
- Code-behind handler ready to be wired via MouseDoubleClick event in XAML
---
*Phase: 14-user-directory-view*
*Completed: 2026-04-09*
@@ -0,0 +1,338 @@
---
phase: 14-user-directory-view
plan: 02
type: execute
wave: 2
depends_on: [14-01]
files_modified:
- SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml
autonomous: true
requirements:
- UDIR-05
- UDIR-01
must_haves:
truths:
- "The left panel shows a mode toggle (two RadioButtons: Search / Browse Directory) at the top"
- "When Search mode is selected (IsBrowseMode=false), the existing people-picker GroupBox is visible and the directory panel is collapsed"
- "When Browse mode is selected (IsBrowseMode=true), the directory panel is visible and the people-picker GroupBox is collapsed"
- "The Scan Options GroupBox and Run/Export buttons remain visible in both modes"
- "The directory panel contains: Load Directory button, Cancel button, Include guests checkbox, filter TextBox, status text, user count, and a DataGrid"
- "The DataGrid is bound to DirectoryUsersView with columns: Name, Email, Department, Job Title, Type"
- "The DataGrid has MouseDoubleClick wired to DirectoryDataGrid_MouseDoubleClick code-behind handler"
- "While loading, the status text shows DirectoryLoadStatus and Load button is disabled"
- "A hint text tells users to double-click to add a user to the audit"
- "The SelectedUsers ItemsControl remains visible in both modes (users added from directory appear here)"
artifacts:
- path: "SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml"
provides: "Complete directory browse UI with mode toggle, directory DataGrid, and loading UX"
contains: "DirectoryUsersView"
key_links:
- from: "SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml"
to: "SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs"
via: "data binding"
pattern: "IsBrowseMode|DirectoryUsersView|LoadDirectoryCommand|DirectoryFilterText|IncludeGuests"
---
<objective>
Add the complete directory browse UI to UserAccessAuditView.xaml with mode toggle, directory DataGrid, loading indicators, and seamless integration with the existing audit workflow.
Purpose: SC1-SC4 require visible UI for mode switching, directory display, loading progress, and cancellation. This plan wires all Phase 13 ViewModel properties to the View layer.
Output: Updated UserAccessAuditView.xaml with full directory browse mode.
</objective>
<execution_context>
@C:/Users/dev/.claude/get-shit-done/workflows/execute-plan.md
@C:/Users/dev/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/14-user-directory-view/14-RESEARCH.md
<interfaces>
<!-- ViewModel bindings available (Phase 13 + 14-01) -->
From SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs:
```csharp
// Mode toggle
public bool IsBrowseMode { get; set; }
// Directory data
public ObservableCollection<GraphDirectoryUser> DirectoryUsers { get; }
public ICollectionView DirectoryUsersView { get; } // filtered + sorted
public int DirectoryUserCount { get; } // computed filtered count
// Directory commands
public IAsyncRelayCommand LoadDirectoryCommand { get; }
public RelayCommand CancelDirectoryLoadCommand { get; }
public RelayCommand<GraphDirectoryUser> SelectDirectoryUserCommand { get; }
// Directory state
public bool IsLoadingDirectory { get; }
public string DirectoryLoadStatus { get; }
public bool IncludeGuests { get; set; }
public string DirectoryFilterText { get; set; }
// Existing (still visible in both modes)
public ObservableCollection<GraphUserResult> SelectedUsers { get; }
public string SelectedUsersLabel { get; }
public IAsyncRelayCommand RunCommand { get; }
public RelayCommand CancelCommand { get; }
public IAsyncRelayCommand ExportCsvCommand { get; }
public IAsyncRelayCommand ExportHtmlCommand { get; }
```
<!-- Available converters from App.xaml -->
- `{StaticResource BoolToVisibilityConverter}` — true→Visible, false→Collapsed
- `{StaticResource InverseBoolConverter}` — inverts bool
- `{StaticResource StringToVisibilityConverter}` — non-empty→Visible
<!-- Current left panel structure -->
```
DockPanel (290px, Margin 8)
├── GroupBox "Select Users" (DockPanel.Dock="Top") — SEARCH MODE (hide when IsBrowseMode)
│ └── SearchQuery, SearchResults, SelectedUsers, SelectedUsersLabel
├── GroupBox "Scan Options" (DockPanel.Dock="Top") — ALWAYS VISIBLE
│ └── CheckBoxes
└── StackPanel (DockPanel.Dock="Top") — ALWAYS VISIBLE
└── Run/Cancel/Export buttons
```
<!-- Code-behind handler (from 14-01) -->
```csharp
private void DirectoryDataGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e)
{
if (sender is DataGrid grid && grid.SelectedItem is GraphDirectoryUser user)
{
var vm = (UserAccessAuditViewModel)DataContext;
if (vm.SelectDirectoryUserCommand.CanExecute(user))
vm.SelectDirectoryUserCommand.Execute(user);
}
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Restructure left panel with mode toggle and conditional panels</name>
<files>
SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml
</files>
<behavior>
- At the top of the left panel DockPanel, a mode toggle section appears with two RadioButtons
- RadioButton "Search" is checked when IsBrowseMode=false (uses InverseBoolConverter)
- RadioButton "Browse Directory" is checked when IsBrowseMode=true
- Below the toggle: existing Search GroupBox (visible when IsBrowseMode=false) OR new Directory GroupBox (visible when IsBrowseMode=true)
- SelectedUsers ItemsControl + label extracted from Search GroupBox and placed in a shared section visible in BOTH modes
- Scan Options GroupBox and buttons remain always visible
- Directory GroupBox contains:
a) Two-button grid: Load Directory + Cancel (like Run/Cancel pattern)
b) CheckBox for IncludeGuests
c) Filter TextBox bound to DirectoryFilterText
d) Status/count row: DirectoryLoadStatus + DirectoryUserCount
e) DataGrid bound to DirectoryUsersView with 5 columns (Name, Email, Department, Job Title, Type)
f) Hint text: "Double-click a user to add to audit"
- DataGrid has MouseDoubleClick="DirectoryDataGrid_MouseDoubleClick"
- DataGrid uses AutoGenerateColumns="False", IsReadOnly="True", virtualization enabled
- DataGrid columns are DataGridTextColumn (simple text, sortable by default)
- Guest users highlighted with a subtle "Guest" badge in the Type column (orange, like the existing UserAccessAuditView pattern)
</behavior>
<action>
1. Read the current `UserAccessAuditView.xaml` to get the exact current content.
2. Replace the left panel DockPanel content with the new structure:
```xml
<!-- Mode toggle -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.mode.search]}"
IsChecked="{Binding IsBrowseMode, Converter={StaticResource InverseBoolConverter}}"
Margin="0,0,12,0" />
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.mode.browse]}"
IsChecked="{Binding IsBrowseMode}" />
</StackPanel>
<!-- SEARCH MODE PANEL (visible when IsBrowseMode=false) -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.users]}"
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8"
Visibility="{Binding IsBrowseMode, Converter={StaticResource BoolToVisibilityConverter}, ConverterParameter=Inverse}">
<!-- Keep existing SearchQuery, SearchResults, but move SelectedUsers OUT -->
<StackPanel>
<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,2" />
<!-- Searching indicator -->
<TextBlock Text="Searching..." FontStyle="Italic" FontSize="10" Foreground="Gray" Margin="0,0,0,2">
[existing DataTrigger style]
</TextBlock>
<!-- Search results dropdown -->
<ListBox x:Name="SearchResultsListBox" [existing bindings] />
</StackPanel>
</GroupBox>
<!-- BROWSE MODE PANEL (visible when IsBrowseMode=true) -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.grp.browse]}"
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8"
Visibility="{Binding IsBrowseMode, Converter={StaticResource BoolToVisibilityConverter}}">
<DockPanel>
<!-- Load/Cancel buttons -->
<Grid DockPanel.Dock="Top" Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.btn.load]}"
Command="{Binding LoadDirectoryCommand}" Margin="0,0,4,0" Padding="6,3" />
<Button Grid.Column="1"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.btn.cancel]}"
Command="{Binding CancelDirectoryLoadCommand}" Padding="6,3" />
</Grid>
<!-- Include guests checkbox -->
<CheckBox DockPanel.Dock="Top"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.chk.guests]}"
IsChecked="{Binding IncludeGuests}" Margin="0,0,0,4" />
<!-- Filter text -->
<TextBox DockPanel.Dock="Top"
Text="{Binding DirectoryFilterText, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,4" />
<!-- Status row: load status + user count -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{Binding DirectoryLoadStatus}" FontStyle="Italic" Foreground="Gray" FontSize="10"
Margin="0,0,8,0" />
<TextBlock FontSize="10" Foreground="Gray">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} {1}">
<Binding Path="DirectoryUserCount" />
<Binding Source="{x:Static loc:TranslationSource.Instance}" Path="[directory.status.count]" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
<!-- Hint text -->
<TextBlock DockPanel.Dock="Bottom"
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.hint.doubleclick]}"
FontStyle="Italic" Foreground="Gray" FontSize="10" Margin="0,4,0,0"
TextWrapping="Wrap" />
<!-- Directory DataGrid -->
<DataGrid x:Name="DirectoryDataGrid"
ItemsSource="{Binding DirectoryUsersView}"
AutoGenerateColumns="False" IsReadOnly="True"
VirtualizingPanel.IsVirtualizing="True" EnableRowVirtualization="True"
MouseDoubleClick="DirectoryDataGrid_MouseDoubleClick"
CanUserSortColumns="True"
SelectionMode="Single" SelectionUnit="FullRow"
HeadersVisibility="Column" GridLinesVisibility="Horizontal"
BorderThickness="1" BorderBrush="#DDDDDD">
<DataGrid.Columns>
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.name]}"
Binding="{Binding DisplayName}" Width="120" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.upn]}"
Binding="{Binding UserPrincipalName}" Width="140" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.department]}"
Binding="{Binding Department}" Width="90" />
<DataGridTextColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.jobtitle]}"
Binding="{Binding JobTitle}" Width="90" />
<DataGridTemplateColumn Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.type]}" Width="60">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding UserType}" VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding UserType}" Value="Guest">
<Setter Property="Foreground" Value="#F39C12" />
<Setter Property="FontWeight" Value="SemiBold" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</GroupBox>
<!-- SHARED: Selected users (visible in both modes) -->
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<ItemsControl ItemsSource="{Binding SelectedUsers}" Margin="0,0,0,4">
[existing ItemTemplate with blue border badges + x remove button]
</ItemsControl>
<TextBlock Text="{Binding SelectedUsersLabel}" FontStyle="Italic" Foreground="Gray" FontSize="10" />
</StackPanel>
<!-- Scan Options GroupBox (unchanged, always visible) -->
<GroupBox Header="..." DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
[existing checkboxes]
</GroupBox>
<!-- Run/Export buttons (unchanged, always visible) -->
<StackPanel DockPanel.Dock="Top">
[existing button grids]
</StackPanel>
```
IMPORTANT NOTES:
- The `BoolToVisibilityConverter` natively shows when true. For the Search panel (show when IsBrowseMode=false), we need inverse behavior. Two approaches:
a) Use a DataTrigger-based Style on Visibility (reliable)
b) Check if BoolToVisibilityConverter supports a ConverterParameter for inversion
Since we're not sure the converter supports inversion, use DataTrigger approach for the Search panel:
```xml
<GroupBox.Style>
<Style TargetType="GroupBox">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsBrowseMode}" Value="True">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</GroupBox.Style>
```
And for the Browse panel, use `BoolToVisibilityConverter` directly (shows when IsBrowseMode=true).
- The SelectedUsers ItemsControl must be EXTRACTED from the Search GroupBox and placed in a standalone section — it needs to remain visible when in Browse mode so users can see who they've selected from the directory.
- DataGrid column headers use localized bindings. Note: DataGridTextColumn.Header does NOT support binding in standard WPF — it's not a FrameworkElement. Instead, use DataGridTemplateColumn with HeaderTemplate for localized headers, OR set the Header as a plain string and skip localization for column headers (simpler approach). DECISION: Use plain English headers for DataGrid columns (they are technical column names that don't benefit from localization as much). This avoids the complex HeaderTemplate pattern. Use the localization keys in other UI elements.
Alternative if Header binding works (some WPF versions support it via x:Static): Test with `Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.col.name]}"` — if it compiles and works, great. If not, fall back to plain strings.
</action>
<verify>
<automated>dotnet build --no-restore -warnaserror</automated>
</verify>
<done>UserAccessAuditView has full directory browse UI with mode toggle, conditional panels, directory DataGrid, loading status, and double-click selection. Build passes.</done>
</task>
</tasks>
<verification>
```bash
dotnet build --no-restore -warnaserror
```
Build must pass. Visual verification requires manual testing.
</verification>
<success_criteria>
- SC1: Mode toggle (RadioButtons) visibly switches left panel between search and browse
- SC2: DataGrid double-click adds user to SelectedUsers; Run Audit button works as usual
- SC3: Loading status shows DirectoryLoadStatus, Load button disabled while loading, Cancel button active
- SC4: Cancel clears loading state; status returns to ready; no broken UI
- SelectedUsers visible in both modes
- DataGrid columns: Name, Email, Department, Job Title, Type (Guest highlighted in orange)
- Filter TextBox and IncludeGuests checkbox functional
- Build passes with zero warnings
</success_criteria>
<output>
After completion, create `.planning/phases/14-user-directory-view/14-02-SUMMARY.md`
</output>
@@ -0,0 +1,95 @@
---
phase: 14-user-directory-view
plan: 02
subsystem: ui
tags: [wpf, xaml, datagrid, radio-button, data-trigger, directory-browse]
requires:
- phase: 14-user-directory-view/01
provides: "Code-behind handler DirectoryDataGrid_MouseDoubleClick and localization keys"
- phase: 13-user-directory-data
provides: "ViewModel properties: IsBrowseMode, DirectoryUsersView, LoadDirectoryCommand, etc."
provides:
- "Complete directory browse UI in UserAccessAuditView with mode toggle, DataGrid, and loading UX"
- "Mode switching between search and browse panels"
- "Guest user highlighting in directory DataGrid"
affects: [user-directory-view]
tech-stack:
added: []
patterns: ["DataTrigger inverse visibility for mode-conditional panels", "Shared SelectedUsers section visible across modes"]
key-files:
created: []
modified: ["SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml"]
key-decisions:
- "Used DataTrigger inverse visibility for search panel instead of ConverterParameter=Inverse (more reliable in WPF)"
- "Used plain English DataGrid column headers instead of localized bindings (DataGridTextColumn.Header binding is unreliable)"
- "GroupBox.Header uses nested TextBlock for localized binding compatibility with GroupBox.Style"
patterns-established:
- "DataTrigger inverse visibility: Style with default Visible, DataTrigger sets Collapsed on true"
- "Mode-conditional panels: search/browse GroupBoxes with opposite visibility triggers"
requirements-completed: [UDIR-05, UDIR-01]
duration: 2min
completed: 2026-04-09
---
# Phase 14 Plan 02: Directory Browse UI Summary
**Full directory browse mode UI with mode toggle RadioButtons, 5-column DataGrid, loading status, guest highlighting, and shared SelectedUsers section**
## Performance
- **Duration:** 2 min
- **Started:** 2026-04-09T07:28:21Z
- **Completed:** 2026-04-09T07:30:10Z
- **Tasks:** 1
- **Files modified:** 1
## Accomplishments
- Mode toggle (Search/Browse Directory) RadioButtons at top of left panel with InverseBoolConverter binding
- Search panel collapses when IsBrowseMode=true via DataTrigger approach; Browse panel shows via BoolToVisibilityConverter
- Directory panel with Load/Cancel buttons, IncludeGuests checkbox, filter TextBox, status/count display
- DataGrid with 5 columns (Name, Email, Department, Job Title, Type) bound to DirectoryUsersView
- Guest users highlighted in orange (#F39C12) with SemiBold font weight via DataTrigger on UserType
- SelectedUsers ItemsControl extracted from search GroupBox to shared section visible in both modes
- Scan Options and Run/Export buttons remain always visible in both modes
## Task Commits
Each task was committed atomically:
1. **Task 1: Restructure left panel with mode toggle and conditional panels** - `1a1e83c` (feat)
## Files Created/Modified
- `SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml` - Added mode toggle, browse panel with DataGrid, extracted SelectedUsers to shared section
## Decisions Made
- Used DataTrigger inverse visibility for search panel (Visible by default, Collapsed when IsBrowseMode=True) instead of ConverterParameter=Inverse -- more reliable across WPF versions
- Used plain English strings for DataGrid column headers ("Name", "Email", "Department", "Job Title", "Type") instead of localized bindings -- DataGridTextColumn.Header does not reliably support binding in standard WPF
- Moved GroupBox.Header to nested TextBlock element for search panel to avoid conflict between inline Header binding and GroupBox.Style on the same element
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Phase 14 is now complete (both plans executed)
- All directory browse UI elements are wired to ViewModel properties from Phase 13
- Manual testing recommended to verify visual layout, mode switching, DataGrid scrolling, and double-click selection
---
*Phase: 14-user-directory-view*
*Completed: 2026-04-09*
## Self-Check: PASSED
@@ -0,0 +1,47 @@
# Phase 14 Research: User Directory View
## What Exists (Phase 13 Deliverables)
### ViewModel Properties for Directory Browse
- `IsBrowseMode` (bool) — toggles Search/Browse mode
- `DirectoryUsers` (ObservableCollection<GraphDirectoryUser>) — raw directory list
- `DirectoryUsersView` (ICollectionView) — filtered/sorted view, default sort DisplayName asc
- `IsLoadingDirectory` (bool) — true while loading
- `DirectoryLoadStatus` (string) — "Loading... X users" progress text
- `IncludeGuests` (bool) — in-memory member/guest filter
- `DirectoryFilterText` (string) — text filter on DisplayName, UPN, Department, JobTitle
- `DirectoryUserCount` (int) — filtered count
- `LoadDirectoryCommand` (IAsyncRelayCommand) — disabled while loading
- `CancelDirectoryLoadCommand` (RelayCommand) — enabled only while loading
### Existing People-Picker (Search Mode)
- `SearchQuery` → debounced Graph search → `SearchResults` dropdown
- `AddUserCommand(GraphUserResult)``SelectedUsers` collection
- `RemoveUserCommand(GraphUserResult)` → removes from SelectedUsers
- `RunCommand``RunOperationAsync` → audits SelectedUsers against GlobalSites
### GAP: No SelectDirectoryUserCommand
SC2 requires "selecting a user from directory list launches existing audit pipeline."
Need a command that:
1. Takes a `GraphDirectoryUser` from the directory DataGrid
2. Converts it to `GraphUserResult` (same DisplayName + UPN)
3. Adds to `SelectedUsers` via existing `ExecuteAddUser` logic
This is ViewModel work — needs to be done before the View XAML.
### Current View Structure (UserAccessAuditView.xaml)
- Left panel (290px DockPanel): Users GroupBox + Options GroupBox + Buttons StackPanel
- Right panel: Summary banners + Filter/Toggle row + DataGrid (ResultsView)
- Status bar: ProgressBar + StatusMessage
### Available Converters
- `BoolToVisibilityConverter` — true→Visible, false→Collapsed
- `InverseBoolConverter` — inverts bool
- `StringToVisibilityConverter` — non-empty→Visible, empty→Collapsed
### Localization
- No directory.* keys exist — need to add ~10 keys for EN + FR
## Plan Breakdown
1. **14-01** (Wave 1): Add localization keys + `SelectDirectoryUserCommand` on ViewModel + code-behind event handler
2. **14-02** (Wave 2): Full XAML changes — mode toggle, conditional Search/Browse panels, directory DataGrid, loading UX
@@ -0,0 +1,98 @@
---
phase: 14-user-directory-view
verified: 2026-04-09T12:00:00Z
status: passed
score: 4/4 success criteria verified
gaps: []
---
# Phase 14: User Directory View Verification Report
**Phase Goal:** Administrators can toggle into directory browse mode from the user access audit tab, see the paginated user list with filters, and launch an access audit for a selected user.
**Verified:** 2026-04-09
**Status:** passed
**Re-verification:** No -- initial verification
## Goal Achievement
### Observable Truths (Success Criteria)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | The user access audit tab shows a mode toggle control that visibly switches the left panel between the existing people-picker and the directory browse panel | VERIFIED | XAML lines 19-25: two RadioButtons (Search/Browse Directory) bound to IsBrowseMode via InverseBoolConverter. Search GroupBox uses DataTrigger to collapse when IsBrowseMode=true (lines 32-40). Browse GroupBox uses BoolToVisibilityConverter on IsBrowseMode (line 87). Both converters exist in App.xaml. |
| 2 | In browse mode, selecting a user from the directory list and clicking Run Audit launches the existing audit pipeline for that user | VERIFIED | SelectDirectoryUserCommand (ViewModel line 554-562) converts GraphDirectoryUser to GraphUserResult and adds to SelectedUsers. DataGrid has MouseDoubleClick="DirectoryDataGrid_MouseDoubleClick" (XAML line 141). Code-behind handler (line 29-37) invokes SelectDirectoryUserCommand. RunCommand operates on SelectedUsers (line 244-246). Tests 17-20 confirm the full flow. |
| 3 | While the directory is loading, the panel shows a "Loading... X users" counter and an active cancel button; the load button is disabled to prevent concurrent requests | VERIFIED | LoadDirectoryAsync sets DirectoryLoadStatus="Loading..." then updates via Progress callback "Loading... {count} users" (ViewModel lines 411-415). LoadDirectoryCommand CanExecute = !IsLoadingDirectory (line 192). CancelDirectoryLoadCommand CanExecute = IsLoadingDirectory (line 194). OnIsLoadingDirectoryChanged notifies both commands (lines 378-382). XAML binds status text (line 118) and both buttons (lines 98-103). |
| 4 | When the directory load is cancelled or fails, the panel returns to a ready state with a clear status message and no broken UI | VERIFIED | Cancellation sets DirectoryLoadStatus="Load cancelled." (line 436). Failure sets "Failed: {message}" (line 440). Both paths set IsLoadingDirectory=false in finally block (line 445). Test 7 confirms cancellation flow. Tenant switch resets all directory state (lines 321-331, test 13). |
**Score:** 4/4 success criteria verified
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml` | Directory browse UI with mode toggle, DataGrid, loading UX | VERIFIED | 415 lines, complete implementation with mode toggle, search panel, browse panel, shared SelectedUsers, scan options, run/export buttons |
| `SharepointToolbox/Views/Tabs/UserAccessAuditView.xaml.cs` | Code-behind with DirectoryDataGrid_MouseDoubleClick | VERIFIED | Handler at line 29, extracts GraphDirectoryUser, invokes SelectDirectoryUserCommand |
| `SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs` | SelectDirectoryUserCommand, LoadDirectoryCommand, browse mode state | VERIFIED | 661 lines, all properties/commands present, full implementation (no stubs) |
| `SharepointToolbox/Localization/Strings.resx` | 14 directory localization keys (EN) | VERIFIED | All 14 keys present (audit.mode.search/browse, directory.grp/btn/chk/col/hint/status/filter) |
| `SharepointToolbox/Localization/Strings.fr.resx` | 14 directory localization keys (FR) | VERIFIED | All 14 keys present with French translations |
| `SharepointToolbox/Core/Models/GraphDirectoryUser.cs` | Record with DisplayName, UPN, Mail, Department, JobTitle, UserType | VERIFIED | 6-field record, matches DataGrid column bindings |
| `SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs` | Tests for directory commands and state | VERIFIED | 20 tests, all passing |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| UserAccessAuditView.xaml | UserAccessAuditViewModel.cs | Data binding: IsBrowseMode | WIRED | RadioButton IsChecked bindings (lines 21, 23), GroupBox visibility (lines 33-39, 87) |
| UserAccessAuditView.xaml | UserAccessAuditViewModel.cs | Data binding: DirectoryUsersView | WIRED | DataGrid ItemsSource="{Binding DirectoryUsersView}" (line 138) |
| UserAccessAuditView.xaml | UserAccessAuditViewModel.cs | Data binding: LoadDirectoryCommand | WIRED | Button Command="{Binding LoadDirectoryCommand}" (line 100) |
| UserAccessAuditView.xaml | UserAccessAuditViewModel.cs | Data binding: CancelDirectoryLoadCommand | WIRED | Button Command="{Binding CancelDirectoryLoadCommand}" (line 102) |
| UserAccessAuditView.xaml | UserAccessAuditViewModel.cs | Data binding: DirectoryFilterText | WIRED | TextBox Text="{Binding DirectoryFilterText}" (line 113) |
| UserAccessAuditView.xaml | UserAccessAuditViewModel.cs | Data binding: IncludeGuests | WIRED | CheckBox IsChecked="{Binding IncludeGuests}" (line 109) |
| UserAccessAuditView.xaml | UserAccessAuditViewModel.cs | Data binding: DirectoryLoadStatus | WIRED | TextBlock Text="{Binding DirectoryLoadStatus}" (line 118) |
| UserAccessAuditView.xaml | UserAccessAuditViewModel.cs | Data binding: DirectoryUserCount | WIRED | MultiBinding with DirectoryUserCount (line 122) |
| UserAccessAuditView.xaml | UserAccessAuditView.xaml.cs | MouseDoubleClick event | WIRED | MouseDoubleClick="DirectoryDataGrid_MouseDoubleClick" (line 141) |
| UserAccessAuditView.xaml.cs | UserAccessAuditViewModel.cs | SelectDirectoryUserCommand | WIRED | Code-behind casts DataContext, invokes command (lines 31-36) |
| UserAccessAuditViewModel.cs | GraphDirectoryUser.cs | Command parameter type | WIRED | RelayCommand<GraphDirectoryUser> (line 140), ExecuteSelectDirectoryUser parameter (line 554) |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| UDIR-01 | 14-01, 14-02 | User can toggle between search mode and directory browse mode | SATISFIED | RadioButtons in XAML, IsBrowseMode property, conditional panel visibility |
| UDIR-05 | 14-01, 14-02 | User can select users from directory to run audit | SATISFIED | SelectDirectoryUserCommand, DataGrid double-click, SelectedUsers shared panel |
### Anti-Patterns Found
No anti-patterns detected. No TODO/FIXME/HACK/PLACEHOLDER comments. No empty implementations. No console.log-only handlers.
### Build and Test Results
- **Build:** dotnet build --no-restore -warnaserror: 0 warnings, 0 errors
- **Tests:** 20 passed, 0 failed, 0 skipped (160ms)
### Human Verification Required
### 1. Mode Toggle Visual Behavior
**Test:** Click Browse Directory radio button, verify search panel collapses and directory panel appears. Click Search radio button, verify the reverse.
**Expected:** Clean toggle with no layout jump or overlap. Both panels fully visible/collapsed.
**Why human:** Visual layout and transition smoothness cannot be verified programmatically.
### 2. Directory Load and Cancel UX
**Test:** Click Load Directory, observe loading status updating with user count, then click Cancel before completion.
**Expected:** Status shows "Loading... N users" incrementally, Cancel button is active during load, Load button is disabled. After cancel: "Load cancelled." message, both buttons return to normal state.
**Why human:** Real-time progress display and button enable/disable transitions require visual observation.
### 3. DataGrid Double-Click to Audit Flow
**Test:** Load directory, double-click a user row. Verify user appears in SelectedUsers badges. Click Run Audit.
**Expected:** User badge appears immediately. Audit runs and produces results identical to search-mode selection.
**Why human:** End-to-end flow through actual Graph API and audit pipeline requires running application.
### 4. Guest Highlighting
**Test:** Load directory with Include Guests checked. Find a Guest-type user in the list.
**Expected:** Guest users show "Guest" in orange semi-bold text in the Type column.
**Why human:** Color and font rendering verification.
---
_Verified: 2026-04-09_
_Verifier: Claude (gsd-verifier)_
@@ -0,0 +1,70 @@
---
phase: 15
title: Consolidation Data Model
status: ready-for-planning
created: 2026-04-09
---
# Phase 15 Context: Consolidation Data Model
## Decided Areas (from prior research + STATE.md)
These are locked — do not re-litigate during planning or execution.
| Decision | Value |
|---|---|
| Consolidation scope | User access audit report only — site-centric permission report is unchanged |
| Source model | `UserAccessEntry` (already normalized, one user per row) |
| Consolidation is opt-in | Defaults to OFF; toggle wired in Phase 16 |
| No API calls | Pure data transformation — no Graph or CSOM calls |
| Existing exports unchanged | When consolidation is not applied, output is identical to pre-v2.3 |
## Discussed Areas
### 1. Consolidation Key (What Defines "Same Access")
**Decision:** Merge rows only when all four fields match: `UserLogin` + `PermissionLevel` + `AccessType` + `GrantedThrough`.
- Strictest matching — preserves the audit trail of how access was granted
- A user with "Contribute (Direct)" on 3 sites and "Contribute (Group: Members)" on 2 sites produces 2 consolidated rows, not 1
- `UserLogin` is the identity key (not `UserDisplayName`, which could vary)
- `AccessType` enum values: Direct, Group, Inherited — all treated as distinct
- `GrantedThrough` string comparison is exact (e.g., "SharePoint Group: Members" vs "SharePoint Group: Owners" are separate)
### 2. Merged Locations Model
**Decision:** `List<LocationInfo>` with a `LocationCount` convenience property.
- `ConsolidatedPermissionEntry` holds all fields from the consolidation key plus a `List<LocationInfo>` containing each merged site's URL and title
- `LocationInfo` is a lightweight record: `{ string SiteUrl, string SiteTitle, string ObjectTitle, string ObjectUrl, string ObjectType }`
- `LocationCount` is a computed property (`Locations.Count`) — convenience for display and sorting
- No information loss — all original location data is preserved in the list
- Presentation decisions (how to render the list) are deferred to Phase 16
### 3. Report Scope
**Decision:** Consolidation applies to user access audit (`UserAccessEntry`) only.
- The user access audit report is already user-centric and normalized (one user per row) — natural fit for "merge same user across locations"
- The site-centric permission report (`PermissionEntry`) flows the opposite direction (site → users); consolidating it would mean "same permission set across sites" — a different feature entirely
- `HtmlExportService` (site-centric) is untouched by this phase
- `UserAccessHtmlExportService` will receive consolidated data in Phase 16; this phase only builds the model and service
## Deferred Ideas (out of scope for Phase 15)
- Consolidation toggle UI (Phase 16)
- Consolidated view rendering in HTML exports (Phase 16)
- Group expansion within consolidated rows (Phase 17)
- Consolidation in CSV exports (out of scope per REQUIREMENTS.md)
- "Same permission set across sites" consolidation for site-centric report (not planned)
## code_context
| Asset | Path | Reuse |
|---|---|---|
| UserAccessEntry model | `SharepointToolbox/Core/Models/UserAccessEntry.cs` | Source model — consolidated entry mirrors its fields + locations list |
| UserAccessAuditService | `SharepointToolbox/Services/UserAccessAuditService.cs` | Produces the `UserAccessEntry` list that feeds the consolidator |
| UserAccessHtmlExportService | `SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs` | Downstream consumer in Phase 16 — must accept both flat and consolidated lists |
| DuplicatesService grouping pattern | `SharepointToolbox/Services/DuplicatesService.cs` | Reference for composite-key grouping via `MakeKey()` pattern |
| PermissionSummaryBuilder | `SharepointToolbox/Core/Helpers/PermissionSummaryBuilder.cs` | Reference for aggregation pattern over permission data |
| Test project | `SharepointToolbox.Tests/` | New tests for PermissionConsolidator with known input/output pairs |
+336 -341
View File
@@ -1,443 +1,438 @@
# Architecture Patterns
**Domain:** C#/WPF MVVM desktop app — SharePoint Online MSP admin tool
**Feature scope:** Report branding (MSP/client logos in HTML) + User directory browse mode
**Researched:** 2026-04-08
**Confidence:** HIGH — based on direct codebase inspection, not assumptions
**Project:** SharePoint Toolbox v2.3 — Tenant Management & Report Enhancements
**Researched:** 2026-04-09
**Scope:** Integration of four new features into the existing MVVM/DI architecture
---
## Existing Architecture (Baseline)
The app uses a clean layered architecture. Understanding the layers is prerequisite to placing new features correctly.
```
Core/
Models/ — TenantProfile, AppSettings, domain records (all POCOs/records)
Messages/ — WeakReferenceMessenger value message types
Helpers/ — Static utility classes
Models/ Pure data records and enums (no dependencies)
Helpers/ — Static utility methods
Messages/ — WeakReferenceMessenger message types
Infrastructure/
Auth/ — MsalClientFactory, GraphClientFactory (MSAL PCA per-tenant + Graph SDK bridge)
Persistence/ — ProfileRepository, SettingsRepository, TemplateRepository (JSON, atomic write-then-replace)
Logging/ — LogPanelSink (Serilog sink to in-app RichTextBox)
Auth/ — MsalClientFactory, GraphClientFactory, SessionManager wiring
Persistence/ JSON-backed repositories (ProfileRepository, BrandingRepository, etc.)
Services/
Export/Concrete HTML/CSV export services per domain (no interface, consumed directly)
*.cs Domain services with IXxx interfaces
*.cs Interface + implementation pairs (feature business logic)
Export/HTML and CSV export services per feature area
ViewModels/
FeatureViewModelBase.cs — Abstract base: RunCommand, CancelCommand, ProgressValue, StatusMessage,
GlobalSites, WeakReferenceMessenger registration
MainWindowViewModel.cs — Toolbar: tenant picker, Connect, global site picker, broadcasts TenantSwitchedMessage
Tabs/ — One ViewModel per tab, all extend FeatureViewModelBase
ProfileManagementViewModel.cs — Profile CRUD dialog VM
FeatureViewModelBase — Abstract base: RunCommand, CancelCommand, progress, WeakReferenceMessenger
Tabs/ — One ViewModel per tab
ProfileManagementViewModel — Tenant profile CRUD + logo management
Views/
Dialogs/ — ProfileManagementDialog, SitePickerDialog, ConfirmBulkOperationDialog, FolderBrowserDialog
Tabs/ — One UserControl per tab (XAML + code-behind)
App.xaml.cs — Generic Host IServiceCollection DI registration for all layers
Tabs/ — XAML views, pure DataBinding
Dialogs/ — Modal dialogs (ProfileManagementDialog, SitePickerDialog, etc.)
```
### Key Patterns Already Established
### Key Architectural Invariants (must not be broken)
| Pattern | How It Works |
|---------|-------------|
| Tenant switching | `MainWindowViewModel.OnSelectedProfileChanged` broadcasts `TenantSwitchedMessage` via `WeakReferenceMessenger`; each tab VM overrides `OnTenantSwitched(profile)` |
| Global site propagation | `GlobalSitesChangedMessage` received in `FeatureViewModelBase.OnGlobalSitesReceived` |
| HTML export | Concrete service class (e.g. `UserAccessHtmlExportService`), `BuildHtml(entries)` returns a string, `WriteAsync(entries, path, ct)` writes it. No interface. Pure data-in, HTML-out. |
| JSON persistence | Repository pattern: constructor takes `string filePath`, atomic write via `.tmp` + round-trip JSON validation before `File.Move`, `SemaphoreSlim` write lock. |
| DI registration | All in `App.xaml.cs RegisterServices()`. Export services and ViewModels are `AddTransient`; shared infrastructure is `AddSingleton`. |
| Dialog factory | View code-behind sets `ViewModel.OpenXxxDialog = () => new XxxDialog(...)` — keeps dialogs out of ViewModel layer |
| People-picker search | `IGraphUserSearchService.SearchUsersAsync(clientId, query, maxResults, ct)` calls Graph `/users?$filter=startsWith(...)` with `ConsistencyLevel: eventual` |
| Test constructor | `UserAccessAuditViewModel` has a `internal` 3-param constructor without export services — test pattern to replicate for new injections |
1. **SessionManager is the sole holder of ClientContext.** All services receive it via constructor injection; none store it.
2. **GraphClientFactory.CreateClientAsync(clientId)** produces a GraphServiceClient scoped to a specific tenant's PCA from MsalClientFactory.
3. **FeatureViewModelBase** provides RunCommand/CancelCommand/progress wiring. All tab VMs extend it.
4. **WeakReferenceMessenger** carries cross-cutting signals: `TenantSwitchedMessage`, `GlobalSitesChangedMessage`. VMs react in `OnTenantSwitched` / `OnGlobalSitesChanged`.
5. **BulkOperationRunner.RunAsync** is the shared continue-on-error runner for all multi-item operations.
6. **HTML export services** are independent per-feature classes under `Services/Export/`; they receive `ReportBranding?` and call `BrandingHtmlHelper.BuildBrandingHeader()`.
7. **DI registration** is in `App.xaml.cs RegisterServices`. New services register there.
---
## Feature 1: Report Branding (MSP/Client Logos in HTML Reports)
## Feature 1: App Registration via Graph API
### What It Needs
### What It Does
During profile create/edit, attempt to register a new Azure AD app on the target tenant (auto path), or instruct the user through manual steps (guided fallback path).
- **MSP logo** — one global image, shown in every HTML report from every tenant
- **Client logo** — one image per tenant, shown in reports for that tenant only
- **Storage** — base64-encoded strings in JSON (no separate image files — preserves atomic save semantics and single-data-folder design)
- **Embedding** — `data:image/...;base64,...` `<img>` tag injected into the HTML header (maintains self-contained HTML invariant — zero external file references)
- **User action** — file picker → read bytes → detect MIME type → convert to base64 → store in JSON → preview in UI
### Graph API Constraint (HIGH confidence)
Creating an application registration via `POST /applications` requires the caller to hold `Application.ReadWrite.All`. This is an admin-consent-required delegated permission. The existing GraphClientFactory uses `.default` scope, which only acquires permissions already pre-consented on the PCA's app registration. This means:
### New Components (create from scratch)
- **The Toolbox's own client app registration (the one the MSP registered to run this tool) must have `Application.ReadWrite.All` delegated and admin-consented** before the auto path can work.
- If that permission is absent, the Graph call returns 403. The auto path must catch `ODataError` with status 403 and fall through to guided fallback automatically.
- The guided fallback shows the MSP admin step-by-step instructions for creating the app registration manually in the Azure portal and entering the resulting ClientId.
### New Service: `IAppRegistrationService` / `AppRegistrationService`
**Location:** `Services/AppRegistrationService.cs` + `Services/IAppRegistrationService.cs`
**Responsibilities:**
- `RegisterAppAsync(GraphServiceClient, string tenantName, CancellationToken)` — Creates the app registration and optional service principal on the target tenant. Returns `AppRegistrationResult` (success + new ClientId, or failure reason).
- `RemoveAppAsync(GraphServiceClient, string clientId, CancellationToken)` — Deletes the app object by clientId. Also cleans up service principal.
**Required Graph calls (inside `AppRegistrationService`):**
1. `POST /applications` — create the app with required `requiredResourceAccess` (SharePoint delegated scopes)
2. `POST /servicePrincipals` — create service principal for the new app so it can receive admin consent
3. `DELETE /applications/{id}` for removal
4. `DELETE /servicePrincipals/{id}` for service principal cleanup
### New Model: `AppRegistrationResult`
**`Core/Models/BrandingSettings.cs`**
```csharp
public class BrandingSettings
{
public string? MspLogoBase64 { get; set; }
public string? MspLogoMimeType { get; set; } // "image/png", "image/jpeg", etc.
}
// Core/Models/AppRegistrationResult.cs
public record AppRegistrationResult(
bool Success,
string? ClientId, // set when Success=true
string? ApplicationId, // object ID, needed for deletion
string? FailureReason // set when Success=false
);
```
Belongs in Core/Models alongside AppSettings. Kept separate — branding may grow independently of general app settings.
**`Core/Models/ReportBranding.cs`**
### Integration Point: `ProfileManagementViewModel`
This is the only ViewModel that changes. `ProfileManagementViewModel` already receives `GraphClientFactory`. Add:
- `IAppRegistrationService` injected via constructor
- `RegisterAppCommand` (IAsyncRelayCommand) — triggers auto-registration, falls back to guided mode on 403
- `RemoveAppCommand` (IAsyncRelayCommand) — available when `SelectedProfile != null && SelectedProfile.ClientId != null`
- `IsRegistering` observable bool for busy state
- `AppRegistrationStatus` observable string for feedback
**Data flow:**
```
ProfileManagementViewModel.RegisterAppCommand
→ GraphClientFactory.CreateClientAsync(currentMspClientId) // uses MSP's own clientId
→ AppRegistrationService.RegisterAppAsync(graphClient, tenantName)
→ POST /applications, POST /servicePrincipals
→ returns AppRegistrationResult
→ on success: populate NewClientId, surface "Copy ClientId" affordance
→ on 403: set guided fallback mode (show instructions panel)
→ on other error: set ValidationMessage
```
No new ViewModel is needed. The guided fallback is a conditional UI panel in `ProfileManagementDialog.xaml` controlled by a new `IsGuidedFallbackVisible` bool property on `ProfileManagementViewModel`.
### DI Registration (App.xaml.cs)
```csharp
public record ReportBranding(
string? MspLogoBase64,
string? MspLogoMimeType,
string? ClientLogoBase64,
string? ClientLogoMimeType);
services.AddTransient<IAppRegistrationService, AppRegistrationService>();
```
Lightweight data transfer record assembled at export time from BrandingSettings + current TenantProfile. Not persisted directly — constructed on demand.
**`Infrastructure/Persistence/BrandingRepository.cs`**
Same pattern as `SettingsRepository`: constructor takes `string filePath`, atomic write-then-replace, `SemaphoreSlim` write lock. File: `%AppData%\SharepointToolbox\branding.json`.
`ProfileManagementViewModel` registration remains `AddTransient`; the new interface is added to its constructor.
**`Services/BrandingService.cs`**
---
## Feature 2: Auto-Take Ownership on Access Denied
### What It Does
A global toggle in Settings. When enabled, if any SharePoint operation returns an access-denied error, the app automatically adds the authenticated account as a site collection administrator using the tenant admin API, then retries the operation.
### Tenant Admin API Mechanism (HIGH confidence from PnP Framework source)
PnP Framework's `Tenant` class (in `Microsoft.Online.SharePoint.TenantAdministration`) exposes site management. The pattern already used in `SiteListService` (which clones to the `-admin` URL) is exactly the right entry point.
To add self as admin:
```csharp
public class BrandingService
{
public Task<BrandingSettings> GetBrandingAsync();
public Task SetMspLogoAsync(string filePath); // reads file, detects MIME, converts to base64, saves
public Task ClearMspLogoAsync();
}
var tenant = new Tenant(adminCtx);
tenant.SetSiteAdmin(siteUrl, loginName, isAdmin: true);
adminCtx.ExecuteQueryAsync();
```
Thin orchestration, same pattern as `SettingsService`. MSP logo only — client logo is managed via `ProfileService` (it belongs to `TenantProfile`).
This does NOT require having access to the site — only SharePoint Admin role on the tenant, which the interactive login flow already acquires.
### Modified Components
### New Setting Property: `AppSettings.AutoTakeOwnership`
**`Core/Models/TenantProfile.cs`** — Add two nullable string properties:
```csharp
public string? ClientLogoBase64 { get; set; }
public string? ClientLogoMimeType { get; set; }
// Core/Models/AppSettings.cs — ADD property
public bool AutoTakeOwnership { get; set; } = false;
```
This is backward-compatible. `ProfileRepository` uses `JsonSerializer` with `PropertyNameCaseInsensitive: true` — missing JSON fields deserialize to null without error. Existing `profiles.json` files continue to load correctly.
**All HTML export services** — Add `ReportBranding? branding = null` optional parameter to every `BuildHtml()` overload. When non-null and at least one logo is present, inject a branding header div between `<body>` open and `<h1>`:
This persists in `settings.json` automatically via `SettingsRepository`.
### New Service: `ISiteOwnershipService` / `SiteOwnershipService`
**Location:** `Services/SiteOwnershipService.cs` + `Services/ISiteOwnershipService.cs`
**Responsibility:** One method:
```csharp
Task AddCurrentUserAsSiteAdminAsync(
TenantProfile profile,
string siteUrl,
CancellationToken ct);
```
Uses `SessionManager` to get the authenticated context, clones to the admin URL (same pattern as `SiteListService.DeriveAdminUrl`), constructs `Tenant`, and calls `SetSiteAdmin`.
### Integration Point: `ExecuteQueryRetryHelper` or Caller Wrap
Rather than modifying `ExecuteQueryRetryHelper` (which is stateless and generic), the retry-with-ownership logic belongs in a per-operation wrapper:
1. Calls the operation
2. Catches `ServerException` with "Access Denied" message
3. If `AppSettings.AutoTakeOwnership == true`, calls `SiteOwnershipService.AddCurrentUserAsSiteAdminAsync`
4. Retries exactly once
5. If retry also fails, propagates the error with a message indicating ownership was attempted
**Recommended placement:** A new static helper `SiteAccessRetryHelper` in `Core/Helpers/`, wrapping CSOM executeQuery invocations in `PermissionsService`, `UserAccessAuditService`, and `SiteListService`. Each of these services already has an `IProgress<OperationProgress>` parameter and `CancellationToken` — the helper signature matches naturally.
### SettingsViewModel Changes
- Add `AutoTakeOwnership` observable bool property
- Wire to new `SettingsService.SetAutoTakeOwnershipAsync(bool)` method
- Bind to a checkbox in `SettingsView.xaml`
### DI Registration
```csharp
services.AddTransient<ISiteOwnershipService, SiteOwnershipService>();
```
---
## Feature 3: Expand Groups in HTML Reports
### What It Does
In the permissions HTML report, SharePoint group entries (where `PrincipalType == "SharePointGroup"`) currently show the group name as a single user pill. When expanded (click on the group), the report shows the individual group members.
### Data Model Change
`PermissionEntry` is a `record`. Group member data must be captured at scan time because the HTML report is self-contained offline — no live API calls from the browser are possible.
**Approach: Resolve at scan time.** During `PermissionsService.ExtractPermissionsAsync`, when `principalType == "SharePointGroup"`, load group members via CSOM and store them in a new optional field on `PermissionEntry`.
**Model change — additive, backward-compatible:**
```csharp
public record PermissionEntry(
// ... all existing parameters unchanged ...
string? GroupMembers = null // semicolon-joined login names; null when not a group or not expanded
);
```
Using a default parameter keeps all existing constructors and test data valid without changes.
### New Scan Option
```csharp
// Core/Models/ScanOptions.cs — ADD parameter with default
public record ScanOptions(
bool IncludeInherited,
bool ScanFolders,
int FolderDepth,
bool IncludeSubsites,
bool ExpandGroupMembers = false // NEW — defaults off
);
```
### Service Changes: `PermissionsService`
In `ExtractPermissionsAsync`, when `principalType == "SharePointGroup"` and `options.ExpandGroupMembers == true`:
```csharp
ctx.Load(ra.Member, m => m.Users.Include(u => u.LoginName, u => u.Title));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var groupMembers = string.Join(";", ra.Member.Users.Select(u => u.LoginName));
```
This adds one CSOM round-trip per SharePoint group entry. Performance note: default is `false`.
### HTML Export Changes: `HtmlExportService`
When rendering user pills for an entry with `GroupMembers != null`, render the group name as an HTML5 `<details>/<summary>` expandable block. The `<details>/<summary>` element requires zero JavaScript, is self-contained, and is universally supported in all modern browsers (Chrome, Edge, Firefox, Safari) since 2016.
```html
<div class="brand-header" style="display:flex;align-items:center;gap:16px;padding:16px 24px 0;">
<!-- only rendered if logo present -->
<img src="data:{mimeType};base64,{base64}" style="max-height:60px;max-width:200px;" alt="MSP" />
<img src="data:{mimeType};base64,{base64}" style="max-height:60px;max-width:200px;" alt="Client" />
</div>
<details class="group-expand">
<summary class="user-pill group-pill">Members Group Name</summary>
<div class="group-members">
<span class="user-pill">alice@contoso.com</span>
<span class="user-pill">bob@contoso.com</span>
</div>
</details>
```
When `branding` is null (existing callers) the block is omitted entirely. No behavior change for callers that do not pass branding.
`UserAccessHtmlExportService` gets the same treatment in the "Granted Through" column where group access is reported.
Affected services (all in `Services/Export/`):
- `HtmlExportService` (two `BuildHtml` overloads — `PermissionEntry` and `SimplifiedPermissionEntry`)
- `UserAccessHtmlExportService`
- `StorageHtmlExportService` (two `BuildHtml` overloads — with and without `FileTypeMetric`)
- `SearchHtmlExportService`
- `DuplicatesHtmlExportService`
### ViewModel Changes: `PermissionsViewModel`
**ViewModels that call HTML export** — All `ExportHtmlAsync` methods need to resolve branding before calling the export service. The ViewModel calls `BrandingService.GetBrandingAsync()` and reads `_currentProfile.ClientLogoBase64` to assemble a `ReportBranding`, then passes it to `BuildHtml`.
Affected ViewModels: `PermissionsViewModel`, `UserAccessAuditViewModel`, `StorageViewModel`, `SearchViewModel`, `DuplicatesViewModel`. Each gets `BrandingService` injected via constructor.
**`ViewModels/Tabs/SettingsViewModel.cs`** — Add MSP logo management:
```csharp
[ObservableProperty] private string? _mspLogoPreviewBase64;
public RelayCommand BrowseMspLogoCommand { get; }
public RelayCommand ClearMspLogoCommand { get; }
```
On browse: open `OpenFileDialog` (filter: PNG, JPG, GIF) → call `BrandingService.SetMspLogoAsync(path)` → reload and refresh `MspLogoPreviewBase64`.
**`Views/Tabs/SettingsView.xaml`** — Add a "Report Branding — MSP Logo" section:
- `<Image>` bound to `MspLogoPreviewBase64` via a base64-to-BitmapSource converter
- "Browse Logo" button → `BrowseMspLogoCommand`
- "Clear" button → `ClearMspLogoCommand`
- Note label: "Applies to all reports"
**Client logo placement:** Client logo belongs to a `TenantProfile`, not to global settings. The natural place to manage it is `ProfileManagementDialog` (already handles profile CRUD). Add logo fields there rather than in SettingsView.
**`ViewModels/ProfileManagementViewModel.cs`** — Add client logo management per profile:
```csharp
[ObservableProperty] private string? _clientLogoPreviewBase64;
public RelayCommand BrowseClientLogoCommand { get; }
public RelayCommand ClearClientLogoCommand { get; }
```
On browse: read image bytes → base64 → set on the being-edited `TenantProfile` object before saving. Uses `ProfileService.AddProfileAsync` / rename pipeline that already exists.
**`Views/Dialogs/ProfileManagementDialog.xaml`** — Add client logo fields to the add/edit profile form (same pattern as SettingsView branding section).
### Data Flow: Report Branding
```
User picks MSP logo (SettingsView "Browse Logo" button)
→ SettingsViewModel.BrowseMspLogoCommand
→ OpenFileDialog in View code-behind or VM (follow existing BrowseFolder pattern)
→ BrandingService.SetMspLogoAsync(path)
→ File.ReadAllBytesAsync → Convert.ToBase64String
→ detect MIME from extension (.png → image/png, .jpg/.jpeg → image/jpeg, .gif → image/gif)
→ BrandingRepository.SaveAsync(BrandingSettings)
→ ViewModel refreshes MspLogoPreviewBase64
User runs export (e.g. ExportHtmlCommand in UserAccessAuditViewModel)
→ BrandingService.GetBrandingAsync() → BrandingSettings
→ reads _currentProfile.ClientLogoBase64, _currentProfile.ClientLogoMimeType
→ new ReportBranding(mspBase64, mspMime, clientBase64, clientMime)
→ UserAccessHtmlExportService.BuildHtml(entries, branding)
→ injects <img> data URIs in header when base64 is non-null
→ writes HTML file
```
Add `ExpandGroupMembers` observable bool. Include in `ScanOptions` construction in `RunOperationAsync`. Add checkbox to `PermissionsView.xaml`.
---
## Feature 2: User Directory Browse Mode
## Feature 4: Report Entry Consolidation Toggle
### What It Needs
### What It Does
When a user appears in multiple SharePoint groups that all have access to the same object, they generate multiple `PermissionEntry` rows. The consolidation toggle merges rows for the same (Object, User) combination, joining permission levels and grant sources.
The existing `UserAccessAuditView` has a people-picker: search box → Graph API `startsWith` filter → autocomplete dropdown → add to `SelectedUsers`. Directory browse mode is an alternative to the search box: show a paginated, filterable list of all tenant users, allow multi-select, bulk-add to `SelectedUsers`.
### Where Consolidation Lives
This is purely additive. The underlying audit logic (`IUserAccessAuditService`, `RunOperationAsync`, `SelectedUsers` collection, export commands) is completely unchanged.
This is a pure post-processing transformation on the already-collected `IReadOnlyList<PermissionEntry>`. It requires no new service, no CSOM calls, no Graph calls.
### New Components (create from scratch)
**Location:** New static helper class in `Core/Helpers/`:
**`Core/Models/PagedUserResult.cs`**
```csharp
public record PagedUserResult(
IReadOnlyList<GraphUserResult> Users,
string? NextPageToken); // null = last page
```
**`Services/IGraphUserDirectoryService.cs`**
```csharp
public interface IGraphUserDirectoryService
// Core/Helpers/PermissionConsolidator.cs
public static class PermissionConsolidator
{
Task<PagedUserResult> GetUsersPageAsync(
string clientId,
string? filter = null,
string? pageToken = null,
int pageSize = 100,
CancellationToken ct = default);
public static IReadOnlyList<PermissionEntry> Consolidate(
IReadOnlyList<PermissionEntry> entries);
}
```
**`Services/GraphUserDirectoryService.cs`**
Reuses `GraphClientFactory` (already injected elsewhere). Calls `graphClient.Users.GetAsync()` without the `startsWith` constraint used in search — uses `$top=100` with cursor-based paging via Graph's `@odata.nextLink`. Returns `PagedUserResult` so callers control pagination. Uses `ConsistencyLevel: eventual` + `$count=true` (same as existing search service).
**Consolidation key:** `(ObjectType, Title, Url, UserLogin)` — one row per (object, user) pair across all login tokens in a semicolon-delimited `UserLogins` field.
### Modified Components
**Merge logic:**
- `PermissionLevels`: union of distinct values (semicolon-joined)
- `GrantedThrough`: all distinct grant sources joined (e.g., "Direct Permissions; SharePoint Group: X")
- `HasUniquePermissions`: true if any source entry has it true
- `Users`, `UserLogins`: from the first occurrence (same person)
- `PrincipalType`: from the first occurrence
**`ViewModels/Tabs/UserAccessAuditViewModel.cs`** — Add browse mode state:
`PermissionEntry` is a `record``PermissionConsolidator.Consolidate()` produces new instances, never mutates. Consistent with the existing pattern in `PermissionsViewModel` where `Results` is replaced wholesale.
**For `SimplifiedPermissionEntry`:** Consolidation applies to `PermissionEntry` first; `SimplifiedPermissionEntry.WrapAll()` then operates on the consolidated list. No changes to `SimplifiedPermissionEntry` needed.
### ViewModel Changes: `PermissionsViewModel`
Add `ConsolidateEntries` observable bool property. In `RunOperationAsync`, after collecting `allEntries`:
```csharp
[ObservableProperty] private bool _isBrowseModeActive;
[ObservableProperty] private ObservableCollection<GraphUserResult> _directoryUsers = new();
[ObservableProperty] private string _directoryFilter = string.Empty;
[ObservableProperty] private bool _isLoadingDirectory;
[ObservableProperty] private bool _hasMoreDirectoryPages;
private string? _directoryNextPageToken;
public IAsyncRelayCommand LoadDirectoryCommand { get; }
public IAsyncRelayCommand LoadMoreDirectoryCommand { get; }
public RelayCommand<IList<GraphUserResult>> AddDirectoryUsersCommand { get; }
if (ConsolidateEntries)
allEntries = PermissionConsolidator.Consolidate(allEntries).ToList();
```
`partial void OnIsBrowseModeActiveChanged(bool value)` → when `value == true`, fire `LoadDirectoryCommand` to populate page 1.
The export commands (`ExportCsvCommand`, `ExportHtmlCommand`) already consume `Results`, so consolidated data flows into all export formats automatically. No export service changes required for this feature.
`partial void OnDirectoryFilterChanged(string value)` → debounce 300ms (same pattern as `OnSearchQueryChanged`), re-fire `LoadDirectoryCommand` with new filter, clear `_directoryNextPageToken`.
---
The `IGraphUserDirectoryService` is added to the constructor. The internal test constructor (currently 3 params) gets a 4-param overload adding the directory service with a null-safe default, or a new explicit test constructor.
**`App.xaml.cs RegisterServices()`** — Add:
```csharp
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
```
The `UserAccessAuditViewModel` transient registration picks up the new injection automatically (DI resolves by type).
**`UserAccessAuditViewModel.OnTenantSwitched`** — Also clear `DirectoryUsers`, reset `_directoryNextPageToken`, `HasMoreDirectoryPages`, `IsLoadingDirectory`.
**`Views/Tabs/UserAccessAuditView.xaml`** — Add to the top of the left panel:
- Mode toggle: two `RadioButton`s or `ToggleButton`s bound to `IsBrowseModeActive`
- "Search" panel: existing `GroupBox` shown when `IsBrowseModeActive == false`
- "Browse" panel: new `GroupBox` shown when `IsBrowseModeActive == true`, containing:
- Filter `TextBox` bound to `DirectoryFilter`
- `ListView` with `SelectionMode="Extended"` bound to `DirectoryUsers`, `SelectionChanged` handler in code-behind
- "Add Selected" `Button``AddDirectoryUsersCommand`
- "Load more" `Button` shown when `HasMoreDirectoryPages == true``LoadMoreDirectoryCommand`
- Loading indicator (existing `IsSearching` pattern, but for `IsLoadingDirectory`)
- Show/hide panels via `DataTrigger` on `IsBrowseModeActive`
**`Views/Tabs/UserAccessAuditView.xaml.cs`** — Add `SelectionChanged` handler to pass `ListView.SelectedItems` (as `IList<GraphUserResult>`) to `AddDirectoryUsersCommand`. Follow the existing `SearchResultsListBox_SelectionChanged` pattern.
### Data Flow: Directory Browse Mode
## Component Dependency Map
```
User clicks "Browse" mode toggle
→ IsBrowseModeActive = true
→ OnIsBrowseModeActiveChanged fires LoadDirectoryCommand
→ GraphUserDirectoryService.GetUsersPageAsync(clientId, filter: null, pageToken: null, 100, ct)
→ Graph GET /users?$select=displayName,userPrincipalName,mail&$top=100&$orderby=displayName
→ returns PagedUserResult { Users = [...100 items], NextPageToken = "..." }
→ DirectoryUsers = new collection of returned users
→ HasMoreDirectoryPages = (NextPageToken != null)
→ _directoryNextPageToken = returned token
NEW COMPONENT DEPENDS ON (existing unless marked new)
──────────────────────────────────────────────────────────────────────────
AppRegistrationResult (model) — none
AppSettings.AutoTakeOwnership AppSettings (existing model)
ScanOptions.ExpandGroupMembers ScanOptions (existing model)
PermissionEntry.GroupMembers PermissionEntry (existing record)
User types in DirectoryFilter
→ debounce 300ms
→ LoadDirectoryCommand re-fires with filter
→ DirectoryUsers replaced with filtered page 1
PermissionConsolidator PermissionEntry (existing)
User selects users in ListView + clicks "Add Selected"
→ AddDirectoryUsersCommand(selectedItems)
→ for each: if not already in SelectedUsers by UPN → SelectedUsers.Add(user)
IAppRegistrationService —
AppRegistrationService GraphServiceClient (existing via GraphClientFactory)
Microsoft.Graph SDK (existing)
User clicks "Load more"
→ LoadMoreDirectoryCommand
→ GetUsersPageAsync(clientId, currentFilter, _directoryNextPageToken, 100, ct)
→ DirectoryUsers items appended (not replaced)
→ _directoryNextPageToken updated
ISiteOwnershipService —
SiteOwnershipService SessionManager (existing)
TenantProfile (existing)
Tenant CSOM class (existing via PnP Framework)
SiteListService.DeriveAdminUrl pattern (existing)
SettingsService (modified) AppSettings (existing + new field)
PermissionsService (modified) ScanOptions.ExpandGroupMembers (new field)
ExecuteQueryRetryHelper (existing)
HtmlExportService (modified) PermissionEntry.GroupMembers (new field)
BrandingHtmlHelper (existing)
ProfileManagementViewModel (mod) IAppRegistrationService (new)
PermissionsViewModel (modified) ExpandGroupMembers, ConsolidateEntries, PermissionConsolidator
SettingsViewModel (modified) AutoTakeOwnership, SettingsService new method
```
---
## Component Boundary Summary
## Suggested Build Order
### New Components (create)
Dependencies flow upward; each step can be tested before the next begins.
| Component | Layer | Type | Purpose |
|-----------|-------|------|---------|
| `BrandingSettings` | Core/Models | class | MSP logo storage (base64 + MIME type) |
| `ReportBranding` | Core/Models | record | Data passed to `BuildHtml` overloads at export time |
| `BrandingRepository` | Infrastructure/Persistence | class | JSON load/save for `BrandingSettings` |
| `BrandingService` | Services | class | Orchestrates logo file read / MIME detect / base64 convert / save |
| `PagedUserResult` | Core/Models | record | Page of `GraphUserResult` items + next-page token |
| `IGraphUserDirectoryService` | Services | interface | Contract for paginated tenant user enumeration |
| `GraphUserDirectoryService` | Services | class | Graph API user listing with cursor pagination |
### Step 1: Model additions
No external dependencies. All existing tests continue to pass.
- `AppRegistrationResult` record (new file)
- `AppSettings.AutoTakeOwnership` bool property (default false)
- `ScanOptions.ExpandGroupMembers` bool parameter (default false)
- `PermissionEntry.GroupMembers` optional string parameter (default null)
Total new files: 7
### Step 2: Pure-logic helper
Fully unit-testable with no services.
- `PermissionConsolidator` in `Core/Helpers/`
### Modified Components (extend)
### Step 3: New services
Depend only on existing infrastructure (SessionManager, GraphClientFactory).
- `ISiteOwnershipService` + `SiteOwnershipService`
- `IAppRegistrationService` + `AppRegistrationService`
| Component | Change | Risk |
|-----------|--------|------|
| `TenantProfile` | + 2 nullable logo props | LOW — JSON backward-compatible |
| `HtmlExportService` | + optional `ReportBranding?` on 2 overloads | LOW — optional param, existing callers unaffected |
| `UserAccessHtmlExportService` | + optional `ReportBranding?` | LOW |
| `StorageHtmlExportService` | + optional `ReportBranding?` on 2 overloads | LOW |
| `SearchHtmlExportService` | + optional `ReportBranding?` | LOW |
| `DuplicatesHtmlExportService` | + optional `ReportBranding?` | LOW |
| `PermissionsViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
| `UserAccessAuditViewModel` | + inject `BrandingService` + `IGraphUserDirectoryService`, browse mode state/commands | MEDIUM |
| `StorageViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
| `SearchViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
| `DuplicatesViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
| `SettingsViewModel` | + inject `BrandingService`, MSP logo commands + preview property | LOW |
| `ProfileManagementViewModel` | + client logo browse/preview/clear | LOW |
| `SettingsView.xaml` | + branding section with logo preview + buttons | LOW |
| `ProfileManagementDialog.xaml` | + client logo fields | LOW |
| `UserAccessAuditView.xaml` | + mode toggle + browse panel in left column | MEDIUM |
| `App.xaml.cs RegisterServices()` | + 3 new registrations | LOW |
### Step 4: SettingsService extension
Thin method addition, no structural change.
- `SetAutoTakeOwnershipAsync(bool)` on existing `SettingsService`
Total modified files: 17
### Step 5: PermissionsService modification
- Group member CSOM load in `ExtractPermissionsAsync` (guarded by `ExpandGroupMembers`)
- Access-denied retry using `SiteOwnershipService` (guarded by `AutoTakeOwnership`)
### Step 6: Export service modifications
- `HtmlExportService.BuildHtml`: `<details>/<summary>` rendering for `GroupMembers`
- `UserAccessHtmlExportService.BuildHtml`: same for group access entries
### Step 7: ViewModel modifications
- `SettingsViewModel`: `AutoTakeOwnership` property wired to `SettingsService`
- `PermissionsViewModel`: `ExpandGroupMembers`, `ConsolidateEntries`, updated `ScanOptions`
- `ProfileManagementViewModel`: `IAppRegistrationService` injection, `RegisterAppCommand`, `RemoveAppCommand`, guided fallback state
### Step 8: View/XAML additions
- `SettingsView.xaml`: AutoTakeOwnership checkbox
- `PermissionsView.xaml`: ExpandGroupMembers checkbox, ConsolidateEntries checkbox
- `ProfileManagementDialog.xaml`: Register App button, Remove App button, guided fallback panel
### Step 9: DI wiring (App.xaml.cs)
- Register `IAppRegistrationService`, `ISiteOwnershipService`
- `ProfileManagementViewModel` constructor change is picked up automatically (AddTransient)
---
## Build Order (Dependency-Aware)
## New vs. Modified Summary
The two features are independent of each other. Phases can run in parallel if worked by two developers; solo they should follow top-to-bottom order.
| Component | Status | Layer |
|-----------|--------|-------|
| `AppRegistrationResult` | NEW | Core/Models |
| `AppSettings.AutoTakeOwnership` | MODIFIED | Core/Models |
| `ScanOptions.ExpandGroupMembers` | MODIFIED | Core/Models |
| `PermissionEntry.GroupMembers` | MODIFIED | Core/Models |
| `PermissionConsolidator` | NEW | Core/Helpers |
| `IAppRegistrationService` | NEW | Services |
| `AppRegistrationService` | NEW | Services |
| `ISiteOwnershipService` | NEW | Services |
| `SiteOwnershipService` | NEW | Services |
| `SettingsService.SetAutoTakeOwnershipAsync` | MODIFIED | Services |
| `PermissionsService.ExtractPermissionsAsync` | MODIFIED | Services |
| `HtmlExportService.BuildHtml` | MODIFIED | Services/Export |
| `UserAccessHtmlExportService.BuildHtml` | MODIFIED | Services/Export |
| `ProfileManagementViewModel` | MODIFIED | ViewModels |
| `PermissionsViewModel` | MODIFIED | ViewModels/Tabs |
| `SettingsViewModel` | MODIFIED | ViewModels/Tabs |
| `ProfileManagementDialog.xaml` | MODIFIED | Views/Dialogs |
| `PermissionsView.xaml` | MODIFIED | Views/Tabs |
| `SettingsView.xaml` | MODIFIED | Views/Tabs |
| `App.xaml.cs RegisterServices` | MODIFIED | Root |
### Phase A — Data Models (no dependencies)
1. `Core/Models/BrandingSettings.cs` (new)
2. `Core/Models/ReportBranding.cs` (new)
3. `Core/Models/PagedUserResult.cs` (new)
4. `Core/Models/TenantProfile.cs` — add nullable logo props (modification)
All files are POCOs/records. Unit-testable in isolation. No risk.
### Phase B — Persistence + Service Layer
5. `Infrastructure/Persistence/BrandingRepository.cs` (new) — depends on BrandingSettings
6. `Services/BrandingService.cs` (new) — depends on BrandingRepository
7. `Services/IGraphUserDirectoryService.cs` (new) — depends on PagedUserResult
8. `Services/GraphUserDirectoryService.cs` (new) — depends on GraphClientFactory (already exists)
Unit tests for BrandingService (mock repository) and GraphUserDirectoryService (mock Graph client) can be written at this phase.
### Phase C — HTML Export Service Extensions
9. All 5 `Services/Export/*HtmlExportService.cs` modifications — add optional `ReportBranding?` param
These are independent of each other. Tests: verify that passing `null` branding produces identical HTML to current output (regression), and that passing a branding record injects the expected `<img>` tags.
### Phase D — ViewModel Integration (branding)
10. `SettingsViewModel.cs` — add MSP logo commands + preview
11. `ProfileManagementViewModel.cs` — add client logo commands + preview
12. `PermissionsViewModel.cs` — add BrandingService injection, use in ExportHtmlAsync
13. `StorageViewModel.cs` — same
14. `SearchViewModel.cs` — same
15. `DuplicatesViewModel.cs` — same
16. `App.xaml.cs` — register BrandingRepository, BrandingService
Steps 12-15 follow an identical pattern and can be batched together.
### Phase E — ViewModel Integration (directory browse)
17. `UserAccessAuditViewModel.cs` — add IGraphUserDirectoryService injection, browse mode state/commands
Note: UserAccessAuditViewModel also gets BrandingService at this phase (from Phase D pattern). Do both together to avoid touching the constructor twice.
### Phase F — View Layer (branding UI)
18. `SettingsView.xaml` — add MSP branding section
19. `ProfileManagementDialog.xaml` — add client logo fields
Requires a base64-to-BitmapSource `IValueConverter` (add to `Views/Converters/`). This is a common WPF pattern — implement once, reuse in both views.
### Phase G — View Layer (directory browse UI)
20. `UserAccessAuditView.xaml` — add mode toggle + browse panel
21. `UserAccessAuditView.xaml.cs` — add SelectionChanged handler for directory ListView
This is the highest-risk UI change: the left panel is being restructured. Do this last, after all ViewModel behavior is proven by unit tests.
**No new tabs. No new XAML files. No new dialog windows required.** All four features extend existing surfaces.
---
## Anti-Patterns to Avoid
## Critical Integration Notes
### Storing Logo Images as Separate Files
**Why bad:** Breaks the single-data-folder design. Reports become non-self-contained if they reference external paths. Atomic save semantics break.
**Instead:** Base64-encode into JSON. Logo thumbnails are typically 10-200KB. Base64 overhead (~33%) is negligible.
### App Registration: Permission Prerequisite
The auto-registration path requires `Application.ReadWrite.All` to be granted and admin-consented on the MSP's own client app registration. The tool cannot bootstrap this permission itself. The guided fallback path is the safe default — auto path is an enhancement for pre-prepared deployments. Catch `ODataError` with `ResponseStatusCode == 403` to trigger the fallback automatically.
### Adding an `IHtmlExportService` Interface Just for Branding
**Why bad:** The existing pattern is 5 concrete classes with no interfaces, consumed directly by ViewModels. Adding an interface for a parameter change creates ceremony without value.
**Instead:** Add `ReportBranding? branding = null` as optional parameter. Existing callers compile unchanged.
### Auto-Ownership: Retry Once, Not Infinitely
Retry exactly once per site. If the second attempt fails (account lacks tenant admin rights), propagate the original error with a clear message indicating that ownership take-over was attempted. Log both attempts via `ILogger`.
### Loading All Tenant Users at Once
**Why bad:** Enterprise tenants regularly have 20,000-100,000 users. A full load blocks the UI for 30+ seconds and allocates hundreds of MB.
**Instead:** `PagedUserResult` pattern — page 1 on mode toggle, "Load more" button, server-side filter applied to DirectoryFilter text.
### Group Expansion: Scan Performance Impact
Loading group members adds one CSOM round-trip per unique SharePoint group encountered. The `ExpandGroupMembers` toggle must default to `false` and be labeled clearly in the UI (e.g., "Expand group members in report (slower scan)"). On tenants with many groups across many sites, this could multiply scan time significantly.
### Async in ViewModel Constructor
**Why bad:** DI constructs ViewModels synchronously on the UI thread. Async work in constructors requires fire-and-forget which loses exceptions.
**Instead:** `partial void OnIsBrowseModeActiveChanged` fires `LoadDirectoryCommand` when browse mode activates. Constructor only wires up commands and state.
### Consolidation: Records Are Immutable
`PermissionEntry` is a `record`. `PermissionConsolidator.Consolidate()` produces new record instances — no mutation. Consistent with how `Results` is already replaced wholesale in `PermissionsViewModel`.
### Client Logo in `AppSettings` or `BrandingSettings`
**Why bad:** Client logos are per-tenant. `AppSettings` and `BrandingSettings` are global. Mixing them makes per-profile deletion awkward and serialization structure unclear.
**Instead:** `ClientLogoBase64` + `ClientLogoMimeType` directly on `TenantProfile` (serialized in `profiles.json`). MSP logo goes in `branding.json` via `BrandingRepository`.
### HTML `<details>/<summary>` Compatibility
Self-contained HTML reports target any modern browser. `<details>/<summary>` is fully supported without JavaScript since 2016 across all major browsers. This is the correct choice over adding onclick JS toggle logic.
### Changing `BuildHtml` Signatures to Required Parameters
**Why bad:** All 5 HTML export services currently have callers without branding. Making the parameter required is a breaking change forcing simultaneous updates across 5 VMs.
**Instead:** `ReportBranding? branding = null` is optional. Inject only where branding is desired. Existing call sites remain unchanged.
---
## Scalability Considerations
| Concern | Impact | Mitigation |
|---------|--------|------------|
| Logo storage size in JSON | PNG logos base64-encoded: 10-200KB per logo. `profiles.json` grows by at most that per tenant | Acceptable — config files, not bulk data |
| HTML report file size | +2-10KB per logo (base64 inline) | Negligible — reports are already 100-500KB |
| Directory browse load time | 100-user pages from Graph: ~200-500ms per page | Loading indicator, pagination. Acceptable UX. |
| Large tenants (50k+ users) | Full load would take minutes and exceed memory budgets | Pagination via `PagedUserResult` prevents this entirely |
| ViewModel constructor overhead | BrandingService adds one lazy JSON read at first export | Not at construction — no startup impact |
### No Breaking Changes to Existing Tests
All model changes use optional parameters with defaults. Existing test data and constructors remain valid. `PermissionConsolidator` and `SiteOwnershipService` are new testable units that can use the existing `InternalsVisibleTo` pattern for test access.
---
## Sources
All findings are based on direct inspection of the codebase at `C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox/`. No external research needed — this is an integration architecture document for a known codebase.
Key files examined:
- `Core/Models/TenantProfile.cs`, `AppSettings.cs`
- `Infrastructure/Persistence/ProfileRepository.cs`, `SettingsRepository.cs`
- `Infrastructure/Auth/GraphClientFactory.cs`
- `Services/SettingsService.cs`, `ProfileService.cs`
- `Services/GraphUserSearchService.cs`, `IGraphUserSearchService.cs`
- `Services/Export/HtmlExportService.cs`, `UserAccessHtmlExportService.cs`, `StorageHtmlExportService.cs`
- `ViewModels/FeatureViewModelBase.cs`, `MainWindowViewModel.cs`
- `ViewModels/Tabs/UserAccessAuditViewModel.cs`, `SettingsViewModel.cs`
- `Views/Tabs/UserAccessAuditView.xaml`, `SettingsView.xaml`
- `App.xaml.cs`
- Microsoft Graph permissions reference: https://learn.microsoft.com/en-us/graph/permissions-reference
- Graph API grant permissions programmatically: https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph
- PnP Core SDK site security (SetSiteCollectionAdmins): https://pnp.github.io/pnpcore/using-the-sdk/admin-sharepoint-security.html
- PnP Framework TenantExtensions source: https://github.com/pnp/PnP-Sites-Core/blob/master/Core/OfficeDevPnP.Core/Extensions/TenantExtensions.cs
+452 -129
View File
@@ -1,211 +1,534 @@
# Feature Landscape
**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)
**Domain:** MSP IT admin desktop tool — Tenant Management & Report Enhancements
**Milestone:** v2.3
**Researched:** 2026-04-09
**Overall confidence:** HIGH (verified via official Graph API docs, PnP docs, and direct codebase inspection)
---
## Scope Boundary
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
This file covers only the five net-new features in v2.3:
1. Automated app registration on target tenant (with guided fallback)
2. App removal from target tenant
3. Auto-take ownership of SharePoint sites on access denied (global toggle)
4. Expand groups in HTML reports (clickable to show members)
5. Report consolidation toggle (merge duplicate user entries across locations)
Everything else is already shipped. Dependencies on existing code are called out explicitly.
---
## Feature 1: HTML Report Branding
## Feature 1: Automated App Registration on Target Tenant
### What it is
During profile creation/editing, the app can register itself as an Azure AD application on the
target tenant. This eliminates the current manual step where admins must open Entra portal, create
an app registration, copy the Client ID, and paste it into the profile form.
### How it works (technical)
Graph API app registration is a two-phase operation when performed programmatically:
**Phase 1 — Create the Application object:**
`POST /applications` with `displayName` and optionally `requiredResourceAccess` (permission
declarations). Returns `appId` (client ID) and `id` (object ID). Requires delegated permission
`Application.ReadWrite.All` (least privilege for delegated scenarios), or the calling user must
hold a role of Application Developer, Cloud Application Administrator, or higher.
**Phase 2 — Create the Service Principal:**
`POST /servicePrincipals` with `appId` from Phase 1. This is a required explicit step when
registering via Graph API — the portal creates the SP automatically, the API does not.
Requires the same `Application.ReadWrite.All` delegated permission.
**Phase 3 — Grant admin consent for required permissions:**
`POST /servicePrincipals/{resourceId}/appRoleAssignedTo` for each application permission
(SharePoint, Graph scopes needed). The calling user must hold Cloud Application Administrator
or Global Administrator to grant tenant-wide consent. Requires delegated permissions
`Application.Read.All` + `AppRoleAssignment.ReadWrite.All`.
**Phase 4 — Store credentials (optional):**
`POST /applications/{id}/addPassword` to create a client secret. The `secretText` is only
returned once at creation — must be stored immediately in the profile. Alternatively, the app
can use a MSAL public client flow (interactive login), which does not require a client secret.
**Guided fallback:** If the calling user lacks Application.ReadWrite.All or admin consent cannot
be granted programmatically (e.g., tenant has restricted app consent policies), the automated
path fails. The fallback shows step-by-step instructions + a deep-link to the Entra portal app
registration wizard, with the `appId` field pre-fill-ready when the admin returns.
### 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 |
|---------|--------------|------------|-------|
| 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 `<img src=` |
| Logo graceful absence (no logo configured = no broken image) | Admins will run the tool before configuring logos | Trivial | Conditional render — omit the `<img>` 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 |
| Create app registration on target tenant via Graph API | MSPs manage 10-50 tenants; manual Entra portal steps per-tenant is the biggest onboarding friction | High | 4 API calls; requires `Application.ReadWrite.All` + admin consent grant scope in the calling token |
| Return and store the Client ID automatically | The resulting `appId` must be wired into the TenantProfile as the registered clientId | Low | Phase 1 response body contains `appId`; persist to TenantProfile.ClientId |
| Guided fallback (manual instructions + portal deep-link) if automated path fails | Tenant admin consent policies may block programmatic app creation | Medium | Detect 403/insufficient_scope errors; render a modal with numbered steps and a link to `https://entra.microsoft.com/#blade/Microsoft_AAD_RegisteredApps/CreateApplicationBlade` |
| Progress/status feedback during multi-step registration | 4 API calls; each can fail independently | Low | Use existing OperationProgress pattern; surface per-step status in the UI |
### Differentiators
Features not expected by default, but add meaningful value once table stakes are covered.
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| 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 |
| Pre-configure required Graph/SharePoint permissions in the app manifest | Avoids admin having to manually tick permissions in Entra portal after creation | Medium | Include `requiredResourceAccess` in POST /applications body, targeting Graph SP (appId `00000003-0000-0000-c000-000000000000`) and SharePoint SP (appId `00000003-0000-0ff1-ce00-000000000000`) |
| Verify existing registration before creating a new one | Prevents duplicate registrations on re-run or retry | Low | `GET /applications?$filter=displayName eq '{name}'` before POST; surface existing one if found |
### Anti-Features
Do not build these. They add scope without proportionate MSP value.
| 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 |
| Store client secrets in the profile JSON | Secrets at rest in a local JSON file are a liability; the app uses delegated (interactive) auth, not app-only | Use MSAL interactive delegated flow; no client secret needed at runtime |
| Certificate-based credential management | Cert lifecycle (expiry, rotation) is out of scope for an MSP admin tool | Interactive user auth handles token refresh automatically |
| Silent background retry on consent failures | The calling user may not have the right role; silent retry without user action would spin indefinitely | Detect error class, surface actionable UI immediately |
### Feature Dependencies
```
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
Existing:
GraphClientFactory → provides authenticated GraphServiceClient for target tenant
TenantProfile.ClientId → stores the resulting appId after registration
ProfileManagementDialog → hosts the registration trigger button
OperationProgress → used for per-step status display
New:
IAppRegistrationService / AppRegistrationService
→ CreateApplicationAsync(tenantId, displayName) : Task<Application>
→ CreateServicePrincipalAsync(appId) : Task<ServicePrincipal>
→ GrantAdminConsentAsync(servicePrincipalId) : Task (app role assignments)
→ RemoveApplicationAsync(appId) : Task (Feature 2)
AppRegistrationFallbackDialog (new WPF dialog)
→ renders numbered steps when automated path fails
→ deep-link button to Entra portal
```
**Key existing code note:** All 5+ HTML export services currently build their `<body>` 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.
Option (b) is lower risk — it does not change method signatures that existing unit tests already call.
**Key existing code note:** GraphClientFactory already acquires delegated tokens with the
tenant's registered clientId. For the registration flow specifically, the app needs a token
scoped to the *management* tenant (where Entra lives), not just SharePoint/Graph read scopes.
The MSAL PCA must request `Application.ReadWrite.All` and `AppRoleAssignment.ReadWrite.All`
for the registration step — these are broader than the app's normal operation scopes and will
trigger a new consent prompt if not previously consented.
### Complexity Assessment
| 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 |
| POST /applications + POST /servicePrincipals | Medium | Two sequential calls; error handling at each step |
| Grant admin consent (appRoleAssignment per permission) | High | Must look up resource SP IDs, match appRole GUIDs by permission name; 4-6 role assignments needed |
| TenantProfile.ClientId persistence after registration | Low | Existing JSON serialization; add one field |
| UI: Register button in ProfileManagementDialog | Low | Button + status label; hooks into existing async command pattern |
| Guided fallback modal | Medium | Error detection logic + WPF dialog with instructional content |
| Localization EN/FR | Low | ~12-16 new keys |
| Unit tests for AppRegistrationService | High | Requires mocking Graph SDK Application/ServicePrincipal/AppRoleAssignment calls; 15-20 test cases |
---
## Feature 2: User Directory Browse Mode
## Feature 2: App Removal from Target Tenant
### What it is
Inverse of Feature 1. When a tenant profile is deleted or when the admin explicitly removes the
registration, the app deletes the Azure AD application object from the target tenant.
### How it works (technical)
`DELETE /applications/{id}` — soft-deletes the application object (moved to deleted items for
30 days). Requires `Application.ReadWrite.All` delegated. The `{id}` here is the object ID
(not the `appId`/client ID) — must be resolved first via
`GET /applications?$filter=appId eq '{clientId}'`.
Optionally: `DELETE /directory/deletedItems/{id}` for permanent deletion (requires
`Application.ReadWrite.All`; irreversible within the 30-day window — avoid).
### Table Stakes
Features an admin expects when a "browse all users" mode is offered alongside the existing search.
| 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 |
### Differentiators
| 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 |
| Remove app registration when profile is deleted | Avoid Entra app sprawl in client tenants; clean exit | Medium | Resolve object ID by appId, then DELETE /applications/{id} |
| Confirmation prompt before removal | Deletion is irreversible within the session; accidental removal would break other automations using the same app | Low | Modal confirm dialog with clientId displayed |
| Graceful handling when app no longer exists | Re-run, manual deletion in portal, or already removed | Low | Handle 404 as success (idempotent delete) |
### Anti-Features
| 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 |
| Permanent hard-delete from deletedItems | 30-day soft-delete is a safety net; no MSP needs immediate permanent removal | Soft-delete only (default DELETE /applications behavior) |
| Auto-remove on profile deletion without prompt | Silent data destruction is never acceptable | Always require explicit user confirmation |
### Feature Dependencies
```
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)
Existing:
AppRegistrationService (from Feature 1)
+ RemoveApplicationAsync(clientId) : Task
→ GET /applications?$filter=appId eq '{clientId}' → resolve object ID
→ DELETE /applications/{objectId}
UserAccessAuditViewModel additions:
+ IsBrowseMode (bool property, toggle)
+ DirectoryUsers (ObservableCollection<GraphUserResult> or new DirectoryUserEntry model)
+ DirectoryFilterText (string, filters in-memory)
+ LoadDirectoryCommand (async, cancellable)
+ IsDirectoryLoading (bool)
+ SelectedDirectoryUser → feeds into existing audit execution path
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
ProfileManagementDialog
→ Remove Registration button (separate from Delete Profile)
→ or confirmation step during profile deletion flow
```
**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 |
| RemoveApplicationAsync (resolve then delete) | Low-Medium | Two calls; 404 idempotency handling |
| Confirmation dialog | Low | Reuse existing ConfirmationDialog pattern |
| Wire into profile deletion flow | Low | Existing profile delete command; add optional app removal step |
---
## Feature 3: Auto-Take Ownership on Access Denied (Global Toggle)
### What it is
When the scanner hits a site with "Access Denied", and the global toggle is on, the app
automatically adds the scanning account as a Site Collection Administrator for that site, retries
the scan, and (optionally) removes itself afterward. The admin controls this with a global on/off.
### How it works (technical)
**Option A — PnP Framework (preferred):**
`context.Web.Context.Site.Owner = user` combined with
`Tenant.SetSiteProperties(siteUrl, owners: loginName)` or
`SPOTenantContext.SetSiteAdmin(siteUrl, loginName, isAdmin: true)` — all available via
`PnP.Framework` which is already a project dependency.
The calling account must hold the SharePoint Administrator role at the tenant level (not just
site admin) to add itself as site collection admin to a site it is currently denied from.
**Option B — Graph API:**
`POST /sites/{siteId}/permissions` with `roles: ["owner"]` — grants the service principal owner
access to a specific site. This works for application permissions with Sites.FullControl.All
but requires additional Graph permission scopes not currently in use.
**Recommended:** PnP Framework path (Option A) because:
- PnP.Framework is already a dependency (no new package)
- The app already uses delegated PnP context for all SharePoint operations
- `Tenant.SetSiteAdmin` is a single method call, well-understood in the MSP ecosystem
- Graph site permissions path requires Sites.FullControl.All which is a very broad app permission
**Self-healing sequence:**
1. Site scan returns 401/403
2. If toggle is ON: call `SetSiteAdmin(siteUrl, currentUserLogin, isAdmin: true)`
3. Retry the scan operation
4. If auto-remove-after is ON: call `SetSiteAdmin(siteUrl, currentUserLogin, isAdmin: false)`
5. Log the takeover action (site, timestamp, user) for audit trail
### Table Stakes
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Global toggle in Settings to enable/disable auto-ownership | Some MSPs want this; others consider it too aggressive for compliance reasons | Low | Boolean field in AppSettings; surfaced in Settings tab |
| Take ownership on 401/403 and retry the scan | The core capability; without the retry it is pointless | Medium | Error interception in the scan pipeline; conditional branch |
| Audit log of takeover actions | Compliance requirement — admin must know which sites were temporarily owned | Low | Extend existing Serilog logging; optionally surface in the scan results list |
| Respect the toggle per-scan-run (not retroactive) | Some scans are read-only audits; the toggle state at run-start should be captured | Low | Capture AppSettings.AutoTakeOwnership at scan start; pass through as scan context |
### Differentiators
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| Auto-remove ownership after scan completes | Least-privilege principle; the scanning account should not retain admin rights beyond the scan | Medium | Track which sites were auto-granted; remove in a finally block or post-scan cleanup step |
| Per-scan results column showing "Ownership Taken" flag | Transparency — admin sees which sites required escalation | Low | Add a flag to the ScanResultItem model; render as icon/badge in the results DataGrid |
### Anti-Features
| Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------|
| Auto-take enabled by default | Too aggressive for compliance-conscious MSPs; could violate client change-control policies | Default OFF; require explicit opt-in |
| Permanently retain ownership | Violates least-privilege; creates audit exposure for the MSP | Always remove after scan unless admin explicitly retains |
| Silent ownership changes with no audit trail | Undiscoverable by the client tenant's own admins | Log every takeover with timestamp and account UPN |
### Feature Dependencies
```
Existing:
AppSettings + AutoTakeOwnership (bool, default false)
+ AutoRemoveOwnershipAfterScan (bool, default true)
IPnPContextFactory → provides PnP context for Tenant-level operations
PermissionsScanService → where 401/403 errors currently surface per site
ScanResultItem → add OwnershipTakenFlag (bool)
New:
ISiteOwnershipService / SiteOwnershipService
→ TakeOwnershipAsync(siteUrl, loginName) : Task
→ RemoveOwnershipAsync(siteUrl, loginName) : Task
→ Uses PnP.Framework Tenant.SetSiteAdmin
ScanContext record (or existing scan parameters)
→ Carry AutoTakeOwnership bool captured at scan-start
```
**Key existing code note:** The existing BulkOperationRunner pattern handles per-item continue-on-
error. The ownership-takeover path should not be grafted into BulkOperationRunner directly;
instead it wraps the per-site scan call with a retry decorator that intercepts 401/403 and
invokes SiteOwnershipService before retrying.
### Complexity Assessment
| Sub-task | Complexity | Reason |
|----------|------------|--------|
| AppSettings fields + Settings UI toggle | Low | Trivial bool fields; existing settings pattern |
| ISiteOwnershipService + PnP SetSiteAdmin calls | Low-Medium | Well-known PnP API; success/failure handling |
| Error interception + retry in scan pipeline | Medium | Must not break existing error reporting; retry must not loop on non-permissions errors |
| Auto-remove in finally block after scan | Medium | Must track which sites were granted and clean up even if scan errors |
| Audit log / results column | Low | Extend existing model + DataGrid |
| Localization EN/FR | Low | ~8-10 new keys |
| Unit tests | High | Retry logic + cleanup path requires careful mock setup; ~15 test cases |
---
## Feature 4: Expand Groups in HTML Reports
### What it is
In HTML permission reports, security groups currently appear as a flat entry (e.g., "IT Team — Edit").
With this feature, each group row has an expand/collapse toggle that shows its members inline,
without leaving the report page. The expanded members list is embedded at report-generation time
(not lazy-loaded via an API call when the report is opened).
### How it works (technical)
At report generation time, for each permission entry that is a group:
1. Resolve group membership: `GET /groups/{id}/members?$select=displayName,userPrincipalName`
2. Embed member data inline in the HTML as a hidden `<tbody>` or `<div>` with a stable CSS class
3. Emit a small inline `<script>` block (vanilla JS, no external dependencies) that toggles
`display:none` on the child rows when the group header is clicked
The `<details>/<summary>` HTML5 approach is also viable and requires zero JavaScript, but gives
less control over styling and the expand icon placement. The onclick/toggle pattern with a
`<span>` chevron is more consistent with the existing report CSS.
**Group member resolution** requires the `GroupMember.Read.All` Graph permission (delegated) or
`Group.Read.All`. The app likely already consumes `Group.Read.All` for the existing group-in-
permissions display — confirm scope list during implementation.
**Depth limit:** Nested groups (groups-within-groups) should be resolved one level deep only.
Full recursive expansion of nested groups can return hundreds of entries and is overkill for a
permissions audit. Mark nested group entries with a "nested group — expand separately" note.
### Table Stakes
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Group rows in HTML report are expandable to show members | A flat "IT Team — Edit" entry is not auditable; admins need to see who is actually in the group | Medium | Member data embedded at generation time; vanilla JS toggle |
| Collapsed by default | Reports may have dozens of groups; expanded by default would be overwhelming | Low | CSS `display:none` on child rows by default; toggle on click |
| Member count shown on collapsed group row | Gives the admin a preview of group size without expanding | Low | `memberCount` available from group metadata or `members.length` at generation time |
| Groups without members (empty) still render correctly | Empty groups exist; collapsed empty list should not crash or show a spinner | Low | Conditional render: no chevron and "(0 members)" label when empty |
### Differentiators
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| "Expand all / Collapse all" button in report header | Useful for small reports or print-to-PDF workflows | Low | Two buttons calling a JS `querySelectorAll('.group-members').forEach(...)` |
| Distinguish direct members vs nested group members visually | Clear hierarchy: direct members vs members-via-nested-group | Medium | Color code or indent nested group entries; requires recursive resolution with depth tracking |
### Anti-Features
| Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------|
| Live API call when user clicks expand (lazy load in browser) | HTML reports are static files — often emailed or archived offline; API calls from a saved HTML file will fail | Embed all member data at generation time, unconditionally |
| Full recursive group expansion (unlimited depth) | Deep nesting can multiply entries 100x; makes reports unusable | One level deep; label nested group entries as such |
| Add group expansion to CSV exports | CSV is flat by nature | CSV stays flat; group expansion is HTML-only |
### Feature Dependencies
```
Existing:
IGraphGroupService (or existing permission resolution code)
→ MemberResolutionAsync(groupId) : Task<IEnumerable<GroupMemberEntry>>
→ Uses existing GraphClientFactory
HtmlExportService (and all other HTML exporters that include group entries)
→ Pass group members into the template at generation time
→ New: HtmlGroupExpansionHelper
→ Renders group header row with expand chevron + member count
→ Renders hidden member rows
→ Emits the inline toggle JS snippet once per report (idempotent)
PermissionEntry model (or equivalent)
→ Add: ResolvedMembers (IList<GroupMemberEntry>?, nullable — only populated for groups)
```
**Key existing code note:** v2.2 already has a shared `HtmlReportHeaderBuilder`. The group
expansion helper follows the same pattern — a shared renderer called from each HTML export
service that emits the group expand/collapse markup and the one-time JS snippet.
### Complexity Assessment
| Sub-task | Complexity | Reason |
|----------|------------|--------|
| Group member resolution at export time | Medium | Graph call per group; rate-limit awareness; empty group handling |
| HTML template for expandable group rows | Medium | Markup + CSS; inline vanilla JS toggle |
| Embed member data in report model | Low | Extend permission entry model; nullable field |
| Wire up in all HTML exporters that render groups | Medium | Multiple exporters (permissions, user access); each needs the helper |
| Localization EN/FR | Low | ~6-8 new keys |
| Unit tests | Medium | Mock member resolution; verify HTML output contains toggle structure |
---
## Feature 5: Report Consolidation Toggle (Merge Duplicate Entries)
### What it is
In the permissions report, a user who appears in multiple groups — or has direct AND group-based
access — currently generates multiple rows (one per access path). With consolidation ON, these
rows are merged into a single row showing the user's highest-permission level and a count of
access paths.
Example before: "Alice — Edit (via IT Team)", "Alice — Read (direct)"
Example after: "Alice — Edit (2 access paths)" [with a detail-expand or tooltip]
### How it works (technically)
Pure in-memory post-processing on the list of resolved permission entries:
1. Group entries by UPN (or object ID for robustness)
2. For each group: keep the highest-privilege entry, aggregate source paths into a list
3. Annotate the merged entry with access path count and source summary
4. The toggle lives in the export settings or the results toolbar — not a permanent report setting
This is entirely client-side (in-memory in C#) — no additional API calls needed.
**Privilege ordering** must be well-defined:
`FullControl > Edit/Contribute > Read > Limited Access > View Only`
### Table Stakes
| Feature | Why Expected | Complexity | Notes |
|---------|--------------|------------|-------|
| Consolidation toggle in the report/export UI | Auditors want one row per user for a clean headcount view; default OFF preserves existing behavior | Low | Toggle in ViewModel; filters the display/export collection |
| Merge duplicate user rows, keep highest permission | Core consolidation logic | Medium | LINQ GroupBy on UPN + MaxBy on permission level; requires a defined privilege enum ordering |
| Show access path count on consolidated row | "Alice — Edit (3 access paths)" is auditable; silent deduplication is not | Low | Derived count from the group; add to the display model |
| Consolidated export to both HTML and CSV | The toggle must apply equally to all export formats | Low-Medium | Apply consolidation in the ViewModel before passing to export services |
### Differentiators
| Feature | Value Proposition | Complexity | Notes |
|---------|-------------------|------------|-------|
| Expand consolidated row to see individual access paths (HTML only) | Same expand pattern as Feature 4 (groups); user sees "why Edit" on click | Medium | Reuse the group expansion HTML pattern; embed source paths as hidden child rows |
| Summary line: "X users, Y consolidated entries" in report header | Gives auditors the before/after count immediately | Low | Simple count comparison; rendered in the report header |
### Anti-Features
| Anti-Feature | Why Avoid | What to Do Instead |
|--------------|-----------|-------------------|
| Consolidation ON by default | Breaks existing workflow; MSPs relying on multi-path audit output would lose data silently | Default OFF; opt-in per export run |
| Permanent merge (no way to see individual paths) | Auditors must be able to see all access paths for security review | Always preserve the unexpanded detail; consolidation is a view layer only |
| Merge across sites | A user's Edit on Site A and Read on Site B are not the same permission; cross-site merge would lose site context | Consolidate within a site only; separate sections per site remain intact |
### Feature Dependencies
```
Existing:
PermissionEntry / UserAccessEntry models
→ No schema changes needed; consolidation is a view-model transform
PermissionsScanViewModel / UserAccessAuditViewModel
→ Add: IsConsolidated (bool toggle, default false)
→ Add: ConsolidatedResults (computed from raw results via LINQ on toggle change)
HtmlExportService / CsvExportService
→ Accept either raw or consolidated entry list based on toggle state
New:
PermissionConsolidationService (or static helper)
→ Consolidate(IEnumerable<PermissionEntry>, siteScope) : IEnumerable<ConsolidatedEntry>
→ Defines PermissionLevel enum with ordering for MaxBy
```
**Key existing code note:** The app already has a detail-level toggle (simplified vs full
permissions view — shipped in v1.1). The consolidation toggle follows the same UX pattern:
a toolbar toggle that switches between two display modes. Reuse that toggle component and the
pattern of maintaining a filtered/transformed display collection alongside the raw results.
### Complexity Assessment
| Sub-task | Complexity | Reason |
|----------|------------|--------|
| PermissionLevel enum with ordering | Low | Define once; used by consolidation service and existing simplified view |
| PermissionConsolidationService (LINQ GroupBy + MaxBy) | Low-Medium | Straightforward transformation; edge cases around tie-breaking and LimitedAccess entries |
| ViewModel toggle + computed consolidated collection | Low | Mirrors existing simplified/detail toggle pattern |
| Wire consolidated list into export services | Low | Both exporters already accept IEnumerable; no signature change needed |
| HTML: expand access paths for consolidated entries | Medium | Reuse group expansion markup from Feature 4 |
| Localization EN/FR | Low | ~8-10 new keys |
| Unit tests | Medium | Consolidation logic has many edge cases (direct+group, multiple groups, empty) |
---
## 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
Graph API scopes (cumulative for this milestone):
Application.ReadWrite.All → Features 1+2 (app registration/removal)
AppRoleAssignment.ReadWrite.All → Feature 1 (consent grant)
GroupMember.Read.All → Feature 4 (group expansion)
SharePoint Sites.FullControl.All → Optional alt path for Feature 3 (avoid if possible)
Model changes:
AppSettings + AutoTakeOwnership (bool)
+ AutoRemoveOwnershipAfterScan (bool)
+ (no new branding fields — v2.2 shipped those)
TenantProfile + ClientId may be auto-populated (Feature 1)
PermissionEntry + ResolvedMembers (Feature 4)
ScanResultItem + OwnershipTakenFlag (Feature 3)
New services (all injectable, interface-first):
IAppRegistrationService → Features 1+2
ISiteOwnershipService → Feature 3
IPermissionConsolidationService → Feature 5
HtmlGroupExpansionHelper → Feature 4 (not a full service, a renderer helper)
Shared infrastructure (no changes needed):
GraphClientFactory → unchanged
BulkOperationRunner → unchanged; Feature 3 wraps around it
HtmlReportHeaderBuilder → extended by Feature 4 helper
Serilog logging → unchanged; Features 1+3 add audit log entries
```
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.
No new NuGet packages are needed for Features 3-5. Features 1-2 are already covered by the
Microsoft Graph SDK which is a current dependency. The self-contained EXE size is not expected
to increase.
---
## MVP Recommendation
## Build Order Recommendation
Build in this order, each independently releasable:
Sequence by lowest-risk-to-highest-risk, 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.
1. **Report Consolidation Toggle (Feature 5)** — Pure in-memory LINQ; zero new API calls; zero
risk to existing pipeline. Builds confidence before touching external APIs.
2. **Group Expansion in HTML Reports (Feature 4)** — Graph call at export time; reuses existing
GraphClientFactory; lower blast radius than account/registration operations.
3. **Auto-Take Ownership Toggle (Feature 3)** — Modifies tenant state (site admin changes);
must be tested on a non-production tenant. PnP path is well-understood.
4. **App Registration (Feature 1)** — Modifies Entra configuration on the target tenant; highest
blast radius if something goes wrong; save for last when the rest of the milestone is stable.
5. **App Removal (Feature 2)** — Depends on Feature 1 infra (AppRegistrationService); build
immediately after Feature 1 is stable and tested.
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.
- Certificate-based credentials for registered apps (out of scope by design)
- Cross-site consolidation (different problem domain)
- Recursive group expansion beyond 1 level (complexity/value ratio too low)
---
## Sources
- 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
- Graph API POST /applications (v1.0 official): https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0 — HIGH confidence
- Graph API grant/revoke permissions programmatically: https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph — HIGH confidence
- Graph API POST /servicePrincipals: https://learn.microsoft.com/en-us/graph/api/serviceprincipal-post-serviceprincipals?view=graph-rest-1.0 — HIGH confidence (confirmed SP creation is an explicit separate step when using Graph API)
- PnP PowerShell Add-PnPSiteCollectionAdmin: https://pnp.github.io/powershell/cmdlets/Add-PnPSiteCollectionAdmin.html — HIGH confidence (C# equivalent available via PnP.Framework Tenant API)
- PnP PowerShell Set-PnPTenantSite -Owners: https://pnp.github.io/powershell/cmdlets/Set-PnPTenantSite.html — HIGH confidence
- HTML collapsible pattern (details/summary + JS onclick): https://dev.to/jordanfinners/creating-a-collapsible-section-with-nothing-but-html-4ip9 — HIGH confidence (standard HTML5)
- W3Schools collapsible JS pattern: https://www.w3schools.com/howto/howto_js_collapsible.asp — HIGH confidence
- Graph API programmatically manage Entra apps: https://learn.microsoft.com/en-us/graph/tutorial-applications-basics — HIGH confidence
- Required Entra role for app registration: Application.ReadWrite.All + Cloud Application Administrator minimum — HIGH confidence (official permissions reference)
- Direct codebase inspection: AppSettings.cs, TenantProfile.cs, GraphClientFactory.cs, BulkOperationRunner.cs, HtmlExportService.cs, PermissionsScanService.cs — HIGH confidence
+321
View File
@@ -800,3 +800,324 @@ bitmap.Freeze(); // Makes it immutable and thread-safe; also releases the file h
---
*v2.2 pitfalls appended: 2026-04-08*
---
# v2.3 Pitfalls: Tenant Management & Report Enhancements
**Milestone:** v2.3 — App registration, auto-ownership, HTML group expansion, report consolidation
**Researched:** 2026-04-09
**Confidence:** HIGH for app registration sequence and group expansion limits (official Microsoft Learn docs); MEDIUM for auto-ownership security implications (multiple official sources cross-verified); MEDIUM for report consolidation (general deduplication principles applied to specific codebase model)
These pitfalls are specific to the four new feature areas in v2.3. They complement all prior pitfall sections above.
---
## Critical Pitfalls (v2.3)
### Pitfall v2.3-1: Missing Service Principal Creation After App Registration
**What goes wrong:**
`POST /applications` creates the application object (the registration) but does NOT automatically create the service principal (enterprise app entry) in the target tenant. Attempting to grant permissions or use the app before creating the service principal produces cryptic 400/404 errors with no clear explanation. The application appears in Entra "App registrations" but is absent from "Enterprise applications."
**Why it happens:**
The distinction between the application object (one across all tenants, lives in home tenant) and the service principal (one per tenant that uses the app) is not obvious. Most UI flows in the Azure portal create both atomically; the Graph API does not.
**Consequences:**
Permission grants fail. Admin consent cannot be completed. The automated registration path appears broken with no recoverable error message.
**Prevention:**
Implement app creation as a three-step atomic transaction with rollback on any failure:
1. `POST /applications` — capture `appId` and object `id`
2. `POST /servicePrincipals` with `{ "appId": "<appId>" }` — capture service principal `id`
3. `POST /servicePrincipals/{spId}/appRoleAssignments` — grant each required app role
If step 2 or 3 fail, delete the application object created in step 1 to avoid orphaned registrations. Surface the failure with a specific message: "App was registered but could not be configured. It has been removed. Try again or use the manual setup guide."
**Detection:**
- App appears in Azure portal App Registrations but not in Enterprise Applications.
- Token acquisition fails with AADSTS700016 ("Application not found in directory").
- `appRoleAssignment` POST returns 404 "Resource not found."
**Phase to address:** App Registration feature — before writing any registration code.
---
### Pitfall v2.3-2: Circular Consent Dependency for the Automated Registration Path
**What goes wrong:**
The automated path calls Graph APIs to create an app registration on a target tenant. These calls require the MSP own `TenantProfile.ClientId` app to have `Application.ReadWrite.All` and `AppRoleAssignment.ReadWrite.All` delegated permissions consented. These permissions are high-privilege and almost certainly not in the MSP app current consent grant (which was configured for SharePoint auditing). Without them, the automated path fails with 403 Forbidden on the very first Graph call.
**Why it happens:**
The MSP app was registered for auditing scopes (SharePoint, Graph user read). App management scopes are a distinct, highly privileged category. Developers test against their own dev tenant where they have unrestricted access and never hit this problem.
**Consequences:**
The "auto via Graph API" mode works only in the narrow case where the MSP has pre-configured their own app with these elevated permissions. For all other deployments, it fails silently or with a confusing 403.
**Prevention:**
- Design two modes from day one: **automated** (MSP app already has `Application.ReadWrite.All` + `AppRoleAssignment.ReadWrite.All`) and **guided fallback** (step-by-step portal instructions shown in UI).
- Before attempting the automated path, detect whether the required permissions are available: request a token with the required scopes and handle `MsalUiRequiredException` or `MsalServiceException` with error code `insufficient_scope` as a signal to fall back.
- The guided fallback must be a first-class feature, not an afterthought. It should produce a pre-filled PowerShell script or direct portal URLs the target tenant admin can follow.
- Never crash on a 403; always degrade gracefully to guided mode.
**Detection:**
- MSAL token request returns `insufficient_scope` or Graph returns `Authorization_RequestDenied`.
- Works on dev machine (dev has Global Admin + explicit consent), fails on first real MSP deployment.
**Phase to address:** App Registration design — resolve guided vs. automated split before implementation begins.
---
### Pitfall v2.3-3: App Removal Leaves Orphaned Service Principals in Target Tenant
**What goes wrong:**
`DELETE /applications/{objectId}` removes the application object from the home tenant but does NOT delete the service principal in the target tenant. OAuth2 permission grants and app role assignments linked to that service principal also remain. On re-registration, Entra may reject with a duplicate `appId` error, or the target tenant accumulates zombie enterprise app entries that confuse tenant admins.
**Why it happens:**
The service principal is owned by the target tenant Entra directory, not by the application home tenant. The MSP app may not have permission to delete service principals in the target tenant.
**Prevention:**
Define the removal sequence as:
1. Revoke all app role assignments: `DELETE /servicePrincipals/{spId}/appRoleAssignments/{id}` for each grant
2. Delete the service principal: `DELETE /servicePrincipals/{spId}`
3. Delete the application object: `DELETE /applications/{appObjectId}`
If step 2 fails with 403 (cross-tenant restriction), surface a guided step: "Open the target tenant Azure portal -> Enterprise Applications -> search for the app name -> Delete." Do not silently skip — leaving an orphaned SP is a security artifact.
Require the stored `ManagedAppObjectId` and `ManagedServicePrincipalId` fields (see Pitfall v2.3-5) for this operation; never search by display name.
**Detection:**
- After deletion, the Enterprise Application still appears in the target tenant portal.
- Re-registration attempt produces `AADSTS70011: Invalid scope. The scope ... is not valid`.
**Phase to address:** App Removal feature.
---
### Pitfall v2.3-4: Auto-Ownership Elevation Not Cleaned Up on Crash or Token Expiry
**What goes wrong:**
The "auto-take ownership on access denied" flow elevates the tool to site collection administrator, performs the scan, then removes itself. If the app crashes mid-scan, the user closes the window, or the MSAL token expires and the removal call fails, the elevation is never reverted. The MSP account now has persistent, undocumented site collection admin rights on a client site — a security and compliance risk.
**Why it happens:**
The take-ownership -> act -> release pattern requires reliable cleanup in all failure paths. WPF desktop apps can be terminated by the OS (BSOD, force close, low memory). Token expiry is time-based and unpredictable. No amount of `try/finally` protects against hard process termination.
**Consequences:**
- MSP account silently holds elevated permissions on client sites.
- If audited, the MSP appears to have persistent admin access without justification.
- Client tenant admins may notice unexplained site collection admins and raise a security concern.
**Prevention:**
- `try/finally` is necessary but not sufficient. Also maintain a persistent "cleanup pending" list in a local JSON file (e.g., `pending_ownership_cleanup.json`). Write the site URL and elevation timestamp to this file BEFORE the elevation happens. Remove the entry AFTER successful cleanup.
- On every app startup, check this file and surface a non-dismissable warning listing any pending cleanups with links to the SharePoint admin center for manual resolution.
- The UI toggle label should reflect the risk: "Auto-take site ownership on access denied (will attempt to release after scan)."
- Log every elevation and every release attempt to Serilog with outcome (success/failure), site URL, and timestamp.
**Detection:**
- After a scan that uses auto-ownership, check the site Site Collection Administrators in SharePoint admin center. The MSP account should not be present.
- Simulate a crash mid-scan; restart the app. Verify the cleanup warning appears.
**Phase to address:** Auto-Ownership feature — persistence mechanism and startup check must be built before the elevation logic.
---
## Moderate Pitfalls (v2.3)
### Pitfall v2.3-5: TenantProfile Model Missing Fields for Registration Metadata
**What goes wrong:**
`TenantProfile` currently has `Name`, `TenantUrl`, `ClientId`, and `ClientLogo`. After app registration, the tool needs to store the created application Graph object ID, `appId`, and service principal ID for later removal. Without these fields, removal requires searching by display name — fragile if a tenant admin renamed the app — or is impossible programmatically.
**Prevention:**
Extend `TenantProfile` with optional fields before writing any registration code:
```csharp
public string? ManagedAppObjectId { get; set; } // Graph object ID of created application
public string? ManagedAppId { get; set; } // appId (client ID) of created app
public string? ManagedServicePrincipalId { get; set; }
public DateTimeOffset? ManagedAppRegisteredAt { get; set; }
```
These are nullable: profiles created before v2.3 or using manually configured app registrations will have them null, which signals "use guided removal."
Persist atomically to the JSON profile file immediately after successful registration (using the existing write-then-replace pattern from the foundation pitfall section).
**Phase to address:** App Registration feature — model change must precede implementation of both registration and removal.
---
### Pitfall v2.3-6: `$expand=members` Silently Truncates Group Members at ~20
**What goes wrong:**
The simplest approach to get group members for HTML report expansion is `GET /groups/{id}?$expand=members`. This is hard-capped at approximately 20 members and is not paginable — `$top` does not increase the limit for expanded navigational properties. For any real-world group (department group, "All Employees"), the expanded list is silently incomplete with no `@odata.nextLink` or warning.
**Why it happens:**
`$expand` is a navigational shortcut for small relationships, not for large collection fetches. Developers use it because it retrieves the parent object and its members in one call.
**Prevention:**
Always use the dedicated endpoint: `GET /groups/{id}/transitiveMembers?$select=displayName,mail,userPrincipalName&$top=999` and follow `@odata.nextLink` until exhausted. `transitiveMembers` resolves nested group membership server-side, eliminating the need for manual recursion in most cases.
Group member data must be resolved server-side at report generation time (in C#). The HTML output is a static offline file — no live Graph calls are possible after export.
**Phase to address:** HTML Group Expansion feature.
---
### Pitfall v2.3-7: Nested Group Recursion Without Cycle Detection
**What goes wrong:**
If `transitiveMembers` is not used and manual recursion is implemented, groups can form cycles in edge cases. Even without true cycles, the same group ID can appear via multiple paths (group A and group B both contain group C), causing its members to be listed twice.
**Prevention:**
- Prefer `transitiveMembers` over manual recursion for M365/Entra groups — Graph resolves transitivity server-side.
- If manual recursion is needed (e.g., for SharePoint groups which are not M365 groups), maintain a `HashSet<string>` of visited group IDs. If a group ID is already in the set, skip it.
- Cap recursion depth at 5. Surface a "(nesting limit reached)" indicator in the HTML if the cap is hit.
**Phase to address:** HTML Group Expansion feature.
---
### Pitfall v2.3-8: Report Consolidation Changes the Output Schema Users Depend On
**What goes wrong:**
`UserAccessEntry` is a flat record: one row = one permission assignment. Users (and any downstream automation) expect this structure. Consolidation merges rows for the same user across sites/objects into a single row with aggregated data. This is a breaking change to the report format. Existing users treating the CSV export as structured input will have their scripts break silently (wrong row count, missing columns, or changed column semantics).
**Why it happens:**
Consolidation is useful but changes the fundamental shape of the data. If it is on by default or a persistent global setting, users who do not read release notes discover the breakage in production.
**Prevention:**
- Consolidation toggle must be **off by default** and **per-report-generation** (a checkbox at export time, not a persistent global preference).
- Introduce a new `ConsolidatedUserAccessEntry` type; do not modify `UserAccessEntry`. The existing audit pipeline, CSV export, and HTML export continue to use `UserAccessEntry` unchanged.
- Consolidation produces a clearly labelled report (e.g., a "Consolidated View" header in the HTML, or a `_consolidated` filename suffix for CSV).
- Both CSV and HTML exports must honour the toggle consistently. A mismatch (CSV not consolidated, HTML consolidated for the same run) is a data integrity error.
**Phase to address:** Report Consolidation feature — model and toggle design must be settled before building the consolidation logic.
---
### Pitfall v2.3-9: Graph API Throttling During Bulk Group Expansion at Report Generation
**What goes wrong:**
A user access report across 20 sites may surface 50+ distinct groups. Expanding all of them via sequential Graph calls can trigger HTTP 429. After September 2025, Microsoft reduced per-app per-user throttling limits to half of the tenant total, making this more likely under sustained MSP use.
**Prevention:**
- Cache group membership results within a single report generation run: if the same `groupId` appears in multiple sites, resolve it once and reuse the result. A `Dictionary<string, IReadOnlyList<GroupMember>>` keyed by group ID is sufficient.
- Process group expansions with bounded concurrency: `SemaphoreSlim(3)` (max 3 concurrent) rather than `Task.WhenAll` over all groups.
- Apply exponential backoff on 429 responses using the `Retry-After` response header value.
- The existing `BulkOperationRunner` pattern can be adapted for this purpose.
**Phase to address:** HTML Group Expansion feature.
---
### Pitfall v2.3-10: SharePoint Admin Role Required for Site Ownership Changes (Not Just Global Admin)
**What goes wrong:**
Adding a user as site collection administrator via PnP Framework or Graph requires the authenticated account to be a SharePoint Administrator (the role in the Microsoft 365 admin center), not just a Global Administrator. A user can be Global Admin in Entra without being SharePoint Admin. In testing environments the developer is typically both; in production MSP deployments a dedicated service account may only have the roles explicitly needed for auditing.
**Why it happens:**
SharePoint has its own RBAC layer. PnP `AddAdministratorToSiteAsync` and equivalent CSOM calls check SharePoint-level admin role, not just Entra admin roles.
**Prevention:**
- Before enabling the auto-ownership feature for a profile, validate that the current authenticated account has SharePoint admin rights. Attempt a low-risk admin API call (e.g., `GET /admin/sharepoint/sites`) and handle 403 as "insufficient permissions — SharePoint Administrator role required."
- Document the requirement in the UI tooltip and guided setup text.
- Test the feature against an account that is Global Admin but NOT SharePoint Admin to confirm the error path and message.
**Phase to address:** Auto-Ownership feature.
---
### Pitfall v2.3-11: HTML Report Size Explosion from Embedded Group Member Data
**What goes wrong:**
The current HTML export embeds all data inline as JavaScript variables. Expanding group members for a large report (50 groups x 200 members) embeds 10,000 additional name/email strings inline. Report file size can grow from ~200 KB to 5+ MB. Opening the file in Edge on an older machine becomes slow; in extreme cases the browser tab crashes.
**Prevention:**
- Cap embedded member data at a configurable limit (e.g., 200 members per group). Display the actual count alongside a "(showing first 200 of 1,450)" indicator.
- Render member lists as hidden `<div>` blocks toggled by the existing clickable-expand JavaScript pattern — do not pre-render all member rows into visible DOM nodes.
- Do not attempt to implement live API calls from the HTML file. It is a static offline report and has no authentication context.
**Phase to address:** HTML Group Expansion feature.
---
### Pitfall v2.3-12: App Registration Display Name Collision
**What goes wrong:**
Using a fixed display name (e.g., "SharePoint Toolbox") for every app registration created across all client tenants, combined with looking up apps by display name for removal, causes the removal flow to target the wrong app if a tenant admin manually created another app with the same name.
**Prevention:**
- Use a unique display name per registration that includes a recognizable prefix and ideally the MSP name, e.g., "SharePoint Toolbox - Contoso MSP."
- Never use display name for targeting deletions. Always use the stored `ManagedAppObjectId` (see Pitfall v2.3-5).
**Phase to address:** App Registration feature.
---
## Phase-Specific Warnings (v2.3)
| Phase Topic | Likely Pitfall | Mitigation |
|---|---|---|
| App Registration — design | Automated path fails due to missing `Application.ReadWrite.All` (v2.3-2) | Design guided fallback before automated path; detect permission gaps before first API call |
| App Registration — data model | `TenantProfile` cannot store created app IDs (v2.3-5) | Add nullable fields to model; persist atomically after registration |
| App Registration — sequence | Forgetting `POST /servicePrincipals` after `POST /applications` (v2.3-1) | Implement as atomic 3-step transaction with rollback |
| App Registration — display name | Collision with manually created apps (v2.3-12) | Unique name including MSP identifier; never search/delete by name |
| App Removal | Orphaned service principal in target tenant (v2.3-3) | Three-step removal with guided fallback if cross-tenant SP deletion fails |
| Auto-Ownership — cleanup | Elevation not reverted on crash (v2.3-4) | Persistent cleanup-pending JSON + startup check + non-dismissable warning |
| Auto-Ownership — permissions | Works in dev (Global Admin), fails in production (no SharePoint Admin role) (v2.3-10) | Validate SharePoint admin role before first elevation; test against restricted account |
| Group Expansion — member fetch | `$expand=members` silently truncates at ~20 (v2.3-6) | Use `transitiveMembers` with `$top=999` + follow `@odata.nextLink` |
| Group Expansion — recursion | Cycle / duplication in nested groups (v2.3-7) | `HashSet<string>` visited set; prefer `transitiveMembers` over manual recursion |
| Group Expansion — throttling | 429 from bulk group member fetches (v2.3-9) | Per-session member cache; `SemaphoreSlim(3)`; exponential backoff on 429 |
| Group Expansion — HTML size | Report file grows to 5+ MB (v2.3-11) | Cap members per group; lazy-render hidden blocks; display "first N of M" indicator |
| Report Consolidation — schema | Breaking change to row structure (v2.3-8) | Off by default; new model type; consistent CSV+HTML behaviour |
---
## v2.3 Integration Gotchas
| Integration | Common Mistake | Correct Approach |
|---|---|---|
| Graph `POST /applications` | Assuming service principal is auto-created | Always follow with `POST /servicePrincipals { "appId": "..." }` before granting permissions |
| Graph admin consent grant | Using delegated flow without `Application.ReadWrite.All` pre-consented | Detect missing scope at startup; fall back to guided mode gracefully |
| Graph group members | `$expand=members` on group object | `GET /groups/{id}/transitiveMembers?$select=...&$top=999` + follow `nextLink` |
| PnP set site collection admin | Global Admin account without SharePoint Admin role | Validate SharePoint admin role before attempting; test against restricted account |
| Auto-ownership cleanup | `try/finally` assumed sufficient | Persistent JSON cleanup list + startup check handles hard process termination |
| `TenantProfile` for removal | Search for app by display name | Store `ManagedAppObjectId` at registration time; use object ID for all subsequent operations |
| Report consolidation toggle | Persistent global setting silently changes future exports | Per-export-run checkbox, off by default; new model type; never modify `UserAccessEntry` |
---
## v2.3 "Looks Done But Isn't" Checklist
- [ ] **App registration — service principal:** After automated registration, verify the app appears in the target tenant Enterprise Applications (not just App Registrations).
- [ ] **App registration — guided fallback:** Disable `Application.ReadWrite.All` on the MSP app and attempt automated registration. Verify graceful fallback to guided mode with a clear explanation, not a crash.
- [ ] **App removal — SP cleanup:** After removal, verify the Enterprise Application is gone from the target tenant. If SP deletion failed, verify the guided manual step is surfaced.
- [ ] **Auto-ownership — cleanup on crash:** Start an auto-ownership scan, force-close the app mid-scan, restart. Verify the cleanup-pending warning appears with the site URL.
- [ ] **Auto-ownership — release after scan:** Complete a full auto-ownership scan. Verify the MSP account is no longer in the site collection admins list.
- [ ] **Group expansion — large group:** Expand a group with 200+ members. Verify all members are shown (not just 20), or the cap indicator is correct.
- [ ] **Group expansion — nested groups:** Expand a group that contains a sub-group. Verify sub-group members appear without duplicates.
- [ ] **Group expansion — throttle recovery:** Simulate 429 during group expansion. Verify the operation pauses, logs "Retrying in Xs", and completes.
- [ ] **Report consolidation — off by default:** Generate a user access report without enabling the toggle. Verify the output is identical to v2.2 output for the same data.
- [ ] **Report consolidation — CSV + HTML consistency:** Enable consolidation and export both CSV and HTML. Verify both show the same number of consolidated rows.
- [ ] **TenantProfile persistence:** After app registration, open the profile JSON file and verify `ManagedAppObjectId`, `ManagedAppId`, and `ManagedServicePrincipalId` are present and non-empty.
---
## v2.3 Sources
- Microsoft Learn: Create application — Graph v1.0 — https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0
- Microsoft Learn: Grant and revoke API permissions programmatically — https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph
- Microsoft Learn: Grant tenant-wide admin consent to an application — https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent
- Microsoft Learn: Grant an appRoleAssignment to a service principal — https://learn.microsoft.com/en-us/graph/api/serviceprincipal-post-approleassignments?view=graph-rest-1.0
- Microsoft Learn: List group transitive members — Graph v1.0 — https://learn.microsoft.com/en-us/graph/api/group-list-transitivemembers?view=graph-rest-1.0
- Microsoft Learn: Microsoft Graph service-specific throttling limits — https://learn.microsoft.com/en-us/graph/throttling-limits
- Microsoft Q&A: How to use $expand=members parameter with pagination — https://learn.microsoft.com/en-us/answers/questions/5526721/how-to-use-the-expand-members-parameter-with-pagin
- Microsoft Learn: Create SharePoint site ownership policy — https://learn.microsoft.com/en-us/sharepoint/create-sharepoint-site-ownership-policy
- PnP PowerShell GitHub Issue #542: Add-PnPSiteCollectionAdmin Access Is Denied — https://github.com/pnp/powershell/issues/542
- Pim Widdershoven: Privilege escalation using Azure App Registration and Microsoft Graph — https://www.pimwiddershoven.nl/entry/privilege-escalation-azure-app-registration-microsoft-graph/
- 4Spot Consulting: Deduplication Pitfalls — When Not to Merge Data — https://4spotconsulting.com/when-clean-data-damages-your-business-the-perils-of-over-deduplication/
- Existing codebase: `TenantProfile.cs`, `UserAccessEntry.cs`, `UserAccessHtmlExportService.cs`, `SessionManager.cs` (reviewed 2026-04-09)
---
*v2.3 pitfalls appended: 2026-04-09*
+205 -10
View File
@@ -189,7 +189,7 @@ The implementation follows the same `GraphClientFactory` + `GraphServiceClient`
---
## No New NuGet Packages Required
## No New NuGet Packages Required (v2.2)
| Feature | What's needed | How provided |
|---|---|---|
@@ -207,7 +207,7 @@ The implementation follows the same `GraphClientFactory` + `GraphServiceClient`
---
## Impact on Existing Services
## Impact on Existing Services (v2.2)
### HTML Export Services
@@ -239,9 +239,196 @@ Add a `BrowseMode` boolean property (bound to a RadioButton or ToggleButton). Wh
---
---
## v2.3 Stack Additions
**Researched:** 2026-04-09
**Scope:** Only what is NEW vs the validated v2.2 stack. The short answer: **no new NuGet packages are required for any v2.3 feature.**
---
### Feature 1: App Registration on Target Tenant (Auto + Guided Fallback)
**What is needed:** Create an Entra app registration in a *target* (client) tenant from within the app, using the already-authenticated delegated token.
**No new packages required.** The existing `Microsoft.Graph` SDK (currently 5.74.0, latest stable is 5.103.0 as of 2026-02-20) already supports this via:
- `graphClient.Applications.PostAsync(new Application { DisplayName = "...", RequiredResourceAccess = [...] })` — creates the app object; returns the new `appId`
- `graphClient.ServicePrincipals.PostAsync(new ServicePrincipal { AppId = newAppId })` — instantiates the enterprise app in the target tenant so it can be consented
- `graphClient.Applications["{objectId}"].DeleteAsync()` — removes the registration (soft-delete, 30-day recycle bin in Entra)
All three operations are Graph v1.0 endpoints confirmed in official Microsoft Learn documentation (HIGH confidence).
**Required delegated permissions for these Graph calls:**
| Operation | Minimum delegated scope |
|-----------|------------------------|
| Create application (`POST /applications`) | `Application.ReadWrite.All` |
| Create service principal (`POST /servicePrincipals`) | `Application.ReadWrite.All` |
| Delete application (`DELETE /applications/{id}`) | `Application.ReadWrite.All` |
| Grant app role consent (`POST /servicePrincipals/{id}/appRoleAssignments`) | `AppRoleAssignment.ReadWrite.All` |
The calling user must also hold the **Application Administrator** or **Cloud Application Administrator** Entra role on the target tenant (or Global Administrator). Without the role, the delegated call returns 403 regardless of scope consent.
**Integration point — `GraphClientFactory` scope extension:**
The existing `GraphClientFactory.CreateClientAsync` uses `["https://graph.microsoft.com/.default"]`, relying on pre-consented `.default` resolution. For app registration, add an overload:
```csharp
// New method — only used by AppRegistrationService
public async Task<GraphServiceClient> CreateRegistrationClientAsync(
string clientId, CancellationToken ct)
{
var pca = await _msalFactory.GetOrCreateAsync(clientId);
var accounts = await pca.GetAccountsAsync();
var account = accounts.FirstOrDefault();
// Explicit scopes trigger incremental consent on first call
var scopes = new[]
{
"Application.ReadWrite.All",
"AppRoleAssignment.ReadWrite.All"
};
var tokenProvider = new MsalTokenProvider(pca, account, scopes);
var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider);
return new GraphServiceClient(authProvider);
}
```
MSAL will prompt for incremental consent if not yet granted. This keeps the default `CreateClientAsync` scopes unchanged and avoids over-permissioning all Graph calls throughout the app.
**`TenantProfile` model extension:**
```csharp
// Add to TenantProfile.cs
public string? AppObjectId { get; set; } // Entra object ID of the registered app
// null until registration completes
// used for deletion
```
Stored in JSON (existing ProfileService persistence). No schema migration needed — `System.Text.Json` deserializes missing properties as their default value (`null`).
**Guided fallback path:** If the automated registration fails (user lacks Application Administrator role, or consent blocked by tenant policy), open a browser to the Entra admin center app registration URL. No additional API calls needed for the fallback path.
---
### Feature 2: App Removal from Target Tenant
**Same stack as Feature 1.** `graphClient.Applications["{objectId}"].DeleteAsync()` is the Graph v1.0 `DELETE /applications/{id}` endpoint. Returns 204 on success. `AppObjectId` stored in `TenantProfile` provides the handle.
Deletion behavior: apps go to Entra's 30-day deleted items container and can be restored via the admin center. The app does not need to handle restoration — that is admin-center territory.
---
### Feature 3: Auto-Take Ownership of Sites on Access Denied (Global Toggle)
**No new packages required.** PnP Framework 1.18.0 already exposes the SharePoint tenant admin API via the `Tenant` class, which can add a site collection admin without requiring existing access to that site:
```csharp
// Requires ClientContext pointed at the tenant admin site
// (e.g., https://contoso-admin.sharepoint.com)
var tenant = new Tenant(adminCtx);
tenant.SetSiteAdmin(siteUrl, userLogin, isAdmin: true);
adminCtx.ExecuteQueryRetry();
```
**Critical constraint (HIGH confidence):** `Tenant.SetSiteAdmin` calls the SharePoint admin tenant API. This bypasses the site-level permission check — it does NOT require the authenticated user to already be a member of the site. It DOES require the user to hold the **SharePoint Administrator** or **Global Administrator** Entra role. If the user lacks this role, the call throws `ServerException` with "Access denied."
**Integration point — `SessionManager` admin context:**
The existing `SessionManager.GetOrCreateContextAsync` accepts a `TenantProfile` and uses `profile.TenantUrl` as the site URL. For tenant admin operations, a second context is needed pointing at the admin URL. Add:
```csharp
// New method in SessionManager (no new library, same PnP auth path)
public async Task<ClientContext> GetOrCreateAdminContextAsync(
TenantProfile profile, CancellationToken ct = default)
{
// Derive admin URL: https://contoso.sharepoint.com -> https://contoso-admin.sharepoint.com
var adminUrl = profile.TenantUrl
.TrimEnd('/')
.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
var adminProfile = profile with { TenantUrl = adminUrl };
return await GetOrCreateContextAsync(adminProfile, ct);
}
```
**Global toggle storage:** Add `AutoTakeOwnership: bool` to `AppSettings` (existing JSON settings file). No new model needed.
---
### Feature 4: Expand Groups in HTML Reports (Clickable to Show Members)
**No new packages required.** Pure C# + inline JavaScript.
**Server-side group member resolution:** SharePoint group members are already loaded during the permissions scan via CSOM `RoleAssignment.Member`. For SharePoint groups, `Member` is a `Group` object. Load its `Users` collection:
```csharp
// Already inside the permissions scan loop — extend it
if (roleAssignment.Member is Group spGroup)
{
ctx.Load(spGroup.Users);
// ExecuteQueryRetry already called in the scan loop
// Members available as spGroup.Users
}
```
No additional API calls beyond what the existing scan already performs (the Users collection is a CSOM lazy-load — one additional batch per group, amortized over the scan).
**HTML export change:** Pass group member lists into the export service as part of the existing `PermissionEntry` model (extend with `IReadOnlyList<string>? GroupMembers`). The export service renders members as a collapsible `<details>/<summary>` HTML element inline with each group-access row — pure HTML5, no JS library, no external dependency.
**Report consolidation pre-processing:** Consolidation is a LINQ step before export. No new model or service.
---
### Feature 5: Report Consolidation Toggle (Merge Duplicate User Entries)
**No new packages required.** Standard LINQ aggregation on the existing `IReadOnlyList<UserAccessEntry>` before handing off to any export service.
Consolidation merges rows with the same `(UserLogin, SiteUrl, PermissionLevel)` key, collecting distinct `GrantedThrough` values into a semicolon-joined string. Add a `ConsolidateEntries(IReadOnlyList<UserAccessEntry>)` static helper in a shared location (e.g., `UserAccessEntryExtensions`). Toggle stored in `AppSettings` or passed as a flag at export time.
---
## No New NuGet Packages Required (v2.3)
| Feature | What's needed | How provided |
|---|---|---|
| Create app registration | `graphClient.Applications.PostAsync` | Microsoft.Graph 5.74.0 (existing) |
| Create service principal | `graphClient.ServicePrincipals.PostAsync` | Microsoft.Graph 5.74.0 (existing) |
| Delete app registration | `graphClient.Applications[id].DeleteAsync` | Microsoft.Graph 5.74.0 (existing) |
| Grant app role consent | `graphClient.ServicePrincipals[id].AppRoleAssignments.PostAsync` | Microsoft.Graph 5.74.0 (existing) |
| Add site collection admin | `new Tenant(ctx).SetSiteAdmin(...)` | PnP.Framework 1.18.0 (existing) |
| Admin site context | `SessionManager.GetOrCreateAdminContextAsync` — new method, no new lib | PnP.Framework + MSAL (existing) |
| Group member loading | `ctx.Load(spGroup.Users)` + `ExecuteQueryRetry` | PnP.Framework 1.18.0 (existing) |
| HTML group expansion | `<details>/<summary>` HTML5 element | Plain HTML, BCL StringBuilder |
| Consolidation logic | `GroupBy` + LINQ | BCL (.NET 10) |
| Incremental Graph scopes | `MsalTokenProvider` with explicit scopes | MSAL 4.83.3 (existing) |
**Do NOT add:**
| Package | Reason to Skip |
|---------|---------------|
| `Azure.Identity` | App uses `Microsoft.Identity.Client` (MSAL) directly via `BaseBearerTokenAuthenticationProvider`. Azure.Identity would duplicate auth and conflict with the existing PCA + `MsalCacheHelper` token-cache-sharing pattern. |
| PnP Core SDK | Distinct from PnP Framework (CSOM-based). Adding both creates confusion, ~15 MB extra weight, and no benefit since `Tenant.SetSiteAdmin` already exists in PnP.Framework 1.18.0. |
| Any HTML template engine (Razor, Scriban) | StringBuilder pattern is established and sufficient. Template engines add complexity with no gain for server-side HTML generation. |
| SignalR / polling / background service | Auto-ownership is a synchronous, on-demand CSOM call triggered by an access-denied event. No push infrastructure needed. |
---
## Version Bump Consideration (v2.3)
| Package | Current | Latest Stable | Recommendation |
|---------|---------|--------------|----------------|
| `Microsoft.Graph` | 5.74.0 | 5.103.0 | Optional. All new Graph API calls work on 5.74.0. Bump only if a specific bug is encountered. All 5.x versions maintain API compatibility. |
| `PnP.Framework` | 1.18.0 | Check NuGet before bumping | Hold. `Tenant.SetSiteAdmin` works in 1.18.0. PnP Framework version bumps have historically introduced CSOM interop issues. Bump only with explicit testing. |
---
## Existing Stack (Unchanged)
The full stack as validated through v1.1:
The full stack as validated through v2.2:
| Technology | Version | Purpose |
|---|---|---|
@@ -249,13 +436,13 @@ The full stack as validated through v1.1:
| WPF | built-in | UI framework |
| C# 13 | built-in | Language |
| PnP.Framework | 1.18.0 | SharePoint CSOM, provisioning engine |
| Microsoft.Graph | 5.74.0 | Graph API (users, Teams, Groups) |
| Microsoft.Graph | 5.74.0 | Graph API (users, Teams, Groups, app management) |
| Microsoft.Identity.Client (MSAL) | 4.83.3 | Multi-tenant auth, token acquisition |
| Microsoft.Identity.Client.Extensions.Msal | 4.83.3 | Token cache persistence |
| Microsoft.Identity.Client.Broker | 4.82.1 | Windows broker (WAM) |
| CommunityToolkit.Mvvm | 8.4.2 | MVVM base classes, source generators |
| Microsoft.Extensions.Hosting | 10.0.0 | DI container, app lifetime |
| LiveCharts2 (SkiaSharpView.WPF) | 2.0.0-rc5.4 | Storage charts (in use, stable enough) |
| LiveCharts2 (SkiaSharpView.WPF) | 2.0.0-rc5.4 | Storage charts |
| Serilog | 4.3.1 | Structured logging |
| Serilog.Extensions.Hosting | 10.0.0 | ILogger<T> bridge |
| Serilog.Sinks.File | 7.0.0 | Rolling file output |
@@ -268,8 +455,16 @@ The full stack as validated through v1.1:
## Sources
- Microsoft Learn — List users (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0 — permissions, $top max 999, $orderby with ConsistencyLevel, default fields (HIGH confidence, updated 2025-07-23)
- Microsoft Learn — Page through a collection (Graph SDKs): https://learn.microsoft.com/en-us/graph/sdks/paging — PageIterator C# pattern, DirectoryPageTokenNotFoundException warning (HIGH confidence, updated 2025-08-06)
- Microsoft Learn — Get organizationalBranding: https://learn.microsoft.com/en-us/graph/api/organizationalbranding-get?view=graph-rest-1.0 — branding stream retrieval via localizations/default/bannerLogo (HIGH confidence, updated 2025-11-08) — note: tenant branding pull is optional/future, not required for v2.2 which relies on user-supplied logo files
- .NET Perls / BCL docs — Convert.ToBase64String + data URI pattern: confirmed BCL, no library needed (HIGH confidence)
- Existing codebase inspection: GraphClientFactory.cs, GraphUserSearchService.cs, HtmlExportService.cs, UserAccessHtmlExportService.cs, TenantProfile.cs, AppSettings.cs — confirmed exact integration points
**v2.2 sources:**
- Microsoft Learn — List users (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0 — permissions, $top max 999, $orderby with ConsistencyLevel (HIGH confidence)
- Microsoft Learn — Page through a collection (Graph SDKs): https://learn.microsoft.com/en-us/graph/sdks/paging — PageIterator C# pattern (HIGH confidence)
**v2.3 sources:**
- Microsoft Learn — Create application (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/application-post-applications?view=graph-rest-1.0 — C# SDK 5.x pattern confirmed, `Application.ReadWrite.All` required (HIGH confidence, updated 2026-03-14)
- Microsoft Learn — Create servicePrincipal (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/serviceprincipal-post-serviceprincipals?view=graph-rest-1.0 — `Application.ReadWrite.All` required for multi-tenant apps (HIGH confidence, updated 2026-03-14)
- Microsoft Learn — Delete application (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/application-delete?view=graph-rest-1.0 — `graphClient.Applications[id].DeleteAsync()`, 204 response, 30-day soft-delete (HIGH confidence)
- Microsoft Learn — Grant permissions programmatically: https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph — `AppRoleAssignment.ReadWrite.All` for consent grants, C# SDK 5.x examples (HIGH confidence, updated 2026-03-21)
- Microsoft Learn — Choose authentication providers: https://learn.microsoft.com/en-us/graph/sdks/choose-authentication-providers — Interactive provider pattern for desktop apps confirmed (HIGH confidence, updated 2025-08-06)
- PnP Core SDK docs — Security/SetSiteCollectionAdmins: https://pnp.github.io/pnpcore/using-the-sdk/admin-sharepoint-security.html — tenant API bypasses site-level permission check, requires SharePoint Admin role (MEDIUM confidence — PnP Core docs; maps to PnP Framework `Tenant.SetSiteAdmin` behavior)
- Microsoft.Graph NuGet package: https://www.nuget.org/packages/Microsoft.Graph/ — latest stable 5.103.0 confirmed 2026-02-20 (HIGH confidence)
- Codebase — GraphClientFactory.cs, SessionManager.cs, MsalClientFactory.cs — confirmed existing `BaseBearerTokenAuthenticationProvider` + MSAL PCA integration pattern (HIGH confidence, source read directly)
+48
View File
@@ -508,7 +508,55 @@ Based on the combined research, the dependency graph from ARCHITECTURE.md and FE
- ManageEngine SharePoint Manager Plus — https://www.manageengine.com/sharepoint-management-reporting/sharepoint-permission-auditing-tool.html
- AdminDroid SharePoint Online auditing — https://admindroid.com/microsoft-365-sharepoint-online-auditing
---
## v2.3 Tenant Management & Report Enhancements
**Researched:** 2026-04-09
**Confidence:** HIGH
### Stack Additions
**None.** All five features are delivered using existing dependencies:
- `Microsoft.Graph` 5.74.0 — app registration, service principal, admin consent, group member resolution
- `PnP.Framework` 1.18.0 — `Tenant.SetSiteAdmin` for auto-ownership
- BCL .NET 10 — LINQ consolidation, HTML5 `<details>/<summary>`
Do NOT add `Azure.Identity` — conflicts with existing MSAL PCA + MsalCacheHelper pattern.
### Feature Table Stakes
| Feature | Table Stakes | Differentiators |
|---------|-------------|-----------------|
| App Registration | Create app + SP + grant roles; guided fallback mandatory | Auto-detect admin permissions, single-click register |
| App Removal | Delete app + SP, revoke consent | Clear MSAL cache for removed app |
| Auto-Ownership | `Tenant.SetSiteAdmin` on access denied; global toggle OFF by default | Persistent cleanup list, startup warning for pending removals |
| Group Expansion | Resolve members at scan time; HTML5 details/summary | `transitiveMembers` for nested groups; pagination for large groups |
| Report Consolidation | Toggle per-export; merge same-user same-access rows | New `ConsolidatedUserAccessEntry` type (never modify existing) |
### Critical Pitfalls
1. **App registration requires `Application.ReadWrite.All` + `AppRoleAssignment.ReadWrite.All`** — MSP app likely doesn't have these consented. Guided fallback is first-class, not a degraded mode.
2. **`POST /applications` does NOT create service principal** — Must be 3-step atomic: create app → create SP → grant roles, with rollback on failure.
3. **Auto-ownership cleanup**`try/finally` insufficient for hard termination. Need persistent JSON cleanup-pending list + startup warning.
4. **`$expand=members` caps at ~20 silently** — Must use `GET /groups/{id}/transitiveMembers?$top=999` with pagination.
5. **Consolidation is a schema change** — Must be off by default, opt-in per export.
### Suggested Build Order (5 phases, starting at 15)
1. **Phase 15** — Model extensions + PermissionConsolidator (zero API calls, data shapes)
2. **Phase 16** — Report consolidation toggle (first user-visible, pure LINQ)
3. **Phase 17** — Group expansion in HTML reports (Graph at export time, HTML5 details/summary)
4. **Phase 18** — Auto-take ownership (PnP Tenant.SetSiteAdmin, retry once, default OFF)
5. **Phase 19** — App registration + removal (highest blast radius, Entra changes, guided fallback default)
### Research Flags
- **Phase 19:** Admin consent grant appRole GUIDs need validation against real tenant
- **Phase 17:** Confirm `GroupMember.Read.All` scope availability on MSP app registration
---
*v1.0 research completed: 2026-04-02*
*v2.2 research synthesized: 2026-04-08*
*v2.3 research synthesized: 2026-04-09*
*Ready for roadmap: yes*
@@ -348,4 +348,59 @@ public class UserAccessAuditViewModelDirectoryTests
Assert.Single(visible);
Assert.Equal("Alice", visible[0].DisplayName);
}
// ── Test 17: SelectDirectoryUserCommand adds user to SelectedUsers ──────
[Fact]
public void SelectDirectoryUserCommand_adds_user_to_SelectedUsers()
{
var (vm, _, _) = CreateViewModel();
var dirUser = MakeMember("Alice");
vm.SelectDirectoryUserCommand.Execute(dirUser);
Assert.Single(vm.SelectedUsers);
Assert.Equal("Alice", vm.SelectedUsers[0].DisplayName);
Assert.Equal("alice@contoso.com", vm.SelectedUsers[0].UserPrincipalName);
}
// ── Test 18: SelectDirectoryUserCommand skips duplicates ─────────────────
[Fact]
public void SelectDirectoryUserCommand_skips_duplicates()
{
var (vm, _, _) = CreateViewModel();
var dirUser = MakeMember("Alice");
vm.SelectDirectoryUserCommand.Execute(dirUser);
vm.SelectDirectoryUserCommand.Execute(dirUser);
Assert.Single(vm.SelectedUsers);
}
// ── Test 19: SelectDirectoryUserCommand with null does nothing ───────────
[Fact]
public void SelectDirectoryUserCommand_with_null_does_nothing()
{
var (vm, _, _) = CreateViewModel();
vm.SelectDirectoryUserCommand.Execute(null);
Assert.Empty(vm.SelectedUsers);
}
// ── Test 20: After SelectDirectoryUser, user can be audited ──────────────
[Fact]
public void SelectDirectoryUser_adds_auditable_user_to_SelectedUsers()
{
var (vm, _, _) = CreateViewModel();
var dirUser = MakeMember("Alice");
vm.SelectDirectoryUserCommand.Execute(dirUser);
Assert.True(vm.SelectedUsers.Count > 0);
Assert.Equal("alice@contoso.com", vm.SelectedUsers[0].UserPrincipalName);
}
}
@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox.Tests")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+7c7d87d86b7dbd94b1c5591ea880fa33a1ee0827")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+b9511bd2b07dc1b8f7a457602899a3644db4f099")]
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox.Tests")]
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox.Tests")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
f9df09480b479069e5e6ae5f78b859fa720a12b4459d28036dfb96df77d53bef
a6a103bebe57a485c13eef1c486d11ae19b7d31a857b43f59666705dc94a6cdb
@@ -15,7 +15,7 @@ build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = SharepointToolbox.Tests
build_property.ProjectDir = c:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox.Tests\
build_property.ProjectDir = C:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox.Tests\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false
@@ -1 +1 @@
a590f1603da7d8620e6edc276235fbd796db819f8f128515c72d60c0add97067
17b6b482b078d0ca357cbc341151e0b1e20afe20c4b7bd849f6e0f34b62c2c26
@@ -394,4 +394,19 @@
<data name="profile.logo.clear" xml:space="preserve"><value>Effacer</value></data>
<data name="profile.logo.autopull" xml:space="preserve"><value>Importer depuis Entra</value></data>
<data name="profile.logo.nopreview" xml:space="preserve"><value>Aucun logo configur&#233;</value></data>
<!-- Phase 14: Directory Browse UI -->
<data name="audit.mode.search" xml:space="preserve"><value>Recherche</value></data>
<data name="audit.mode.browse" xml:space="preserve"><value>Parcourir l'annuaire</value></data>
<data name="directory.grp.browse" xml:space="preserve"><value>Annuaire utilisateurs</value></data>
<data name="directory.btn.load" xml:space="preserve"><value>Charger l'annuaire</value></data>
<data name="directory.btn.cancel" xml:space="preserve"><value>Annuler</value></data>
<data name="directory.filter.placeholder" xml:space="preserve"><value>Filtrer les utilisateurs...</value></data>
<data name="directory.chk.guests" xml:space="preserve"><value>Inclure les invit&#233;s</value></data>
<data name="directory.status.count" xml:space="preserve"><value>utilisateurs</value></data>
<data name="directory.hint.doubleclick" xml:space="preserve"><value>Double-cliquez sur un utilisateur pour l'ajouter &#224; l'audit</value></data>
<data name="directory.col.name" xml:space="preserve"><value>Nom</value></data>
<data name="directory.col.upn" xml:space="preserve"><value>Courriel</value></data>
<data name="directory.col.department" xml:space="preserve"><value>D&#233;partement</value></data>
<data name="directory.col.jobtitle" xml:space="preserve"><value>Poste</value></data>
<data name="directory.col.type" xml:space="preserve"><value>Type</value></data>
</root>
@@ -394,4 +394,19 @@
<data name="profile.logo.clear" xml:space="preserve"><value>Clear</value></data>
<data name="profile.logo.autopull" xml:space="preserve"><value>Pull from Entra</value></data>
<data name="profile.logo.nopreview" xml:space="preserve"><value>No logo configured</value></data>
<!-- Phase 14: Directory Browse UI -->
<data name="audit.mode.search" xml:space="preserve"><value>Search</value></data>
<data name="audit.mode.browse" xml:space="preserve"><value>Browse Directory</value></data>
<data name="directory.grp.browse" xml:space="preserve"><value>User Directory</value></data>
<data name="directory.btn.load" xml:space="preserve"><value>Load Directory</value></data>
<data name="directory.btn.cancel" xml:space="preserve"><value>Cancel</value></data>
<data name="directory.filter.placeholder" xml:space="preserve"><value>Filter users...</value></data>
<data name="directory.chk.guests" xml:space="preserve"><value>Include guests</value></data>
<data name="directory.status.count" xml:space="preserve"><value>users</value></data>
<data name="directory.hint.doubleclick" xml:space="preserve"><value>Double-click a user to add to audit</value></data>
<data name="directory.col.name" xml:space="preserve"><value>Name</value></data>
<data name="directory.col.upn" xml:space="preserve"><value>Email</value></data>
<data name="directory.col.department" xml:space="preserve"><value>Department</value></data>
<data name="directory.col.jobtitle" xml:space="preserve"><value>Job Title</value></data>
<data name="directory.col.type" xml:space="preserve"><value>Type</value></data>
</root>
@@ -137,6 +137,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
public IAsyncRelayCommand ExportHtmlCommand { get; }
public RelayCommand<GraphUserResult> AddUserCommand { get; }
public RelayCommand<GraphUserResult> RemoveUserCommand { get; }
public RelayCommand<GraphDirectoryUser> SelectDirectoryUserCommand { get; }
public IAsyncRelayCommand LoadDirectoryCommand { get; }
public RelayCommand CancelDirectoryLoadCommand { get; }
@@ -174,6 +175,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
SelectDirectoryUserCommand = new RelayCommand<GraphDirectoryUser>(ExecuteSelectDirectoryUser);
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
@@ -216,6 +218,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
SelectDirectoryUserCommand = new RelayCommand<GraphDirectoryUser>(ExecuteSelectDirectoryUser);
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
@@ -548,6 +551,16 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
SelectedUsers.Remove(user);
}
private void ExecuteSelectDirectoryUser(GraphDirectoryUser? dirUser)
{
if (dirUser == null) return;
var userResult = new GraphUserResult(dirUser.DisplayName, dirUser.UserPrincipalName, dirUser.Mail);
if (!SelectedUsers.Any(u => u.UserPrincipalName == userResult.UserPrincipalName))
{
SelectedUsers.Add(userResult);
}
}
private async Task DebounceSearchAsync(string query, CancellationToken ct)
{
try
@@ -15,8 +15,30 @@
<!-- Left panel -->
<DockPanel Grid.Column="0" Grid.Row="0" Margin="8">
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.users]}"
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
<!-- Mode toggle -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,8">
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.mode.search]}"
IsChecked="{Binding IsBrowseMode, Converter={StaticResource InverseBoolConverter}}"
Margin="0,0,12,0" />
<RadioButton Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.mode.browse]}"
IsChecked="{Binding IsBrowseMode}" />
</StackPanel>
<!-- SEARCH MODE PANEL (visible when IsBrowseMode=false) -->
<GroupBox DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
<GroupBox.Header>
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.users]}" />
</GroupBox.Header>
<GroupBox.Style>
<Style TargetType="GroupBox">
<Setter Property="Visibility" Value="Visible" />
<Style.Triggers>
<DataTrigger Binding="{Binding IsBrowseMode}" Value="True">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
</GroupBox.Style>
<StackPanel>
<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,2" />
<TextBlock Text="Searching..." FontStyle="Italic" FontSize="10" Foreground="Gray" Margin="0,0,0,2">
@@ -57,26 +79,124 @@
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<ItemsControl ItemsSource="{Binding SelectedUsers}" Margin="0,0,0,4">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#EBF5FB" BorderBrush="#2980B9" BorderThickness="1"
CornerRadius="4" Padding="6,2" Margin="0,1">
<DockPanel>
<Button Content="x" DockPanel.Dock="Right" Padding="4,0"
Background="Transparent" BorderThickness="0"
Command="{Binding DataContext.RemoveUserCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}" />
<TextBlock Text="{Binding DisplayName}" VerticalAlignment="Center" />
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding SelectedUsersLabel}" FontStyle="Italic" Foreground="Gray" FontSize="10" />
</StackPanel>
</GroupBox>
<!-- BROWSE MODE PANEL (visible when IsBrowseMode=true) -->
<GroupBox DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8"
Visibility="{Binding IsBrowseMode, Converter={StaticResource BoolToVisibilityConverter}}">
<GroupBox.Header>
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.grp.browse]}" />
</GroupBox.Header>
<DockPanel>
<!-- Load/Cancel buttons -->
<Grid DockPanel.Dock="Top" Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.btn.load]}"
Command="{Binding LoadDirectoryCommand}" Margin="0,0,4,0" Padding="6,3" />
<Button Grid.Column="1"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.btn.cancel]}"
Command="{Binding CancelDirectoryLoadCommand}" Padding="6,3" />
</Grid>
<!-- Include guests checkbox -->
<CheckBox DockPanel.Dock="Top"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.chk.guests]}"
IsChecked="{Binding IncludeGuests}" Margin="0,0,0,4" />
<!-- Filter text -->
<TextBox DockPanel.Dock="Top"
Text="{Binding DirectoryFilterText, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,4" />
<!-- Status row: load status + user count -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="{Binding DirectoryLoadStatus}" FontStyle="Italic" Foreground="Gray" FontSize="10"
Margin="0,0,8,0" />
<TextBlock FontSize="10" Foreground="Gray">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0} {1}">
<Binding Path="DirectoryUserCount" />
<Binding Source="{x:Static loc:TranslationSource.Instance}" Path="[directory.status.count]" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
<!-- Hint text -->
<TextBlock DockPanel.Dock="Bottom"
Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[directory.hint.doubleclick]}"
FontStyle="Italic" Foreground="Gray" FontSize="10" Margin="0,4,0,0"
TextWrapping="Wrap" />
<!-- Directory DataGrid -->
<DataGrid x:Name="DirectoryDataGrid"
ItemsSource="{Binding DirectoryUsersView}"
AutoGenerateColumns="False" IsReadOnly="True"
VirtualizingPanel.IsVirtualizing="True" EnableRowVirtualization="True"
MouseDoubleClick="DirectoryDataGrid_MouseDoubleClick"
CanUserSortColumns="True"
SelectionMode="Single" SelectionUnit="FullRow"
HeadersVisibility="Column" GridLinesVisibility="Horizontal"
BorderThickness="1" BorderBrush="#DDDDDD">
<DataGrid.Columns>
<DataGridTextColumn Header="Name"
Binding="{Binding DisplayName}" Width="120" />
<DataGridTextColumn Header="Email"
Binding="{Binding UserPrincipalName}" Width="140" />
<DataGridTextColumn Header="Department"
Binding="{Binding Department}" Width="90" />
<DataGridTextColumn Header="Job Title"
Binding="{Binding JobTitle}" Width="90" />
<DataGridTemplateColumn Header="Type" Width="60">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding UserType}" VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding UserType}" Value="Guest">
<Setter Property="Foreground" Value="#F39C12" />
<Setter Property="FontWeight" Value="SemiBold" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</GroupBox>
<!-- SHARED: Selected users (visible in both modes) -->
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<ItemsControl ItemsSource="{Binding SelectedUsers}" Margin="0,0,0,4">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#EBF5FB" BorderBrush="#2980B9" BorderThickness="1"
CornerRadius="4" Padding="6,2" Margin="0,1">
<DockPanel>
<Button Content="x" DockPanel.Dock="Right" Padding="4,0"
Background="Transparent" BorderThickness="0"
Command="{Binding DataContext.RemoveUserCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}" />
<TextBlock Text="{Binding DisplayName}" VerticalAlignment="Center" />
</DockPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding SelectedUsersLabel}" FontStyle="Italic" Foreground="Gray" FontSize="10" />
</StackPanel>
<!-- Scan Options (always visible) -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.options]}"
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
<StackPanel>
@@ -89,6 +209,7 @@
</StackPanel>
</GroupBox>
<!-- Run/Export buttons (always visible) -->
<StackPanel DockPanel.Dock="Top">
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
@@ -1,4 +1,5 @@
using System.Windows.Controls;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
using SharepointToolbox.ViewModels.Tabs;
@@ -24,4 +25,14 @@ public partial class UserAccessAuditView : UserControl
listBox.SelectedItem = null;
}
}
private void DirectoryDataGrid_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (sender is DataGrid grid && grid.SelectedItem is GraphDirectoryUser user)
{
var vm = (UserAccessAuditViewModel)DataContext;
if (vm.SelectDirectoryUserCommand.CanExecute(user))
vm.SelectDirectoryUserCommand.Execute(user);
}
}
}
@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+7c7d87d86b7dbd94b1c5591ea880fa33a1ee0827")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+b9511bd2b07dc1b8f7a457602899a3644db4f099")]
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
@@ -1 +1 @@
72f994ecac20797c56b9c39d5917ad31c134243b0218fc33af11e5587a50ed39
92fb59486a9b4569136d423b674d1545abfe4aa70a8cb949969aac2f7c58c28c
@@ -0,0 +1,25 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("SharepointToolbox.Tests")]
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+e6ba2d8146484ab85e4b74b4640282d051e462e4")]
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
// Generated by the MSBuild WriteCodeFragment class.
@@ -0,0 +1 @@
0659bc7ce6bd4add20a40ec175f2ae2d4690e16312ba96cfa266940f89d22e4e
@@ -0,0 +1,23 @@
is_global = true
build_property.MvvmToolkitEnableINotifyPropertyChangingSupport = true
build_property._MvvmToolkitIsUsingWindowsRuntimePack = false
build_property.CsWinRTComponent =
build_property.CsWinRTAotOptimizerEnabled =
build_property.CsWinRTAotWarningLevel =
build_property.TargetFramework = net10.0-windows
build_property.TargetFrameworkIdentifier = .NETCoreApp
build_property.TargetFrameworkVersion = v10.0
build_property.TargetPlatformMinVersion = 7.0
build_property.UsingMicrosoftNETSdkWeb =
build_property.ProjectTypeGuids =
build_property.InvariantGlobalization =
build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = SharepointToolbox
build_property.ProjectDir = C:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false
build_property.EffectiveAnalysisLevelStyle = 10.0
build_property.EnableCodeStyleSeverity =
@@ -0,0 +1,6 @@
// <auto-generated/>
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
@@ -0,0 +1,25 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("SharepointToolbox.Tests")]
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+381081da18180dea03b0e69a260c69461f68a718")]
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
// Generated by the MSBuild WriteCodeFragment class.
@@ -0,0 +1 @@
3fef37f623bcf5d17978ebf53360e98891ba93a92cd81406e5ed1d76ce4c14b7
@@ -0,0 +1,23 @@
is_global = true
build_property.MvvmToolkitEnableINotifyPropertyChangingSupport = true
build_property._MvvmToolkitIsUsingWindowsRuntimePack = false
build_property.CsWinRTComponent =
build_property.CsWinRTAotOptimizerEnabled =
build_property.CsWinRTAotWarningLevel =
build_property.TargetFramework = net10.0-windows
build_property.TargetFrameworkIdentifier = .NETCoreApp
build_property.TargetFrameworkVersion = v10.0
build_property.TargetPlatformMinVersion = 7.0
build_property.UsingMicrosoftNETSdkWeb =
build_property.ProjectTypeGuids =
build_property.InvariantGlobalization =
build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = SharepointToolbox
build_property.ProjectDir = C:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false
build_property.EffectiveAnalysisLevelStyle = 10.0
build_property.EnableCodeStyleSeverity =
@@ -0,0 +1,6 @@
// <auto-generated/>
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
@@ -0,0 +1,25 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
using System;
using System.Reflection;
[assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("SharepointToolbox.Tests")]
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+f11bfefe52e33fe70c456cb05cac1252b33b077f")]
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
[assembly: System.Runtime.Versioning.TargetPlatformAttribute("Windows7.0")]
[assembly: System.Runtime.Versioning.SupportedOSPlatformAttribute("Windows7.0")]
// Generated by the MSBuild WriteCodeFragment class.
@@ -0,0 +1 @@
97d17a1cb043b978e1963dc14bb6a53e41f858e995ba4228fecd59ae19eb3360
@@ -0,0 +1,23 @@
is_global = true
build_property.MvvmToolkitEnableINotifyPropertyChangingSupport = true
build_property._MvvmToolkitIsUsingWindowsRuntimePack = false
build_property.CsWinRTComponent =
build_property.CsWinRTAotOptimizerEnabled =
build_property.CsWinRTAotWarningLevel =
build_property.TargetFramework = net10.0-windows
build_property.TargetFrameworkIdentifier = .NETCoreApp
build_property.TargetFrameworkVersion = v10.0
build_property.TargetPlatformMinVersion = 7.0
build_property.UsingMicrosoftNETSdkWeb =
build_property.ProjectTypeGuids =
build_property.InvariantGlobalization =
build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = SharepointToolbox
build_property.ProjectDir = c:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false
build_property.EffectiveAnalysisLevelStyle = 10.0
build_property.EnableCodeStyleSeverity =
@@ -0,0 +1,6 @@
// <auto-generated/>
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;
@@ -1,4 +1,4 @@
#pragma checksum "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "CBA7E811798D1605D43A084B6989D797CB13323D"
#pragma checksum "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "0F51C9F8F2735BDED33D928B9D536B5267CB02CA"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
@@ -41,13 +41,21 @@ namespace SharepointToolbox.Views.Tabs {
public partial class UserAccessAuditView : System.Windows.Controls.UserControl, System.Windows.Markup.IComponentConnector {
#line 34 "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml"
#line 56 "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.ListBox SearchResultsListBox;
#line default
#line hidden
#line 137 "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.DataGrid DirectoryDataGrid;
#line default
#line hidden
private bool _contentLoaded;
/// <summary>
@@ -81,9 +89,18 @@ namespace SharepointToolbox.Views.Tabs {
case 1:
this.SearchResultsListBox = ((System.Windows.Controls.ListBox)(target));
#line 37 "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml"
#line 59 "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml"
this.SearchResultsListBox.SelectionChanged += new System.Windows.Controls.SelectionChangedEventHandler(this.SearchResultsListBox_SelectionChanged);
#line default
#line hidden
return;
case 2:
this.DirectoryDataGrid = ((System.Windows.Controls.DataGrid)(target));
#line 141 "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml"
this.DirectoryDataGrid.MouseDoubleClick += new System.Windows.Input.MouseButtonEventHandler(this.DirectoryDataGrid_MouseDoubleClick);
#line default
#line hidden
return;
@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyCopyrightAttribute(" ")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+7c7d87d86b7dbd94b1c5591ea880fa33a1ee0827")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+b9511bd2b07dc1b8f7a457602899a3644db4f099")]
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]