docs: create milestone v2.3 roadmap (5 phases, 15-19)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-09 11:14:03 +02:00
parent d967a8bb65
commit e3ff27a673
60 changed files with 1138 additions and 35 deletions

View File

@@ -48,24 +48,24 @@ Requirements for v2.3 Tenant Management & Report Enhancements. Each maps to road
| Requirement | Phase | Status |
|-------------|-------|--------|
| APPREG-01 | | Pending |
| APPREG-02 | | Pending |
| APPREG-03 | | Pending |
| APPREG-04 | | Pending |
| APPREG-05 | | Pending |
| APPREG-06 | | Pending |
| OWN-01 | | Pending |
| OWN-02 | | Pending |
| RPT-01 | | Pending |
| RPT-02 | | Pending |
| RPT-03 | | Pending |
| RPT-04 | | Pending |
| APPREG-01 | Phase 19 | Pending |
| APPREG-02 | Phase 19 | Pending |
| APPREG-03 | Phase 19 | Pending |
| APPREG-04 | Phase 19 | Pending |
| APPREG-05 | Phase 19 | Pending |
| APPREG-06 | Phase 19 | Pending |
| OWN-01 | Phase 18 | Pending |
| OWN-02 | Phase 18 | Pending |
| RPT-01 | Phase 17 | Pending |
| RPT-02 | Phase 17 | Pending |
| RPT-03 | Phase 16 | Pending |
| RPT-04 | Phase 15 | Pending |
**Coverage:**
- v2.3 requirements: 12 total
- Mapped to phases: 0
- Unmapped: 12
- Mapped to phases: 12
- Unmapped: 0
---
*Requirements defined: 2026-04-09*
*Last updated: 2026-04-09 after initial definition*
*Last updated: 2026-04-09 after roadmap created*

View File

@@ -5,6 +5,7 @@
-**v1.0 MVP** — Phases 1-5 (shipped 2026-04-07) — [archive](milestones/v1.0-ROADMAP.md)
-**v1.1 Enhanced Reports** — Phases 6-9 (shipped 2026-04-08) — [archive](milestones/v1.1-ROADMAP.md)
-**v2.2 Report Branding & User Directory** — Phases 10-14 (shipped 2026-04-09) — [archive](milestones/v2.2-ROADMAP.md)
- 🔄 **v2.3 Tenant Management & Report Enhancements** — Phases 15-19 (in progress)
## Phases
@@ -40,6 +41,72 @@
</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 15: Consolidation Data Model
**Goal**: The data shape and merge logic for report consolidation exist and are fully testable in isolation before any UI touches them
**Depends on**: Nothing (no API calls, no UI dependencies)
**Requirements**: RPT-04
**Success Criteria** (what must be TRUE):
1. A `ConsolidatedPermissionEntry` model exists that represents a single user's merged access across multiple locations with identical access levels
2. A `PermissionConsolidator` service accepts a flat list of permission rows and returns a consolidated list where duplicate user+level rows are merged
3. Consolidation logic has unit test coverage — a known 10-row input with 3 duplicate pairs produces the expected 7-row output
4. Existing HTML export services compile and produce identical output when consolidation is not applied (opt-in, defaults off)
**Plans**: TBD
### Phase 16: Report Consolidation Toggle
**Goal**: Users can choose to merge duplicate permission entries per export through a toggle in the export settings dialog
**Depends on**: Phase 15
**Requirements**: RPT-03
**Success Criteria** (what must be TRUE):
1. A consolidation toggle is visible in the export settings dialog (or export options panel) and defaults to OFF
2. When the toggle is OFF, the exported HTML report is byte-for-byte identical to the pre-v2.3 output
3. When the toggle is ON, the exported HTML report merges rows for the same user with identical access levels into a single row showing all affected locations
4. The toggle state is remembered for the session (does not reset between exports within the same session)
**Plans**: TBD
### Phase 17: Group Expansion in HTML Reports
**Goal**: Users can expand SharePoint group entries in HTML reports to see the group's members, including members of nested groups
**Depends on**: Phase 16
**Requirements**: RPT-01, RPT-02
**Success Criteria** (what must be TRUE):
1. SharePoint group rows in the HTML report render as expandable — clicking a group name reveals its member list inline
2. Member resolution includes transitive membership: nested groups are recursively resolved so every leaf user is shown
3. Group expansion is triggered at export time via Graph API — the permission scan itself is unchanged
4. When Graph cannot resolve a group's members (throttled or insufficient scope), the report shows the group row with a "members unavailable" label rather than failing the export
**Plans**: TBD
### Phase 18: Auto-Take Ownership
**Goal**: Users can enable automatic site collection admin elevation so that access-denied sites during scans no longer block audit progress
**Depends on**: Phase 15
**Requirements**: OWN-01, OWN-02
**Success Criteria** (what must be TRUE):
1. A global "Auto-take ownership on access denied" toggle exists in application settings and defaults to OFF
2. When the toggle is OFF, access-denied sites produce the same error behavior as before v2.3 (no regression)
3. When the toggle is ON and a scan hits access denied on a site, the app automatically calls `Tenant.SetSiteAdmin` to elevate ownership and retries the site without interrupting the scan
4. The scan result for an auto-elevated site is visually distinguishable from a normally-scanned site (e.g., a flag or icon in the results)
**Plans**: TBD
### Phase 19: App Registration & Removal
**Goal**: Users can register and remove the Toolbox's Azure AD application on a target tenant directly from the profile dialog, with a guided fallback when permissions are insufficient
**Depends on**: Phase 18
**Requirements**: APPREG-01, APPREG-02, APPREG-03, APPREG-04, APPREG-05, APPREG-06
**Success Criteria** (what must be TRUE):
1. A "Register App" action is available in the profile create/edit dialog and is the recommended path for new tenant onboarding
2. Before attempting registration, the app checks for Global Admin role and surfaces a clear message if the signed-in user lacks the required permissions, then presents step-by-step manual registration instructions as a fallback
3. Registration creates the Azure AD application, service principal, and grants all required API permissions in a single atomic operation — if any step fails, all partial changes are rolled back and the user sees a specific error explaining what failed and why
4. A "Remove App" action in the profile dialog removes the Azure AD application registration from the target tenant
5. After removal, all cached MSAL tokens and session state for that tenant are cleared, and subsequent operations require re-authentication
**Plans**: TBD
## Progress
| Phase | Milestone | Plans | Status | Completed |
@@ -47,3 +114,8 @@
| 1-5 | v1.0 | 36/36 | Shipped | 2026-04-07 |
| 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 |
| 10-14 | v2.2 | 14/14 | Shipped | 2026-04-09 |
| 15. Consolidation Data Model | v2.3 | 0/? | Not started | — |
| 16. Report Consolidation Toggle | v2.3 | 0/? | Not started | — |
| 17. Group Expansion in HTML Reports | v2.3 | 0/? | Not started | — |
| 18. Auto-Take Ownership | v2.3 | 0/? | Not started | — |
| 19. App Registration & Removal | v2.3 | 0/? | Not started | — |

