Compare commits
14 Commits
df6f4949a8
...
e3ff27a673
| Author | SHA1 | Date | |
|---|---|---|---|
| e3ff27a673 | |||
| d967a8bb65 | |||
| 4ad5f078c9 | |||
| 853f47c4a6 | |||
| 9318bb494d | |||
| f41dbd333e | |||
| b9511bd2b0 | |||
| febb67ab64 | |||
| 1a1e83cfad | |||
| f11bfefe52 | |||
| d1282cea5d | |||
| e6ba2d8146 | |||
| 381081da18 | |||
| 70e8d121fd |
+45
-18
@@ -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.
|
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
|
## Current State
|
||||||
|
|
||||||
**Shipped:** v1.1 Enhanced Reports (2026-04-08)
|
**Shipped:** v2.2 Report Branding & User Directory (2026-04-09)
|
||||||
**Status:** Active milestone v2.2
|
**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)
|
- 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
|
- 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
|
- 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
|
- 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
|
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)
|
Distribution: 200 MB self-contained EXE (win-x64)
|
||||||
|
LOC: ~16,900 C#
|
||||||
|
|
||||||
## Requirements
|
## 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] 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
|
- [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)
|
- [x] HTML report branding with MSP and client logos (BRAND-01/02/03/04/05/06) — v2.2
|
||||||
- [ ] User directory browse mode in user access audit tab
|
- [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
|
### 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.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
|
- **v1.1 shipped** with enhanced reports: user access audit, simplified permissions, storage charts, global site selection
|
||||||
- **Localization:** 220+ EN/FR keys, full parity verified
|
- **v2.2 shipped** with report branding (logos in HTML exports) and user directory browse mode
|
||||||
- **Architecture:** 120+ C# files + 17 XAML files across Core/Infrastructure/Services/ViewModels/Views layers
|
- **Localization:** 230+ EN/FR keys, full parity verified
|
||||||
|
- **Architecture:** 140+ C# files + 17 XAML files across Core/Infrastructure/Services/ViewModels/Views layers
|
||||||
|
|
||||||
## Constraints
|
## 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 |
|
| 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
@@ -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.
|
**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)
|
- [ ] **APPREG-01**: User can register the app on a target tenant from the profile create/edit dialog
|
||||||
- [x] **BRAND-02**: User can preview the imported MSP logo in settings UI
|
- [ ] **APPREG-02**: App auto-detects if user has Global Admin permissions before attempting registration
|
||||||
- [x] **BRAND-03**: User can import a client logo per tenant profile
|
- [ ] **APPREG-03**: App creates Azure AD application + service principal + grants required permissions atomically (with rollback on failure)
|
||||||
- [x] **BRAND-04**: User can auto-pull client logo from tenant's Entra branding API
|
- [ ] **APPREG-04**: User sees guided fallback instructions when auto-registration is not possible (insufficient permissions)
|
||||||
- [x] **BRAND-05**: All five HTML report types display MSP and client logos in a consistent header
|
- [ ] **APPREG-05**: User can remove the app registration from a target tenant
|
||||||
- [x] **BRAND-06**: Logo import validates format (PNG/JPG) and enforces 512 KB size limit
|
- [ ] **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
|
- [ ] **OWN-01**: User can enable/disable auto-take-ownership in application settings (global toggle, OFF by default)
|
||||||
- [x] **UDIR-02**: User can browse full tenant user directory with pagination (handles 999+ users)
|
- [ ] **OWN-02**: App automatically takes site collection admin ownership when encountering access denied during scans (when toggle is ON)
|
||||||
- [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
|
### Report Enhancements
|
||||||
- [ ] **UDIR-05**: User can select one or more users from directory to run the access audit
|
|
||||||
|
- [ ] **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
|
## Future Requirements
|
||||||
|
|
||||||
### Report Branding (Deferred)
|
### Site Ownership (deferred)
|
||||||
|
|
||||||
- **BRAND-F01**: PDF export with embedded logos
|
- **OWN-03**: Persistent cleanup-pending list tracking sites where ownership was elevated
|
||||||
- **BRAND-F02**: Custom report title/footer text per tenant
|
- **OWN-04**: Startup warning when stale ownership entries exist from previous sessions
|
||||||
|
|
||||||
### User Directory (Deferred)
|
|
||||||
|
|
||||||
- **UDIR-F01**: Session-scoped directory cache (avoid re-fetching on tab switch)
|
|
||||||
- **UDIR-F02**: Export user directory list to CSV
|
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
| Feature | Reason |
|
| Feature | Reason |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| CSV report branding | CSV is data-only format; logos don't apply |
|
| Auto-revoke permissions | Liability risk — read-only auditing tool, not remediation |
|
||||||
| Logo in application title bar | Not a report branding concern; separate UX decision |
|
| Real-time ownership monitoring | Requires background service, beyond scope of desktop tool |
|
||||||
| User directory as standalone tab | Directory browse is a mode within existing user access audit tab |
|
| Group expansion in CSV reports | CSV format doesn't support expandable sections; consolidation covers the dedup need |
|
||||||
| Real-time directory sync | One-time load with manual refresh is sufficient for audit workflows |
|
| Custom permission scope selection for app registration | Fixed scope set covers all Toolbox features; custom scopes add complexity without value |
|
||||||
|
|
||||||
## Traceability
|
## Traceability
|
||||||
|
|
||||||
Which phases cover which requirements. Updated during roadmap creation.
|
|
||||||
|
|
||||||
| Requirement | Phase | Status |
|
| Requirement | Phase | Status |
|
||||||
|-------------|-------|--------|
|
|-------------|-------|--------|
|
||||||
| BRAND-01 | Phase 10 | Complete |
|
| APPREG-01 | Phase 19 | Pending |
|
||||||
| BRAND-03 | Phase 10 | Complete |
|
| APPREG-02 | Phase 19 | Pending |
|
||||||
| BRAND-06 | Phase 10 | Complete |
|
| APPREG-03 | Phase 19 | Pending |
|
||||||
| BRAND-05 | Phase 11 | Complete |
|
| APPREG-04 | Phase 19 | Pending |
|
||||||
| BRAND-04 | Phase 11 | Complete |
|
| APPREG-05 | Phase 19 | Pending |
|
||||||
| BRAND-02 | Phase 12 | Complete |
|
| APPREG-06 | Phase 19 | Pending |
|
||||||
| UDIR-01 | Phase 13 | Complete |
|
| OWN-01 | Phase 18 | Pending |
|
||||||
| UDIR-02 | Phase 13 | Complete |
|
| OWN-02 | Phase 18 | Pending |
|
||||||
| UDIR-03 | Phase 13 | Complete |
|
| RPT-01 | Phase 17 | Pending |
|
||||||
| UDIR-04 | Phase 13 | Complete |
|
| RPT-02 | Phase 17 | Pending |
|
||||||
| UDIR-05 | Phase 14 | Pending |
|
| RPT-03 | Phase 16 | Pending |
|
||||||
|
| RPT-04 | Phase 15 | Pending |
|
||||||
|
|
||||||
**Coverage:**
|
**Coverage:**
|
||||||
- v2.2 requirements: 11 total
|
- v2.3 requirements: 12 total
|
||||||
- Mapped to phases: 11
|
- Mapped to phases: 12
|
||||||
- Unmapped: 0
|
- Unmapped: 0
|
||||||
|
|
||||||
---
|
---
|
||||||
*Requirements defined: 2026-04-08*
|
*Requirements defined: 2026-04-09*
|
||||||
*Last updated: 2026-04-08 after roadmap creation — all 11 requirements mapped to Phases 10-14*
|
*Last updated: 2026-04-09 after roadmap created*
|
||||||
|
|||||||
+70
-73
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
- ✅ **v1.0 MVP** — Phases 1-5 (shipped 2026-04-07) — [archive](milestones/v1.0-ROADMAP.md)
|
- ✅ **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)
|
- ✅ **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
|
## Phases
|
||||||
|
|
||||||
@@ -29,86 +30,81 @@
|
|||||||
|
|
||||||
</details>
|
</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 10: Branding Data Foundation (3/3 plans) — 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 11: HTML Export Branding + ViewModel Integration (4/4 plans) — 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 12: Branding UI Views (3/3 plans) — 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)
|
- [x] Phase 13: User Directory ViewModel (2/2 plans) — completed 2026-04-08
|
||||||
- [ ] **Phase 14: User Directory View** — Toggle panel in UserAccessAuditView, user selection to trigger existing audit pipeline
|
- [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 Details
|
||||||
|
|
||||||
### Phase 10: Branding Data Foundation
|
### Phase 15: Consolidation Data Model
|
||||||
**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.
|
**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 (additive to existing infrastructure)
|
**Depends on**: Nothing (no API calls, no UI dependencies)
|
||||||
**Requirements**: BRAND-01, BRAND-03, BRAND-06
|
**Requirements**: RPT-04
|
||||||
**Success Criteria** (what must be TRUE):
|
**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
|
1. A `ConsolidatedPermissionEntry` model exists that represents a single user's merged access across multiple locations with identical access levels
|
||||||
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
|
2. A `PermissionConsolidator` service accepts a flat list of permission rows and returns a consolidated list where duplicate user+level rows are merged
|
||||||
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
|
3. Consolidation logic has unit test coverage — a known 10-row input with 3 duplicate pairs produces the expected 7-row output
|
||||||
4. `GraphUserDirectoryService.GetUsersAsync` returns all enabled member users for a tenant, following `@odata.nextLink` until exhausted, without truncating at 999
|
4. Existing HTML export services compile and produce identical output when consolidation is not applied (opt-in, defaults off)
|
||||||
**Plans**: 3 plans
|
**Plans**: TBD
|
||||||
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
|
|
||||||
|
|
||||||
### Phase 11: HTML Export Branding + ViewModel Integration
|
### Phase 16: Report Consolidation Toggle
|
||||||
**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.
|
**Goal**: Users can choose to merge duplicate permission entries per export through a toggle in the export settings dialog
|
||||||
**Depends on**: Phase 10
|
**Depends on**: Phase 15
|
||||||
**Requirements**: BRAND-05, BRAND-04
|
**Requirements**: RPT-03
|
||||||
**Success Criteria** (what must be TRUE):
|
**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
|
1. A consolidation toggle is visible in the export settings dialog (or export options panel) and defaults to OFF
|
||||||
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
|
2. When the toggle is OFF, the exported HTML report is byte-for-byte identical to the pre-v2.3 output
|
||||||
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
|
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. SettingsViewModel exposes browse/clear commands for MSP logo; ProfileManagementViewModel exposes browse/clear commands for client logo — both commands are exercisable without opening any View
|
4. The toggle state is remembered for the session (does not reset between exports within the same session)
|
||||||
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**: TBD
|
||||||
**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
|
|
||||||
|
|
||||||
### Phase 12: Branding UI Views
|
### Phase 17: Group Expansion in HTML Reports
|
||||||
**Goal**: Administrators can see, import, preview, and clear logos directly in the Settings and profile management dialogs.
|
**Goal**: Users can expand SharePoint group entries in HTML reports to see the group's members, including members of nested groups
|
||||||
**Depends on**: Phase 11
|
**Depends on**: Phase 16
|
||||||
**Requirements**: BRAND-02, BRAND-04 (view layer for Entra pull)
|
**Requirements**: RPT-01, RPT-02
|
||||||
**Success Criteria** (what must be TRUE):
|
**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
|
1. SharePoint group rows in the HTML report render as expandable — clicking a group name reveals its member list inline
|
||||||
2. Opening a tenant profile dialog shows the client logo section with the same import/preview/clear controls
|
2. Member resolution includes transitive membership: nested groups are recursively resolved so every leaf user is shown
|
||||||
3. Importing a logo via the UI shows the thumbnail preview without requiring an application restart
|
3. Group expansion is triggered at export time via Graph API — the permission scan itself is unchanged
|
||||||
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
|
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**: 3 plans
|
**Plans**: TBD
|
||||||
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)
|
|
||||||
|
|
||||||
### Phase 13: User Directory ViewModel
|
### Phase 18: Auto-Take Ownership
|
||||||
**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.
|
**Goal**: Users can enable automatic site collection admin elevation so that access-denied sites during scans no longer block audit progress
|
||||||
**Depends on**: Phase 10
|
**Depends on**: Phase 15
|
||||||
**Requirements**: UDIR-01, UDIR-02, UDIR-03, UDIR-04
|
**Requirements**: OWN-01, OWN-02
|
||||||
**Success Criteria** (what must be TRUE):
|
**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
|
1. A global "Auto-take ownership on access denied" toggle exists in application settings and defaults to OFF
|
||||||
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
|
2. When the toggle is OFF, access-denied sites produce the same error behavior as before v2.3 (no regression)
|
||||||
3. A "Members only / Include guests" toggle filters the displayed list in-memory without issuing a new Graph request
|
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. 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`
|
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**: 2 plans
|
**Plans**: TBD
|
||||||
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)
|
|
||||||
|
|
||||||
### Phase 14: User Directory View
|
### Phase 19: App Registration & Removal
|
||||||
**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.
|
**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 13
|
**Depends on**: Phase 18
|
||||||
**Requirements**: UDIR-05, UDIR-01 (view layer)
|
**Requirements**: APPREG-01, APPREG-02, APPREG-03, APPREG-04, APPREG-05, APPREG-06
|
||||||
**Success Criteria** (what must be TRUE):
|
**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
|
1. A "Register App" action is available in the profile create/edit dialog and is the recommended path for new tenant onboarding
|
||||||
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
|
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. 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
|
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. When the directory load is cancelled or fails, the panel returns to a ready state with a clear status message and no broken UI
|
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
|
**Plans**: TBD
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
@@ -117,8 +113,9 @@ Plans:
|
|||||||
|-------|-----------|-------|--------|-----------|
|
|-------|-----------|-------|--------|-----------|
|
||||||
| 1-5 | v1.0 | 36/36 | Shipped | 2026-04-07 |
|
| 1-5 | v1.0 | 36/36 | Shipped | 2026-04-07 |
|
||||||
| 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 |
|
| 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 |
|
||||||
| 10. Branding Data Foundation | v2.2 | 3/3 | Complete | 2026-04-08 |
|
| 10-14 | v2.2 | 14/14 | Shipped | 2026-04-09 |
|
||||||
| 11. HTML Export Branding + ViewModel Integration | 4/4 | Complete | 2026-04-08 | — |
|
| 15. Consolidation Data Model | v2.3 | 0/? | Not started | — |
|
||||||
| 12. Branding UI Views | 3/3 | Complete | 2026-04-08 | — |
|
| 16. Report Consolidation Toggle | v2.3 | 0/? | Not started | — |
|
||||||
| 13. User Directory ViewModel | 2/2 | Complete | 2026-04-08 | — |
|
| 17. Group Expansion in HTML Reports | v2.3 | 0/? | Not started | — |
|
||||||
| 14. User Directory View | v2.2 | 0/? | Not started | — |
|
| 18. Auto-Take Ownership | v2.3 | 0/? | Not started | — |
|
||||||
|
| 19. App Registration & Removal | v2.3 | 0/? | Not started | — |
|
||||||
|
|||||||
+42
-55
@@ -1,83 +1,70 @@
|
|||||||
---
|
---
|
||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v2.2
|
milestone: v2.3
|
||||||
milestone_name: Report Branding & User Directory
|
milestone_name: Tenant Management & Report Enhancements
|
||||||
status: completed
|
status: roadmap-ready
|
||||||
stopped_at: Completed 13-02-PLAN.md
|
stopped_at: roadmap created — ready for phase 15 planning
|
||||||
last_updated: "2026-04-08T14:08:49.579Z"
|
last_updated: "2026-04-09"
|
||||||
last_activity: 2026-04-08 — Phase 11 planning completed
|
last_activity: 2026-04-09 — Roadmap created for v2.3 (phases 15-19)
|
||||||
progress:
|
progress:
|
||||||
total_phases: 5
|
total_phases: 5
|
||||||
completed_phases: 4
|
completed_phases: 0
|
||||||
total_plans: 12
|
total_plans: 0
|
||||||
completed_plans: 12
|
completed_plans: 0
|
||||||
---
|
---
|
||||||
|
|
||||||
# Project State
|
# Project State
|
||||||
|
|
||||||
## Project Reference
|
## 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.
|
**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
|
## Current Position
|
||||||
|
|
||||||
Phase: 11 (planned, ready to execute)
|
Phase: 15 — Consolidation Data Model (not started)
|
||||||
Plan: 4 plans (11-01 through 11-04) in 3 waves
|
Plan: —
|
||||||
Status: Phase 10 complete, Phase 11 planned — ready to execute
|
Status: Roadmap approved — ready to plan Phase 15
|
||||||
Last activity: 2026-04-08 — Phase 11 planning completed
|
Last activity: 2026-04-09 — Roadmap 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
|
## Accumulated Context
|
||||||
|
|
||||||
### Decisions
|
### Decisions
|
||||||
|
|
||||||
Decisions are logged in PROJECT.md Key Decisions table.
|
Decisions are logged in PROJECT.md Key Decisions table.
|
||||||
|
|
||||||
**v2.2 architectural decisions (locked at roadmap):**
|
**v2.3 notable constraints:**
|
||||||
- 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.
|
- Phase 19 has the highest blast radius (Entra changes) — must be last
|
||||||
- Client logo lives on `TenantProfile`, NOT in `BrandingSettings`. Per-tenant ownership; prevents serialization and deletion awkwardness.
|
- Phase 15 is zero-API-call foundation; unblocks Phase 16 (consolidation) and Phase 18 (ownership) independently
|
||||||
- Export services use optional `ReportBranding? branding = null` parameter. All existing call sites compile unchanged. No new `IHtmlExportService` interface needed.
|
- Group expansion (Phase 17) calls Graph at export time, not at scan time — scan pipeline unchanged
|
||||||
- `GraphUserDirectoryService` is a new service, separate from `GraphUserSearchService`. Different pagination model (`PageIterator`), different cancellation needs.
|
- Auto-take ownership uses PnP `Tenant.SetSiteAdmin` — requires Tenant Admin scope
|
||||||
- Directory does NOT load automatically on tab open. Explicit "Load Directory" button required to avoid blocking UI on large tenants.
|
- App registration must be atomic with rollback; partial Entra state is worse than no state
|
||||||
- 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)
|
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
- Confirm `$filter=accountEnabled eq true and userType eq 'Member'` behavior without `ConsistencyLevel: eventual` against a real tenant before Phase 13 planning.
|
None.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Blockers/Concerns
|
### Blockers/Concerns
|
||||||
|
|
||||||
@@ -85,7 +72,7 @@ None.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-04-08T14:08:49.577Z
|
Last session: 2026-04-09
|
||||||
Stopped at: Completed 13-02-PLAN.md
|
Stopped at: Roadmap created — ready to plan Phase 15
|
||||||
Resume file: None
|
Resume file: None
|
||||||
Next step: `/gsd:execute-phase 11`
|
Next step: `/gsd:plan-phase 15`
|
||||||
|
|||||||
@@ -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*
|
||||||
@@ -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
@@ -1,443 +1,438 @@
|
|||||||
# Architecture Patterns
|
# Architecture Patterns
|
||||||
|
|
||||||
**Domain:** C#/WPF MVVM desktop app — SharePoint Online MSP admin tool
|
**Project:** SharePoint Toolbox v2.3 — Tenant Management & Report Enhancements
|
||||||
**Feature scope:** Report branding (MSP/client logos in HTML) + User directory browse mode
|
**Researched:** 2026-04-09
|
||||||
**Researched:** 2026-04-08
|
**Scope:** Integration of four new features into the existing MVVM/DI architecture
|
||||||
**Confidence:** HIGH — based on direct codebase inspection, not assumptions
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Existing Architecture (Baseline)
|
## Existing Architecture (Baseline)
|
||||||
|
|
||||||
|
The app uses a clean layered architecture. Understanding the layers is prerequisite to placing new features correctly.
|
||||||
|
|
||||||
```
|
```
|
||||||
Core/
|
Core/
|
||||||
Models/ — TenantProfile, AppSettings, domain records (all POCOs/records)
|
Models/ — Pure data records and enums (no dependencies)
|
||||||
Messages/ — WeakReferenceMessenger value message types
|
Helpers/ — Static utility methods
|
||||||
Helpers/ — Static utility classes
|
Messages/ — WeakReferenceMessenger message types
|
||||||
|
|
||||||
Infrastructure/
|
Infrastructure/
|
||||||
Auth/ — MsalClientFactory, GraphClientFactory (MSAL PCA per-tenant + Graph SDK bridge)
|
Auth/ — MsalClientFactory, GraphClientFactory, SessionManager wiring
|
||||||
Persistence/ — ProfileRepository, SettingsRepository, TemplateRepository (JSON, atomic write-then-replace)
|
Persistence/ — JSON-backed repositories (ProfileRepository, BrandingRepository, etc.)
|
||||||
Logging/ — LogPanelSink (Serilog sink to in-app RichTextBox)
|
|
||||||
|
|
||||||
Services/
|
Services/
|
||||||
Export/ — Concrete HTML/CSV export services per domain (no interface, consumed directly)
|
*.cs — Interface + implementation pairs (feature business logic)
|
||||||
*.cs — Domain services with IXxx interfaces
|
Export/ — HTML and CSV export services per feature area
|
||||||
|
|
||||||
ViewModels/
|
ViewModels/
|
||||||
FeatureViewModelBase.cs — Abstract base: RunCommand, CancelCommand, ProgressValue, StatusMessage,
|
FeatureViewModelBase — Abstract base: RunCommand, CancelCommand, progress, WeakReferenceMessenger
|
||||||
GlobalSites, WeakReferenceMessenger registration
|
Tabs/ — One ViewModel per tab
|
||||||
MainWindowViewModel.cs — Toolbar: tenant picker, Connect, global site picker, broadcasts TenantSwitchedMessage
|
ProfileManagementViewModel — Tenant profile CRUD + logo management
|
||||||
Tabs/ — One ViewModel per tab, all extend FeatureViewModelBase
|
|
||||||
ProfileManagementViewModel.cs — Profile CRUD dialog VM
|
|
||||||
|
|
||||||
Views/
|
Views/
|
||||||
Dialogs/ — ProfileManagementDialog, SitePickerDialog, ConfirmBulkOperationDialog, FolderBrowserDialog
|
Tabs/ — XAML views, pure DataBinding
|
||||||
Tabs/ — One UserControl per tab (XAML + code-behind)
|
Dialogs/ — Modal dialogs (ProfileManagementDialog, SitePickerDialog, etc.)
|
||||||
|
|
||||||
App.xaml.cs — Generic Host IServiceCollection DI registration for all layers
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Patterns Already Established
|
### Key Architectural Invariants (must not be broken)
|
||||||
|
|
||||||
| Pattern | How It Works |
|
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.
|
||||||
| Tenant switching | `MainWindowViewModel.OnSelectedProfileChanged` broadcasts `TenantSwitchedMessage` via `WeakReferenceMessenger`; each tab VM overrides `OnTenantSwitched(profile)` |
|
3. **FeatureViewModelBase** provides RunCommand/CancelCommand/progress wiring. All tab VMs extend it.
|
||||||
| Global site propagation | `GlobalSitesChangedMessage` received in `FeatureViewModelBase.OnGlobalSitesReceived` |
|
4. **WeakReferenceMessenger** carries cross-cutting signals: `TenantSwitchedMessage`, `GlobalSitesChangedMessage`. VMs react in `OnTenantSwitched` / `OnGlobalSitesChanged`.
|
||||||
| 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. |
|
5. **BulkOperationRunner.RunAsync** is the shared continue-on-error runner for all multi-item operations.
|
||||||
| JSON persistence | Repository pattern: constructor takes `string filePath`, atomic write via `.tmp` + round-trip JSON validation before `File.Move`, `SemaphoreSlim` write lock. |
|
6. **HTML export services** are independent per-feature classes under `Services/Export/`; they receive `ReportBranding?` and call `BrandingHtmlHelper.BuildBrandingHeader()`.
|
||||||
| DI registration | All in `App.xaml.cs RegisterServices()`. Export services and ViewModels are `AddTransient`; shared infrastructure is `AddSingleton`. |
|
7. **DI registration** is in `App.xaml.cs → RegisterServices`. New services register there.
|
||||||
| Dialog factory | View code-behind sets `ViewModel.OpenXxxDialog = () => new XxxDialog(...)` — keeps dialogs out of ViewModel layer |
|
|
||||||
| People-picker search | `IGraphUserSearchService.SearchUsersAsync(clientId, query, maxResults, ct)` calls Graph `/users?$filter=startsWith(...)` with `ConsistencyLevel: eventual` |
|
|
||||||
| Test constructor | `UserAccessAuditViewModel` has a `internal` 3-param constructor without export services — test pattern to replicate for new injections |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature 1: Report Branding (MSP/Client Logos in HTML Reports)
|
## 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
|
### Graph API Constraint (HIGH confidence)
|
||||||
- **Client logo** — one image per tenant, shown in reports for that tenant only
|
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:
|
||||||
- **Storage** — base64-encoded strings in JSON (no separate image files — preserves atomic save semantics and single-data-folder design)
|
|
||||||
- **Embedding** — `data:image/...;base64,...` `<img>` tag injected into the HTML header (maintains self-contained HTML invariant — zero external file references)
|
|
||||||
- **User action** — file picker → read bytes → detect MIME type → convert to base64 → store in JSON → preview in UI
|
|
||||||
|
|
||||||
### New Components (create from scratch)
|
- **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
|
```csharp
|
||||||
public class BrandingSettings
|
// Core/Models/AppRegistrationResult.cs
|
||||||
{
|
public record AppRegistrationResult(
|
||||||
public string? MspLogoBase64 { get; set; }
|
bool Success,
|
||||||
public string? MspLogoMimeType { get; set; } // "image/png", "image/jpeg", etc.
|
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
|
```csharp
|
||||||
public record ReportBranding(
|
services.AddTransient<IAppRegistrationService, AppRegistrationService>();
|
||||||
string? MspLogoBase64,
|
|
||||||
string? MspLogoMimeType,
|
|
||||||
string? ClientLogoBase64,
|
|
||||||
string? ClientLogoMimeType);
|
|
||||||
```
|
```
|
||||||
Lightweight data transfer record assembled at export time from BrandingSettings + current TenantProfile. Not persisted directly — constructed on demand.
|
|
||||||
|
|
||||||
**`Infrastructure/Persistence/BrandingRepository.cs`**
|
`ProfileManagementViewModel` registration remains `AddTransient`; the new interface is added to its constructor.
|
||||||
Same pattern as `SettingsRepository`: constructor takes `string filePath`, atomic write-then-replace, `SemaphoreSlim` write lock. File: `%AppData%\SharepointToolbox\branding.json`.
|
|
||||||
|
|
||||||
**`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
|
```csharp
|
||||||
public class BrandingService
|
var tenant = new Tenant(adminCtx);
|
||||||
{
|
tenant.SetSiteAdmin(siteUrl, loginName, isAdmin: true);
|
||||||
public Task<BrandingSettings> GetBrandingAsync();
|
adminCtx.ExecuteQueryAsync();
|
||||||
public Task SetMspLogoAsync(string filePath); // reads file, detects MIME, converts to base64, saves
|
|
||||||
public Task ClearMspLogoAsync();
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
Thin orchestration, same pattern as `SettingsService`. MSP logo only — client logo is managed via `ProfileService` (it belongs to `TenantProfile`).
|
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
|
```csharp
|
||||||
public string? ClientLogoBase64 { get; set; }
|
// Core/Models/AppSettings.cs — ADD property
|
||||||
public string? ClientLogoMimeType { get; set; }
|
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
|
```html
|
||||||
<div class="brand-header" style="display:flex;align-items:center;gap:16px;padding:16px 24px 0;">
|
<details class="group-expand">
|
||||||
<!-- only rendered if logo present -->
|
<summary class="user-pill group-pill">Members Group Name</summary>
|
||||||
<img src="data:{mimeType};base64,{base64}" style="max-height:60px;max-width:200px;" alt="MSP" />
|
<div class="group-members">
|
||||||
<img src="data:{mimeType};base64,{base64}" style="max-height:60px;max-width:200px;" alt="Client" />
|
<span class="user-pill">alice@contoso.com</span>
|
||||||
</div>
|
<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/`):
|
### ViewModel Changes: `PermissionsViewModel`
|
||||||
- `HtmlExportService` (two `BuildHtml` overloads — `PermissionEntry` and `SimplifiedPermissionEntry`)
|
|
||||||
- `UserAccessHtmlExportService`
|
|
||||||
- `StorageHtmlExportService` (two `BuildHtml` overloads — with and without `FileTypeMetric`)
|
|
||||||
- `SearchHtmlExportService`
|
|
||||||
- `DuplicatesHtmlExportService`
|
|
||||||
|
|
||||||
**ViewModels that call HTML export** — All `ExportHtmlAsync` methods need to resolve branding before calling the export service. The ViewModel calls `BrandingService.GetBrandingAsync()` and reads `_currentProfile.ClientLogoBase64` to assemble a `ReportBranding`, then passes it to `BuildHtml`.
|
Add `ExpandGroupMembers` observable bool. Include in `ScanOptions` construction in `RunOperationAsync`. Add checkbox to `PermissionsView.xaml`.
|
||||||
|
|
||||||
Affected ViewModels: `PermissionsViewModel`, `UserAccessAuditViewModel`, `StorageViewModel`, `SearchViewModel`, `DuplicatesViewModel`. Each gets `BrandingService` injected via constructor.
|
|
||||||
|
|
||||||
**`ViewModels/Tabs/SettingsViewModel.cs`** — Add MSP logo management:
|
|
||||||
```csharp
|
|
||||||
[ObservableProperty] private string? _mspLogoPreviewBase64;
|
|
||||||
public RelayCommand BrowseMspLogoCommand { get; }
|
|
||||||
public RelayCommand ClearMspLogoCommand { get; }
|
|
||||||
```
|
|
||||||
On browse: open `OpenFileDialog` (filter: PNG, JPG, GIF) → call `BrandingService.SetMspLogoAsync(path)` → reload and refresh `MspLogoPreviewBase64`.
|
|
||||||
|
|
||||||
**`Views/Tabs/SettingsView.xaml`** — Add a "Report Branding — MSP Logo" section:
|
|
||||||
- `<Image>` bound to `MspLogoPreviewBase64` via a base64-to-BitmapSource converter
|
|
||||||
- "Browse Logo" button → `BrowseMspLogoCommand`
|
|
||||||
- "Clear" button → `ClearMspLogoCommand`
|
|
||||||
- Note label: "Applies to all reports"
|
|
||||||
|
|
||||||
**Client logo placement:** Client logo belongs to a `TenantProfile`, not to global settings. The natural place to manage it is `ProfileManagementDialog` (already handles profile CRUD). Add logo fields there rather than in SettingsView.
|
|
||||||
|
|
||||||
**`ViewModels/ProfileManagementViewModel.cs`** — Add client logo management per profile:
|
|
||||||
```csharp
|
|
||||||
[ObservableProperty] private string? _clientLogoPreviewBase64;
|
|
||||||
public RelayCommand BrowseClientLogoCommand { get; }
|
|
||||||
public RelayCommand ClearClientLogoCommand { get; }
|
|
||||||
```
|
|
||||||
On browse: read image bytes → base64 → set on the being-edited `TenantProfile` object before saving. Uses `ProfileService.AddProfileAsync` / rename pipeline that already exists.
|
|
||||||
|
|
||||||
**`Views/Dialogs/ProfileManagementDialog.xaml`** — Add client logo fields to the add/edit profile form (same pattern as SettingsView branding section).
|
|
||||||
|
|
||||||
### Data Flow: Report Branding
|
|
||||||
|
|
||||||
```
|
|
||||||
User picks MSP logo (SettingsView "Browse Logo" button)
|
|
||||||
→ SettingsViewModel.BrowseMspLogoCommand
|
|
||||||
→ OpenFileDialog in View code-behind or VM (follow existing BrowseFolder pattern)
|
|
||||||
→ BrandingService.SetMspLogoAsync(path)
|
|
||||||
→ File.ReadAllBytesAsync → Convert.ToBase64String
|
|
||||||
→ detect MIME from extension (.png → image/png, .jpg/.jpeg → image/jpeg, .gif → image/gif)
|
|
||||||
→ BrandingRepository.SaveAsync(BrandingSettings)
|
|
||||||
→ ViewModel refreshes MspLogoPreviewBase64
|
|
||||||
|
|
||||||
User runs export (e.g. ExportHtmlCommand in UserAccessAuditViewModel)
|
|
||||||
→ BrandingService.GetBrandingAsync() → BrandingSettings
|
|
||||||
→ reads _currentProfile.ClientLogoBase64, _currentProfile.ClientLogoMimeType
|
|
||||||
→ new ReportBranding(mspBase64, mspMime, clientBase64, clientMime)
|
|
||||||
→ UserAccessHtmlExportService.BuildHtml(entries, branding)
|
|
||||||
→ injects <img> data URIs in header when base64 is non-null
|
|
||||||
→ writes HTML file
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature 2: User Directory Browse Mode
|
## 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
|
```csharp
|
||||||
public record PagedUserResult(
|
// Core/Helpers/PermissionConsolidator.cs
|
||||||
IReadOnlyList<GraphUserResult> Users,
|
public static class PermissionConsolidator
|
||||||
string? NextPageToken); // null = last page
|
|
||||||
```
|
|
||||||
|
|
||||||
**`Services/IGraphUserDirectoryService.cs`**
|
|
||||||
```csharp
|
|
||||||
public interface IGraphUserDirectoryService
|
|
||||||
{
|
{
|
||||||
Task<PagedUserResult> GetUsersPageAsync(
|
public static IReadOnlyList<PermissionEntry> Consolidate(
|
||||||
string clientId,
|
IReadOnlyList<PermissionEntry> entries);
|
||||||
string? filter = null,
|
|
||||||
string? pageToken = null,
|
|
||||||
int pageSize = 100,
|
|
||||||
CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**`Services/GraphUserDirectoryService.cs`**
|
**Consolidation key:** `(ObjectType, Title, Url, UserLogin)` — one row per (object, user) pair across all login tokens in a semicolon-delimited `UserLogins` field.
|
||||||
Reuses `GraphClientFactory` (already injected elsewhere). Calls `graphClient.Users.GetAsync()` without the `startsWith` constraint used in search — uses `$top=100` with cursor-based paging via Graph's `@odata.nextLink`. Returns `PagedUserResult` so callers control pagination. Uses `ConsistencyLevel: eventual` + `$count=true` (same as existing search service).
|
|
||||||
|
|
||||||
### Modified Components
|
**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
|
```csharp
|
||||||
[ObservableProperty] private bool _isBrowseModeActive;
|
if (ConsolidateEntries)
|
||||||
[ObservableProperty] private ObservableCollection<GraphUserResult> _directoryUsers = new();
|
allEntries = PermissionConsolidator.Consolidate(allEntries).ToList();
|
||||||
[ObservableProperty] private string _directoryFilter = string.Empty;
|
|
||||||
[ObservableProperty] private bool _isLoadingDirectory;
|
|
||||||
[ObservableProperty] private bool _hasMoreDirectoryPages;
|
|
||||||
|
|
||||||
private string? _directoryNextPageToken;
|
|
||||||
|
|
||||||
public IAsyncRelayCommand LoadDirectoryCommand { get; }
|
|
||||||
public IAsyncRelayCommand LoadMoreDirectoryCommand { get; }
|
|
||||||
public RelayCommand<IList<GraphUserResult>> AddDirectoryUsersCommand { get; }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`partial void OnIsBrowseModeActiveChanged(bool value)` → when `value == true`, fire `LoadDirectoryCommand` to populate page 1.
|
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.
|
## Component Dependency Map
|
||||||
|
|
||||||
**`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
|
|
||||||
|
|
||||||
```
|
```
|
||||||
User clicks "Browse" mode toggle
|
NEW COMPONENT DEPENDS ON (existing unless marked new)
|
||||||
→ IsBrowseModeActive = true
|
──────────────────────────────────────────────────────────────────────────
|
||||||
→ OnIsBrowseModeActiveChanged fires LoadDirectoryCommand
|
AppRegistrationResult (model) — none
|
||||||
→ GraphUserDirectoryService.GetUsersPageAsync(clientId, filter: null, pageToken: null, 100, ct)
|
AppSettings.AutoTakeOwnership AppSettings (existing model)
|
||||||
→ Graph GET /users?$select=displayName,userPrincipalName,mail&$top=100&$orderby=displayName
|
ScanOptions.ExpandGroupMembers ScanOptions (existing model)
|
||||||
→ returns PagedUserResult { Users = [...100 items], NextPageToken = "..." }
|
PermissionEntry.GroupMembers PermissionEntry (existing record)
|
||||||
→ DirectoryUsers = new collection of returned users
|
|
||||||
→ HasMoreDirectoryPages = (NextPageToken != null)
|
|
||||||
→ _directoryNextPageToken = returned token
|
|
||||||
|
|
||||||
User types in DirectoryFilter
|
PermissionConsolidator PermissionEntry (existing)
|
||||||
→ debounce 300ms
|
|
||||||
→ LoadDirectoryCommand re-fires with filter
|
|
||||||
→ DirectoryUsers replaced with filtered page 1
|
|
||||||
|
|
||||||
User selects users in ListView + clicks "Add Selected"
|
IAppRegistrationService —
|
||||||
→ AddDirectoryUsersCommand(selectedItems)
|
AppRegistrationService GraphServiceClient (existing via GraphClientFactory)
|
||||||
→ for each: if not already in SelectedUsers by UPN → SelectedUsers.Add(user)
|
Microsoft.Graph SDK (existing)
|
||||||
|
|
||||||
User clicks "Load more"
|
ISiteOwnershipService —
|
||||||
→ LoadMoreDirectoryCommand
|
SiteOwnershipService SessionManager (existing)
|
||||||
→ GetUsersPageAsync(clientId, currentFilter, _directoryNextPageToken, 100, ct)
|
TenantProfile (existing)
|
||||||
→ DirectoryUsers items appended (not replaced)
|
Tenant CSOM class (existing via PnP Framework)
|
||||||
→ _directoryNextPageToken updated
|
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 |
|
### Step 1: Model additions
|
||||||
|-----------|-------|------|---------|
|
No external dependencies. All existing tests continue to pass.
|
||||||
| `BrandingSettings` | Core/Models | class | MSP logo storage (base64 + MIME type) |
|
- `AppRegistrationResult` record (new file)
|
||||||
| `ReportBranding` | Core/Models | record | Data passed to `BuildHtml` overloads at export time |
|
- `AppSettings.AutoTakeOwnership` bool property (default false)
|
||||||
| `BrandingRepository` | Infrastructure/Persistence | class | JSON load/save for `BrandingSettings` |
|
- `ScanOptions.ExpandGroupMembers` bool parameter (default false)
|
||||||
| `BrandingService` | Services | class | Orchestrates logo file read / MIME detect / base64 convert / save |
|
- `PermissionEntry.GroupMembers` optional string parameter (default null)
|
||||||
| `PagedUserResult` | Core/Models | record | Page of `GraphUserResult` items + next-page token |
|
|
||||||
| `IGraphUserDirectoryService` | Services | interface | Contract for paginated tenant user enumeration |
|
|
||||||
| `GraphUserDirectoryService` | Services | class | Graph API user listing with cursor pagination |
|
|
||||||
|
|
||||||
Total new files: 7
|
### 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 |
|
### Step 4: SettingsService extension
|
||||||
|-----------|--------|------|
|
Thin method addition, no structural change.
|
||||||
| `TenantProfile` | + 2 nullable logo props | LOW — JSON backward-compatible |
|
- `SetAutoTakeOwnershipAsync(bool)` on existing `SettingsService`
|
||||||
| `HtmlExportService` | + optional `ReportBranding?` on 2 overloads | LOW — optional param, existing callers unaffected |
|
|
||||||
| `UserAccessHtmlExportService` | + optional `ReportBranding?` | LOW |
|
|
||||||
| `StorageHtmlExportService` | + optional `ReportBranding?` on 2 overloads | LOW |
|
|
||||||
| `SearchHtmlExportService` | + optional `ReportBranding?` | LOW |
|
|
||||||
| `DuplicatesHtmlExportService` | + optional `ReportBranding?` | LOW |
|
|
||||||
| `PermissionsViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
|
|
||||||
| `UserAccessAuditViewModel` | + inject `BrandingService` + `IGraphUserDirectoryService`, browse mode state/commands | MEDIUM |
|
|
||||||
| `StorageViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
|
|
||||||
| `SearchViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
|
|
||||||
| `DuplicatesViewModel` | + inject `BrandingService`, use in ExportHtmlAsync | LOW |
|
|
||||||
| `SettingsViewModel` | + inject `BrandingService`, MSP logo commands + preview property | LOW |
|
|
||||||
| `ProfileManagementViewModel` | + client logo browse/preview/clear | LOW |
|
|
||||||
| `SettingsView.xaml` | + branding section with logo preview + buttons | LOW |
|
|
||||||
| `ProfileManagementDialog.xaml` | + client logo fields | LOW |
|
|
||||||
| `UserAccessAuditView.xaml` | + mode toggle + browse panel in left column | MEDIUM |
|
|
||||||
| `App.xaml.cs RegisterServices()` | + 3 new registrations | LOW |
|
|
||||||
|
|
||||||
Total modified files: 17
|
### 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)
|
**No new tabs. No new XAML files. No new dialog windows required.** All four features extend existing surfaces.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Anti-Patterns to Avoid
|
## Critical Integration Notes
|
||||||
|
|
||||||
### Storing Logo Images as Separate Files
|
### App Registration: Permission Prerequisite
|
||||||
**Why bad:** Breaks the single-data-folder design. Reports become non-self-contained if they reference external paths. Atomic save semantics break.
|
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.
|
||||||
**Instead:** Base64-encode into JSON. Logo thumbnails are typically 10-200KB. Base64 overhead (~33%) is negligible.
|
|
||||||
|
|
||||||
### Adding an `IHtmlExportService` Interface Just for Branding
|
### Auto-Ownership: Retry Once, Not Infinitely
|
||||||
**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.
|
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`.
|
||||||
**Instead:** Add `ReportBranding? branding = null` as optional parameter. Existing callers compile unchanged.
|
|
||||||
|
|
||||||
### Loading All Tenant Users at Once
|
### Group Expansion: Scan Performance Impact
|
||||||
**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.
|
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.
|
||||||
**Instead:** `PagedUserResult` pattern — page 1 on mode toggle, "Load more" button, server-side filter applied to DirectoryFilter text.
|
|
||||||
|
|
||||||
### Async in ViewModel Constructor
|
### Consolidation: Records Are Immutable
|
||||||
**Why bad:** DI constructs ViewModels synchronously on the UI thread. Async work in constructors requires fire-and-forget which loses exceptions.
|
`PermissionEntry` is a `record`. `PermissionConsolidator.Consolidate()` produces new record instances — no mutation. Consistent with how `Results` is already replaced wholesale in `PermissionsViewModel`.
|
||||||
**Instead:** `partial void OnIsBrowseModeActiveChanged` fires `LoadDirectoryCommand` when browse mode activates. Constructor only wires up commands and state.
|
|
||||||
|
|
||||||
### Client Logo in `AppSettings` or `BrandingSettings`
|
### HTML `<details>/<summary>` Compatibility
|
||||||
**Why bad:** Client logos are per-tenant. `AppSettings` and `BrandingSettings` are global. Mixing them makes per-profile deletion awkward and serialization structure unclear.
|
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.
|
||||||
**Instead:** `ClientLogoBase64` + `ClientLogoMimeType` directly on `TenantProfile` (serialized in `profiles.json`). MSP logo goes in `branding.json` via `BrandingRepository`.
|
|
||||||
|
|
||||||
### Changing `BuildHtml` Signatures to Required Parameters
|
### No Breaking Changes to Existing Tests
|
||||||
**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.
|
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.
|
||||||
**Instead:** `ReportBranding? branding = null` is optional. Inject only where branding is desired. Existing call sites remain unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scalability Considerations
|
|
||||||
|
|
||||||
| Concern | Impact | Mitigation |
|
|
||||||
|---------|--------|------------|
|
|
||||||
| Logo storage size in JSON | PNG logos base64-encoded: 10-200KB per logo. `profiles.json` grows by at most that per tenant | Acceptable — config files, not bulk data |
|
|
||||||
| HTML report file size | +2-10KB per logo (base64 inline) | Negligible — reports are already 100-500KB |
|
|
||||||
| Directory browse load time | 100-user pages from Graph: ~200-500ms per page | Loading indicator, pagination. Acceptable UX. |
|
|
||||||
| Large tenants (50k+ users) | Full load would take minutes and exceed memory budgets | Pagination via `PagedUserResult` prevents this entirely |
|
|
||||||
| ViewModel constructor overhead | BrandingService adds one lazy JSON read at first export | Not at construction — no startup impact |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sources
|
## 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.
|
- 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
|
||||||
Key files examined:
|
- PnP Core SDK site security (SetSiteCollectionAdmins): https://pnp.github.io/pnpcore/using-the-sdk/admin-sharepoint-security.html
|
||||||
- `Core/Models/TenantProfile.cs`, `AppSettings.cs`
|
- PnP Framework TenantExtensions source: https://github.com/pnp/PnP-Sites-Core/blob/master/Core/OfficeDevPnP.Core/Extensions/TenantExtensions.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`
|
|
||||||
|
|||||||
+452
-129
@@ -1,211 +1,534 @@
|
|||||||
# Feature Landscape
|
# Feature Landscape
|
||||||
|
|
||||||
**Domain:** MSP IT admin desktop tool — SharePoint audit report branding + user directory browse
|
**Domain:** MSP IT admin desktop tool — Tenant Management & Report Enhancements
|
||||||
**Milestone:** v2.2 — Report Branding & User Directory
|
**Milestone:** v2.3
|
||||||
**Researched:** 2026-04-08
|
**Researched:** 2026-04-09
|
||||||
**Overall confidence:** HIGH (verified via official Graph API docs + direct codebase inspection)
|
**Overall confidence:** HIGH (verified via official Graph API docs, PnP docs, and direct codebase inspection)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Scope Boundary
|
## Scope Boundary
|
||||||
|
|
||||||
This file covers only the two net-new features in v2.2:
|
This file covers only the five net-new features in v2.3:
|
||||||
1. HTML report branding (MSP logo + client logo per tenant)
|
|
||||||
2. User directory browse mode in the user access audit tab
|
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.
|
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
|
### 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 |
|
| 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 |
|
| 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 |
|
||||||
| 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 |
|
| 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 |
|
||||||
| 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=` |
|
| 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` |
|
||||||
| 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 |
|
| 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 |
|
||||||
| Consistent placement across all HTML export types | App already ships 5+ HTML exporters; logos must appear in all of them | Medium | Extract a shared header-builder method or inject a branding context into each export service |
|
|
||||||
|
|
||||||
### Differentiators
|
### Differentiators
|
||||||
|
|
||||||
Features not expected by default, but add meaningful value once table stakes are covered.
|
|
||||||
|
|
||||||
| Feature | Value Proposition | Complexity | Notes |
|
| 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. |
|
| 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`) |
|
||||||
| 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 |
|
| 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
|
### Anti-Features
|
||||||
|
|
||||||
Do not build these. They add scope without proportionate MSP value.
|
|
||||||
|
|
||||||
| Anti-Feature | Why Avoid | What to Do Instead |
|
| 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 |
|
| 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 |
|
||||||
| 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 |
|
| Certificate-based credential management | Cert lifecycle (expiry, rotation) is out of scope for an MSP admin tool | Interactive user auth handles token refresh automatically |
|
||||||
| 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 |
|
| 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 |
|
||||||
| Logo URL field (hotlinked) | Reports break when URL becomes unavailable; creates external dependency for a local-first tool | Force file import with base64 embedding |
|
|
||||||
|
|
||||||
### Feature Dependencies
|
### Feature Dependencies
|
||||||
|
|
||||||
```
|
```
|
||||||
AppSettings + MspLogoBase64 (string?, nullable)
|
Existing:
|
||||||
TenantProfile + ClientLogoBase64 (string?, nullable)
|
GraphClientFactory → provides authenticated GraphServiceClient for target tenant
|
||||||
+ ClientLogoSource (enum: None | Imported | AutoPulled)
|
TenantProfile.ClientId → stores the resulting appId after registration
|
||||||
Shared branding helper → called by HtmlExportService, UserAccessHtmlExportService,
|
ProfileManagementDialog → hosts the registration trigger button
|
||||||
StorageHtmlExportService, DuplicatesHtmlExportService,
|
OperationProgress → used for per-step status display
|
||||||
SearchHtmlExportService
|
|
||||||
Auto-pull code path → Graph API call via existing GraphClientFactory
|
New:
|
||||||
Logo import UI → WPF OpenFileDialog -> File.ReadAllBytes -> Convert.ToBase64String
|
IAppRegistrationService / AppRegistrationService
|
||||||
-> stored in profile JSON via existing ProfileRepository
|
→ 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
|
**Key existing code note:** GraphClientFactory already acquires delegated tokens with the
|
||||||
with no shared header. Branding requires one of:
|
tenant's registered clientId. For the registration flow specifically, the app needs a token
|
||||||
- (a) a `ReportBrandingContext` record passed into each exporter's `BuildHtml` method, or
|
scoped to the *management* tenant (where Entra lives), not just SharePoint/Graph read scopes.
|
||||||
- (b) a `HtmlReportHeaderBuilder` static/injectable helper all exporters call.
|
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
|
||||||
Option (b) is lower risk — it does not change method signatures that existing unit tests already call.
|
trigger a new consent prompt if not previously consented.
|
||||||
|
|
||||||
### Complexity Assessment
|
### Complexity Assessment
|
||||||
|
|
||||||
| Sub-task | Complexity | Reason |
|
| Sub-task | Complexity | Reason |
|
||||||
|----------|------------|--------|
|
|----------|------------|--------|
|
||||||
| AppSettings + TenantProfile model field additions | Low | Trivial nullable-string fields; JSON serialization already in place |
|
| POST /applications + POST /servicePrincipals | Medium | Two sequential calls; error handling at each step |
|
||||||
| Settings UI: MSP logo upload + preview | Low | WPF OpenFileDialog + BitmapImage from base64, standard pattern |
|
| Grant admin consent (appRoleAssignment per permission) | High | Must look up resource SP IDs, match appRole GUIDs by permission name; 4-6 role assignments needed |
|
||||||
| ProfileManagementDialog: client logo upload per tenant | Low | Same pattern as MSP logo |
|
| TenantProfile.ClientId persistence after registration | Low | Existing JSON serialization; add one field |
|
||||||
| Shared HTML header builder with logo injection | Low-Medium | One helper; replaces duplicated header HTML in 5 exporters |
|
| UI: Register button in ProfileManagementDialog | Low | Button + status label; hooks into existing async command pattern |
|
||||||
| Auto-pull from Entra `bannerLogo` endpoint | Medium | Async Graph call; must handle 404, empty stream, no branding configured |
|
| Guided fallback modal | Medium | Error detection logic + WPF dialog with instructional content |
|
||||||
| Localization keys EN/FR for new labels | Low | ~6-10 new keys; 220+ already managed |
|
| 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
|
### Table Stakes
|
||||||
|
|
||||||
Features an admin expects when a "browse all users" mode is offered alongside the existing search.
|
|
||||||
|
|
||||||
| Feature | Why Expected | Complexity | Notes |
|
| 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 |
|
| 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} |
|
||||||
| 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 |
|
| 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 |
|
||||||
| Sortable columns (Name, UPN) | Standard expectation for any directory table | Low | WPF DataGrid column sorting, already used in other tabs |
|
| Graceful handling when app no longer exists | Re-run, manual deletion in portal, or already removed | Low | Handle 404 as success (idempotent delete) |
|
||||||
| 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 |
|
|
||||||
|
|
||||||
### Anti-Features
|
### Anti-Features
|
||||||
|
|
||||||
| Anti-Feature | Why Avoid | What to Do Instead |
|
| 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 |
|
| 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) |
|
||||||
| 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 |
|
| Auto-remove on profile deletion without prompt | Silent data destruction is never acceptable | Always require explicit user confirmation |
|
||||||
| Multi-user bulk select for simultaneous audit | The audit pipeline is per-user by design; multi-user requires a fundamentally different results model | Out of scope; single-user selection only |
|
|
||||||
| Export the user directory to CSV | That is an identity reporting feature (AdminDroid et al.), not an access audit feature | Out of scope for this milestone |
|
|
||||||
| Show disabled accounts by default | Disabled users do not have active SharePoint access; pollutes the list for audit purposes | Default `$filter=accountEnabled eq true`; optionally expose a toggle |
|
|
||||||
|
|
||||||
### Feature Dependencies
|
### Feature Dependencies
|
||||||
|
|
||||||
```
|
```
|
||||||
New IGraphDirectoryService + GraphDirectoryService
|
Existing:
|
||||||
→ GET /users?$select=displayName,userPrincipalName,mail,jobTitle,department,userType
|
AppRegistrationService (from Feature 1)
|
||||||
&$filter=accountEnabled eq true
|
+ RemoveApplicationAsync(clientId) : Task
|
||||||
&$top=100
|
→ GET /applications?$filter=appId eq '{clientId}' → resolve object ID
|
||||||
→ Follow @odata.nextLink in a loop until null
|
→ DELETE /applications/{objectId}
|
||||||
→ Uses existing GraphClientFactory (DI, unchanged)
|
|
||||||
|
|
||||||
UserAccessAuditViewModel additions:
|
ProfileManagementDialog
|
||||||
+ IsBrowseMode (bool property, toggle)
|
→ Remove Registration button (separate from Delete Profile)
|
||||||
+ DirectoryUsers (ObservableCollection<GraphUserResult> or new DirectoryUserEntry model)
|
→ or confirmation step during profile deletion flow
|
||||||
+ 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**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
|
### Complexity Assessment
|
||||||
|
|
||||||
| Sub-task | Complexity | Reason |
|
| Sub-task | Complexity | Reason |
|
||||||
|----------|------------|--------|
|
|----------|------------|--------|
|
||||||
| IGraphDirectoryService + GraphDirectoryService (pagination loop) | Low-Medium | Standard Graph paging; same GraphClientFactory in DI |
|
| RemoveApplicationAsync (resolve then delete) | Low-Medium | Two calls; 404 idempotency handling |
|
||||||
| ViewModel additions (browse toggle, load command, filter, loading state) | Medium | New async command with progress, cancellation on tenant switch |
|
| Confirmation dialog | Low | Reuse existing ConfirmationDialog pattern |
|
||||||
| View XAML: toggle + browse DataGrid panel | Medium | Visibility-toggle two panels; DataGrid column definitions |
|
| Wire into profile deletion flow | Low | Existing profile delete command; add optional app removal step |
|
||||||
| 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 |
|
## Feature 3: Auto-Take Ownership on Access Denied (Global Toggle)
|
||||||
| Unit tests for ViewModel browse mode | Medium | Async load command, pagination mock, filter behavior |
|
|
||||||
|
### 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
|
## Cross-Feature Dependencies
|
||||||
|
|
||||||
Both features touch the same data models. Changes must be coordinated:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
TenantProfile model — gains fields for branding (ClientLogoBase64, ClientLogoSource)
|
Graph API scopes (cumulative for this milestone):
|
||||||
AppSettings model — gains MspLogoBase64
|
Application.ReadWrite.All → Features 1+2 (app registration/removal)
|
||||||
ProfileRepository — serializes/deserializes new TenantProfile fields (JSON, backward-compat)
|
AppRoleAssignment.ReadWrite.All → Feature 1 (consent grant)
|
||||||
SettingsRepository — serializes/deserializes new AppSettings field
|
GroupMember.Read.All → Feature 4 (group expansion)
|
||||||
GraphClientFactory — used by both features (no changes needed)
|
SharePoint Sites.FullControl.All → Optional alt path for Feature 3 (avoid if possible)
|
||||||
TenantSwitchedMessage — consumed by UserAccessAuditViewModel to clear directory cache
|
|
||||||
|
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
|
No new NuGet packages are needed for Features 3-5. Features 1-2 are already covered by the
|
||||||
already present. No new binary dependencies means no EXE size increase.
|
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.
|
1. **Report Consolidation Toggle (Feature 5)** — Pure in-memory LINQ; zero new API calls; zero
|
||||||
2. **Client logo in HTML reports (import from file)** — completes the co-branding pattern. TenantProfile field + ProfileManagementDialog upload UI.
|
risk to existing pipeline. Builds confidence before touching external APIs.
|
||||||
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.
|
2. **Group Expansion in HTML Reports (Feature 4)** — Graph call at export time; reuses existing
|
||||||
5. **Directory: guest filter + department/jobTitle columns** — low-effort differentiators; add after core browse is stable.
|
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:
|
Defer to a later milestone:
|
||||||
- Directory session caching across tab switches — a Refresh button is sufficient for v2.2.
|
- Certificate-based credentials for registered apps (out of scope by design)
|
||||||
- Logo on CSV exports — CSV has no image support; not applicable.
|
- Cross-site consolidation (different problem domain)
|
||||||
|
- Recursive group expansion beyond 1 level (complexity/value ratio too low)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sources
|
## 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 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 Get organizationalBranding (v1.0 official): https://learn.microsoft.com/en-us/graph/api/organizationalbranding-get?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 bannerLogo stream: `GET /organization/{id}/branding/localizations/default/bannerLogo` — HIGH confidence (verified in official docs)
|
- 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)
|
||||||
- Graph pagination concepts: https://learn.microsoft.com/en-us/graph/paging — HIGH confidence
|
- PnP PowerShell Add-PnPSiteCollectionAdmin: https://pnp.github.io/powershell/cmdlets/Add-PnPSiteCollectionAdmin.html — HIGH confidence (C# equivalent available via PnP.Framework Tenant API)
|
||||||
- ControlMap co-branding (MSP + client logo pattern): https://help.controlmap.io/hc/en-us/articles/24174398424347 — MEDIUM confidence
|
- PnP PowerShell Set-PnPTenantSite -Owners: https://pnp.github.io/powershell/cmdlets/Set-PnPTenantSite.html — HIGH confidence
|
||||||
- ManageEngine ServiceDesk Plus MSP per-account branding: https://www.manageengine.com/products/service-desk-msp/rebrand.html — MEDIUM 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)
|
||||||
- SolarWinds MSP report customization: http://allthings.solarwindsmsp.com/2013/06/customize-your-branding-on-client.html — MEDIUM confidence
|
- W3Schools collapsible JS pattern: https://www.w3schools.com/howto/howto_js_collapsible.asp — HIGH confidence
|
||||||
- Direct codebase inspection: HtmlExportService.cs, GraphUserSearchService.cs, AppSettings.cs, TenantProfile.cs — 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
|
||||||
|
|||||||
@@ -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.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
@@ -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 |
|
| 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
|
### 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)
|
## Existing Stack (Unchanged)
|
||||||
|
|
||||||
The full stack as validated through v1.1:
|
The full stack as validated through v2.2:
|
||||||
|
|
||||||
| Technology | Version | Purpose |
|
| Technology | Version | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -249,13 +436,13 @@ The full stack as validated through v1.1:
|
|||||||
| WPF | built-in | UI framework |
|
| WPF | built-in | UI framework |
|
||||||
| C# 13 | built-in | Language |
|
| C# 13 | built-in | Language |
|
||||||
| PnP.Framework | 1.18.0 | SharePoint CSOM, provisioning engine |
|
| 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 (MSAL) | 4.83.3 | Multi-tenant auth, token acquisition |
|
||||||
| Microsoft.Identity.Client.Extensions.Msal | 4.83.3 | Token cache persistence |
|
| Microsoft.Identity.Client.Extensions.Msal | 4.83.3 | Token cache persistence |
|
||||||
| Microsoft.Identity.Client.Broker | 4.82.1 | Windows broker (WAM) |
|
| Microsoft.Identity.Client.Broker | 4.82.1 | Windows broker (WAM) |
|
||||||
| CommunityToolkit.Mvvm | 8.4.2 | MVVM base classes, source generators |
|
| CommunityToolkit.Mvvm | 8.4.2 | MVVM base classes, source generators |
|
||||||
| Microsoft.Extensions.Hosting | 10.0.0 | DI container, app lifetime |
|
| 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 | 4.3.1 | Structured logging |
|
||||||
| Serilog.Extensions.Hosting | 10.0.0 | ILogger<T> bridge |
|
| Serilog.Extensions.Hosting | 10.0.0 | ILogger<T> bridge |
|
||||||
| Serilog.Sinks.File | 7.0.0 | Rolling file output |
|
| Serilog.Sinks.File | 7.0.0 | Rolling file output |
|
||||||
@@ -268,8 +455,16 @@ The full stack as validated through v1.1:
|
|||||||
|
|
||||||
## Sources
|
## 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)
|
**v2.2 sources:**
|
||||||
- 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 — 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 — 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
|
- Microsoft Learn — Page through a collection (Graph SDKs): https://learn.microsoft.com/en-us/graph/sdks/paging — PageIterator C# pattern (HIGH confidence)
|
||||||
- .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.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)
|
||||||
|
|||||||
@@ -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
|
- 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
|
- 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*
|
*v1.0 research completed: 2026-04-02*
|
||||||
*v2.2 research synthesized: 2026-04-08*
|
*v2.2 research synthesized: 2026-04-08*
|
||||||
|
*v2.3 research synthesized: 2026-04-09*
|
||||||
*Ready for roadmap: yes*
|
*Ready for roadmap: yes*
|
||||||
|
|||||||
@@ -348,4 +348,59 @@ public class UserAccessAuditViewModelDirectoryTests
|
|||||||
Assert.Single(visible);
|
Assert.Single(visible);
|
||||||
Assert.Equal("Alice", visible[0].DisplayName);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
-1
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox.Tests")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox.Tests")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[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.AssemblyProductAttribute("SharepointToolbox.Tests")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox.Tests")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox.Tests")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
f9df09480b479069e5e6ae5f78b859fa720a12b4459d28036dfb96df77d53bef
|
a6a103bebe57a485c13eef1c486d11ae19b7d31a857b43f59666705dc94a6cdb
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ build_property.PlatformNeutralAssembly =
|
|||||||
build_property.EnforceExtendedAnalyzerRules =
|
build_property.EnforceExtendedAnalyzerRules =
|
||||||
build_property._SupportedPlatformList = Linux,macOS,Windows
|
build_property._SupportedPlatformList = Linux,macOS,Windows
|
||||||
build_property.RootNamespace = SharepointToolbox.Tests
|
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.EnableComHosting =
|
||||||
build_property.EnableGeneratedComInterfaceComImportInterop =
|
build_property.EnableGeneratedComInterfaceComImportInterop =
|
||||||
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
build_property.CsWinRTUseWindowsUIXamlProjections = false
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
|||||||
a590f1603da7d8620e6edc276235fbd796db819f8f128515c72d60c0add97067
|
17b6b482b078d0ca357cbc341151e0b1e20afe20c4b7bd849f6e0f34b62c2c26
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -394,4 +394,19 @@
|
|||||||
<data name="profile.logo.clear" xml:space="preserve"><value>Effacer</value></data>
|
<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.autopull" xml:space="preserve"><value>Importer depuis Entra</value></data>
|
||||||
<data name="profile.logo.nopreview" xml:space="preserve"><value>Aucun logo configuré</value></data>
|
<data name="profile.logo.nopreview" xml:space="preserve"><value>Aucun logo configuré</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é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 à 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é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>
|
</root>
|
||||||
|
|||||||
@@ -394,4 +394,19 @@
|
|||||||
<data name="profile.logo.clear" xml:space="preserve"><value>Clear</value></data>
|
<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.autopull" xml:space="preserve"><value>Pull from Entra</value></data>
|
||||||
<data name="profile.logo.nopreview" xml:space="preserve"><value>No logo configured</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>
|
</root>
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
||||||
public RelayCommand<GraphUserResult> AddUserCommand { get; }
|
public RelayCommand<GraphUserResult> AddUserCommand { get; }
|
||||||
public RelayCommand<GraphUserResult> RemoveUserCommand { get; }
|
public RelayCommand<GraphUserResult> RemoveUserCommand { get; }
|
||||||
|
public RelayCommand<GraphDirectoryUser> SelectDirectoryUserCommand { get; }
|
||||||
public IAsyncRelayCommand LoadDirectoryCommand { get; }
|
public IAsyncRelayCommand LoadDirectoryCommand { get; }
|
||||||
public RelayCommand CancelDirectoryLoadCommand { get; }
|
public RelayCommand CancelDirectoryLoadCommand { get; }
|
||||||
|
|
||||||
@@ -174,6 +175,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||||
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
|
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
|
||||||
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
|
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
|
||||||
|
SelectDirectoryUserCommand = new RelayCommand<GraphDirectoryUser>(ExecuteSelectDirectoryUser);
|
||||||
|
|
||||||
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
|
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
|
||||||
|
|
||||||
@@ -216,6 +218,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
||||||
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
|
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
|
||||||
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
|
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
|
||||||
|
SelectDirectoryUserCommand = new RelayCommand<GraphDirectoryUser>(ExecuteSelectDirectoryUser);
|
||||||
|
|
||||||
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
|
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
|
||||||
|
|
||||||
@@ -548,6 +551,16 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|||||||
SelectedUsers.Remove(user);
|
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)
|
private async Task DebounceSearchAsync(string query, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -15,8 +15,30 @@
|
|||||||
<!-- Left panel -->
|
<!-- Left panel -->
|
||||||
<DockPanel Grid.Column="0" Grid.Row="0" Margin="8">
|
<DockPanel Grid.Column="0" Grid.Row="0" Margin="8">
|
||||||
|
|
||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.users]}"
|
<!-- Mode toggle -->
|
||||||
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
|
<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>
|
<StackPanel>
|
||||||
<TextBox Text="{Binding SearchQuery, UpdateSourceTrigger=PropertyChanged}" Margin="0,0,0,2" />
|
<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">
|
<TextBlock Text="Searching..." FontStyle="Italic" FontSize="10" Foreground="Gray" Margin="0,0,0,2">
|
||||||
@@ -57,6 +79,104 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox>
|
</ListBox>
|
||||||
|
</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 ItemsSource="{Binding SelectedUsers}" Margin="0,0,0,4">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
@@ -75,8 +195,8 @@
|
|||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
<TextBlock Text="{Binding SelectedUsersLabel}" FontStyle="Italic" Foreground="Gray" FontSize="10" />
|
<TextBlock Text="{Binding SelectedUsersLabel}" FontStyle="Italic" Foreground="Gray" FontSize="10" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</GroupBox>
|
|
||||||
|
|
||||||
|
<!-- Scan Options (always visible) -->
|
||||||
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.options]}"
|
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[audit.grp.options]}"
|
||||||
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
|
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
@@ -89,6 +209,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</GroupBox>
|
</GroupBox>
|
||||||
|
|
||||||
|
<!-- Run/Export buttons (always visible) -->
|
||||||
<StackPanel DockPanel.Dock="Top">
|
<StackPanel DockPanel.Dock="Top">
|
||||||
<Grid Margin="0,0,0,4">
|
<Grid Margin="0,0,0,4">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
using SharepointToolbox.Services;
|
using SharepointToolbox.Services;
|
||||||
using SharepointToolbox.ViewModels.Tabs;
|
using SharepointToolbox.ViewModels.Tabs;
|
||||||
|
|
||||||
@@ -24,4 +25,14 @@ public partial class UserAccessAuditView : UserControl
|
|||||||
listBox.SelectedItem = null;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,7 +14,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[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.AssemblyProductAttribute("SharepointToolbox")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
72f994ecac20797c56b9c39d5917ad31c134243b0218fc33af11e5587a50ed39
|
92fb59486a9b4569136d423b674d1545abfe4aa70a8cb949969aac2f7c58c28c
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25
@@ -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.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
0659bc7ce6bd4add20a40ec175f2ae2d4690e16312ba96cfa266940f89d22e4e
|
||||||
+23
@@ -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 =
|
||||||
+6
@@ -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;
|
||||||
BIN
Binary file not shown.
+25
@@ -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.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
3fef37f623bcf5d17978ebf53360e98891ba93a92cd81406e5ed1d76ce4c14b7
|
||||||
+23
@@ -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 =
|
||||||
+6
@@ -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;
|
||||||
BIN
Binary file not shown.
+25
@@ -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.
|
||||||
|
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
97d17a1cb043b978e1963dc14bb6a53e41f858e995ba4228fecd59ae19eb3360
|
||||||
+23
@@ -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 =
|
||||||
+6
@@ -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;
|
||||||
BIN
Binary file not shown.
Binary file not shown.
@@ -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>
|
// <auto-generated>
|
||||||
// This code was generated by a tool.
|
// 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 {
|
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")]
|
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
|
||||||
internal System.Windows.Controls.ListBox SearchResultsListBox;
|
internal System.Windows.Controls.ListBox SearchResultsListBox;
|
||||||
|
|
||||||
#line default
|
#line default
|
||||||
#line hidden
|
#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;
|
private bool _contentLoaded;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -81,9 +89,18 @@ namespace SharepointToolbox.Views.Tabs {
|
|||||||
case 1:
|
case 1:
|
||||||
this.SearchResultsListBox = ((System.Windows.Controls.ListBox)(target));
|
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);
|
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 default
|
||||||
#line hidden
|
#line hidden
|
||||||
return;
|
return;
|
||||||
|
|||||||
Binary file not shown.
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
|
||||||
[assembly: System.Reflection.AssemblyCopyrightAttribute(" ")]
|
[assembly: System.Reflection.AssemblyCopyrightAttribute(" ")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[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.AssemblyProductAttribute("SharepointToolbox")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user