View File

@@ -2,12 +2,12 @@
gsd_state_version: 1.0
milestone: v2.3
milestone_name: Tenant Management & Report Enhancements
status: defining-requirements
stopped_at: milestone started
status: roadmap-ready
stopped_at: roadmap created — ready for phase 15 planning
last_updated: "2026-04-09"
last_activity: 2026-04-09 — Milestone v2.3 started
last_activity: 2026-04-09 — Roadmap created for v2.3 (phases 15-19)
progress:
total_phases: 0
total_phases: 5
completed_phases: 0
total_plans: 0
completed_plans: 0
@@ -20,14 +20,18 @@ progress:
See: .planning/PROJECT.md (updated 2026-04-09)
**Core value:** Administrators can audit and manage SharePoint/Teams permissions and storage across multiple client tenants from a single, reliable desktop application.
**Current focus:** v2.3 Tenant Management & Report Enhancements
**Current focus:** v2.3 Tenant Management & Report Enhancements — Phase 15 next
## Current Position
Phase: Not started (defining requirements)
Phase: 15 — Consolidation Data Model (not started)
Plan: —
Status: Defining requirements
Last activity: 2026-04-09 — Milestone v2.3 started
Status: Roadmap approved — ready to plan Phase 15
Last activity: 2026-04-09 — Roadmap created for v2.3 (phases 15-19)
```
v2.3 Progress: ░░░░░░░░░░ 0% (0/5 phases)
```
## Shipped Milestones
@@ -35,12 +39,29 @@ Last activity: 2026-04-09 — Milestone v2.3 started
- v1.1 Enhanced Reports — Phases 6-9 (shipped 2026-04-08)
- v2.2 Report Branding & User Directory — Phases 10-14 (shipped 2026-04-09)
## v2.3 Phase Map
| Phase | Name | Requirements | Status |
|-------|------|--------------|--------|
| 15 | Consolidation Data Model | RPT-04 | Not started |
| 16 | Report Consolidation Toggle | RPT-03 | Not started |
| 17 | Group Expansion in HTML Reports | RPT-01, RPT-02 | Not started |
| 18 | Auto-Take Ownership | OWN-01, OWN-02 | Not started |
| 19 | App Registration & Removal | APPREG-01..06 | Not started |
## Accumulated Context
### Decisions
Decisions are logged in PROJECT.md Key Decisions table.
**v2.3 notable constraints:**
- Phase 19 has the highest blast radius (Entra changes) — must be last
- Phase 15 is zero-API-call foundation; unblocks Phase 16 (consolidation) and Phase 18 (ownership) independently
- Group expansion (Phase 17) calls Graph at export time, not at scan time — scan pipeline unchanged
- Auto-take ownership uses PnP `Tenant.SetSiteAdmin` — requires Tenant Admin scope
- App registration must be atomic with rollback; partial Entra state is worse than no state
### Pending Todos
None.
@@ -52,6 +73,6 @@ None.
## Session Continuity
Last session: 2026-04-09
Stopped at: Milestone v2.3 started — defining requirements
Stopped at: Roadmap created — ready to plan Phase 15
Resume file: None
Next step: Define requirements, then create roadmap
Next step: `/gsd:plan-phase 15`

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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)_

View File

@@ -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 |

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox.Tests")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+7c7d87d86b7dbd94b1c5591ea880fa33a1ee0827")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+b9511bd2b07dc1b8f7a457602899a3644db4f099")]
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox.Tests")]
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox.Tests")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -1 +1 @@
f9df09480b479069e5e6ae5f78b859fa720a12b4459d28036dfb96df77d53bef
a6a103bebe57a485c13eef1c486d11ae19b7d31a857b43f59666705dc94a6cdb

View File

@@ -15,7 +15,7 @@ build_property.PlatformNeutralAssembly =
build_property.EnforceExtendedAnalyzerRules =
build_property._SupportedPlatformList = Linux,macOS,Windows
build_property.RootNamespace = SharepointToolbox.Tests
build_property.ProjectDir = c:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox.Tests\
build_property.ProjectDir = C:\Users\dev\Documents\projets\Sharepoint\SharepointToolbox.Tests\
build_property.EnableComHosting =
build_property.EnableGeneratedComInterfaceComImportInterop =
build_property.CsWinRTUseWindowsUIXamlProjections = false

View File

@@ -1 +1 @@
a590f1603da7d8620e6edc276235fbd796db819f8f128515c72d60c0add97067
17b6b482b078d0ca357cbc341151e0b1e20afe20c4b7bd849f6e0f34b62c2c26

View File

@@ -14,7 +14,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+7c7d87d86b7dbd94b1c5591ea880fa33a1ee0827")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+b9511bd2b07dc1b8f7a457602899a3644db4f099")]
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

View File

@@ -1 +1 @@
72f994ecac20797c56b9c39d5917ad31c134243b0218fc33af11e5587a50ed39
92fb59486a9b4569136d423b674d1545abfe4aa70a8cb949969aac2f7c58c28c

View File

@@ -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.

View File

@@ -0,0 +1 @@
0659bc7ce6bd4add20a40ec175f2ae2d4690e16312ba96cfa266940f89d22e4e

View File

@@ -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 =

View File

@@ -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;

View File

@@ -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.

View File

@@ -0,0 +1 @@
3fef37f623bcf5d17978ebf53360e98891ba93a92cd81406e5ed1d76ce4c14b7

View File

@@ -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 =

View File

@@ -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;

View File

@@ -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.

View File

@@ -0,0 +1 @@
97d17a1cb043b978e1963dc14bb6a53e41f858e995ba4228fecd59ae19eb3360

View File

@@ -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 =

View File

@@ -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;

View File

@@ -1,4 +1,4 @@
#pragma checksum "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "CBA7E811798D1605D43A084B6989D797CB13323D"
#pragma checksum "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "0F51C9F8F2735BDED33D928B9D536B5267CB02CA"
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
@@ -41,13 +41,21 @@ namespace SharepointToolbox.Views.Tabs {
public partial class UserAccessAuditView : System.Windows.Controls.UserControl, System.Windows.Markup.IComponentConnector {
#line 34 "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml"
#line 56 "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.ListBox SearchResultsListBox;
#line default
#line hidden
#line 137 "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml"
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1823:AvoidUnusedPrivateFields")]
internal System.Windows.Controls.DataGrid DirectoryDataGrid;
#line default
#line hidden
private bool _contentLoaded;
/// <summary>
@@ -81,9 +89,18 @@ namespace SharepointToolbox.Views.Tabs {
case 1:
this.SearchResultsListBox = ((System.Windows.Controls.ListBox)(target));
#line 37 "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml"
#line 59 "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml"
this.SearchResultsListBox.SelectionChanged += new System.Windows.Controls.SelectionChangedEventHandler(this.SearchResultsListBox_SelectionChanged);
#line default
#line hidden
return;
case 2:
this.DirectoryDataGrid = ((System.Windows.Controls.DataGrid)(target));
#line 141 "..\..\..\..\..\Views\Tabs\UserAccessAuditView.xaml"
this.DirectoryDataGrid.MouseDoubleClick += new System.Windows.Input.MouseButtonEventHandler(this.DirectoryDataGrid_MouseDoubleClick);
#line default
#line hidden
return;

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyCopyrightAttribute(" ")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+7c7d87d86b7dbd94b1c5591ea880fa33a1ee0827")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+b9511bd2b07dc1b8f7a457602899a3644db4f099")]
[assembly: System.Reflection.AssemblyProductAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyTitleAttribute("SharepointToolbox")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]