docs(17): create phase plan for group expansion in HTML reports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
- [x] **Phase 15: Consolidation Data Model** (2 plans) — PermissionConsolidator service and merged-row model; zero API calls, pure data shapes (completed 2026-04-09)
|
- [x] **Phase 15: Consolidation Data Model** (2 plans) — PermissionConsolidator service and merged-row model; zero API calls, pure data shapes (completed 2026-04-09)
|
||||||
- [x] **Phase 16: Report Consolidation Toggle** (2 plans) — Export settings toggle wired to PermissionConsolidator; first user-visible consolidation behavior (completed 2026-04-09)
|
- [x] **Phase 16: Report Consolidation Toggle** (2 plans) — Export settings toggle wired to PermissionConsolidator; first user-visible consolidation behavior (completed 2026-04-09)
|
||||||
- [ ] **Phase 17: Group Expansion in HTML Reports** — Clickable group expansion in HTML exports with transitive membership resolution
|
- [ ] **Phase 17: Group Expansion in HTML Reports** (2 plans) — 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 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 19: App Registration & Removal** — Automated Entra app registration with guided fallback and clean removal
|
||||||
|
|
||||||
@@ -88,7 +88,10 @@ Plans:
|
|||||||
2. Member resolution includes transitive membership: nested groups are recursively resolved so every leaf user is shown
|
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
|
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
|
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
|
**Plans:** 2 plans
|
||||||
|
Plans:
|
||||||
|
- [ ] 17-01-PLAN.md — ResolvedMember model + ISharePointGroupResolver service (CSOM + Graph transitive resolution) + DI registration
|
||||||
|
- [ ] 17-02-PLAN.md — HtmlExportService expandable group pills + toggleGroup JS + PermissionsViewModel wiring
|
||||||
|
|
||||||
### Phase 18: Auto-Take Ownership
|
### 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
|
**Goal**: Users can enable automatic site collection admin elevation so that access-denied sites during scans no longer block audit progress
|
||||||
@@ -121,7 +124,7 @@ Plans:
|
|||||||
| 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 |
|
| 6-9 | v1.1 | 25/25 | Shipped | 2026-04-08 |
|
||||||
| 10-14 | v2.2 | 14/14 | Shipped | 2026-04-09 |
|
| 10-14 | v2.2 | 14/14 | Shipped | 2026-04-09 |
|
||||||
| 15. Consolidation Data Model | v2.3 | 2/2 | Complete | 2026-04-09 |
|
| 15. Consolidation Data Model | v2.3 | 2/2 | Complete | 2026-04-09 |
|
||||||
| 16. Report Consolidation Toggle | 2/2 | Complete | 2026-04-09 | — |
|
| 16. Report Consolidation Toggle | v2.3 | 2/2 | Complete | 2026-04-09 |
|
||||||
| 17. Group Expansion in HTML Reports | v2.3 | 0/? | Not started | — |
|
| 17. Group Expansion in HTML Reports | v2.3 | 0/2 | Not started | — |
|
||||||
| 18. Auto-Take Ownership | v2.3 | 0/? | Not started | — |
|
| 18. Auto-Take Ownership | v2.3 | 0/? | Not started | — |
|
||||||
| 19. App Registration & Removal | v2.3 | 0/? | Not started | — |
|
| 19. App Registration & Removal | v2.3 | 0/? | Not started | — |
|
||||||
|
|||||||
212
.planning/phases/17-group-expansion-html-reports/17-01-PLAN.md
Normal file
212
.planning/phases/17-group-expansion-html-reports/17-01-PLAN.md
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
---
|
||||||
|
phase: 17-group-expansion-html-reports
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- SharepointToolbox/Core/Models/ResolvedMember.cs
|
||||||
|
- SharepointToolbox/Services/ISharePointGroupResolver.cs
|
||||||
|
- SharepointToolbox/Services/SharePointGroupResolver.cs
|
||||||
|
- SharepointToolbox/App.xaml.cs
|
||||||
|
- SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs
|
||||||
|
autonomous: true
|
||||||
|
requirements: [RPT-02]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "SharePointGroupResolver returns a dictionary keyed by group name with OrdinalIgnoreCase comparison"
|
||||||
|
- "SharePointGroupResolver returns empty list (never throws) when a group cannot be resolved"
|
||||||
|
- "IsAadGroup correctly identifies AAD group login patterns (c:0t.c|tenant|<guid>)"
|
||||||
|
- "AAD groups detected in SharePoint group members are resolved transitively via Graph"
|
||||||
|
- "ResolvedMember record exists in Core/Models with DisplayName and Login properties"
|
||||||
|
artifacts:
|
||||||
|
- path: "SharepointToolbox/Core/Models/ResolvedMember.cs"
|
||||||
|
provides: "Value record for resolved group member"
|
||||||
|
contains: "record ResolvedMember"
|
||||||
|
- path: "SharepointToolbox/Services/ISharePointGroupResolver.cs"
|
||||||
|
provides: "Interface contract for group resolution"
|
||||||
|
exports: ["ISharePointGroupResolver"]
|
||||||
|
- path: "SharepointToolbox/Services/SharePointGroupResolver.cs"
|
||||||
|
provides: "CSOM + Graph implementation of group resolution"
|
||||||
|
contains: "ResolveGroupsAsync"
|
||||||
|
- path: "SharepointToolbox/App.xaml.cs"
|
||||||
|
provides: "DI registration for ISharePointGroupResolver"
|
||||||
|
contains: "ISharePointGroupResolver"
|
||||||
|
- path: "SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs"
|
||||||
|
provides: "Unit tests for resolver logic"
|
||||||
|
contains: "IsAadGroup"
|
||||||
|
key_links:
|
||||||
|
- from: "SharepointToolbox/Services/SharePointGroupResolver.cs"
|
||||||
|
to: "GraphClientFactory"
|
||||||
|
via: "constructor injection"
|
||||||
|
pattern: "GraphClientFactory"
|
||||||
|
- from: "SharepointToolbox/Services/SharePointGroupResolver.cs"
|
||||||
|
to: "ExecuteQueryRetryHelper"
|
||||||
|
via: "CSOM retry"
|
||||||
|
pattern: "ExecuteQueryRetryAsync"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the SharePoint group member resolution service that resolves group names to their transitive members via CSOM + Graph API.
|
||||||
|
|
||||||
|
Purpose: This service is the data provider for Phase 17 — it pre-resolves group members before HTML export so the export service remains pure and synchronous.
|
||||||
|
Output: `ResolvedMember` model, `ISharePointGroupResolver` interface, `SharePointGroupResolver` implementation, DI registration, unit tests.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/17-group-expansion-html-reports/17-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs -->
|
||||||
|
|
||||||
|
From SharepointToolbox/Core/Models/PermissionEntry.cs:
|
||||||
|
```csharp
|
||||||
|
public record PermissionEntry(
|
||||||
|
string ObjectType,
|
||||||
|
string Title,
|
||||||
|
string Url,
|
||||||
|
bool HasUniquePermissions,
|
||||||
|
string Users,
|
||||||
|
string UserLogins,
|
||||||
|
string PermissionLevels,
|
||||||
|
string GrantedThrough,
|
||||||
|
string PrincipalType // "SharePointGroup" | "User" | "External User"
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs:
|
||||||
|
```csharp
|
||||||
|
public class GraphClientFactory
|
||||||
|
{
|
||||||
|
public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs:
|
||||||
|
```csharp
|
||||||
|
public static class ExecuteQueryRetryHelper
|
||||||
|
{
|
||||||
|
public static async Task ExecuteQueryRetryAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
IProgress<OperationProgress>? progress = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/App.xaml.cs (DI registration pattern):
|
||||||
|
```csharp
|
||||||
|
// Phase 4: Bulk Members
|
||||||
|
services.AddTransient<IBulkMemberService, BulkMemberService>();
|
||||||
|
// Phase 7: User Access Audit
|
||||||
|
services.AddTransient<IGraphUserSearchService, GraphUserSearchService>();
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: ResolvedMember model + ISharePointGroupResolver interface + SharePointGroupResolver implementation + tests</name>
|
||||||
|
<files>
|
||||||
|
SharepointToolbox/Core/Models/ResolvedMember.cs,
|
||||||
|
SharepointToolbox/Services/ISharePointGroupResolver.cs,
|
||||||
|
SharepointToolbox/Services/SharePointGroupResolver.cs,
|
||||||
|
SharepointToolbox.Tests/Services/SharePointGroupResolverTests.cs
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- Test: IsAadGroup returns true for "c:0t.c|tenant|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh" and false for "i:0#.f|membership|user@contoso.com" and false for "c:0(.s|true"
|
||||||
|
- Test: ExtractAadGroupId extracts GUID from "c:0t.c|tenant|aaaabbbb-cccc-dddd-eeee-ffffgggghhhh" -> "aaaabbbb-cccc-dddd-eeee-ffffgggghhhh"
|
||||||
|
- Test: StripClaims strips "i:0#.f|membership|user@contoso.com" -> "user@contoso.com"
|
||||||
|
- Test: ResolveGroupsAsync returns empty dict when groupNames list is empty
|
||||||
|
- Test: Result dictionary uses OrdinalIgnoreCase comparer (lookup "site members" matches key "Site Members")
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Create `Core/Models/ResolvedMember.cs`:
|
||||||
|
```csharp
|
||||||
|
namespace SharepointToolbox.Core.Models;
|
||||||
|
public record ResolvedMember(string DisplayName, string Login);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `Services/ISharePointGroupResolver.cs`:
|
||||||
|
```csharp
|
||||||
|
public interface ISharePointGroupResolver
|
||||||
|
{
|
||||||
|
Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupsAsync(
|
||||||
|
ClientContext ctx, string clientId,
|
||||||
|
IReadOnlyList<string> groupNames, CancellationToken ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Create `Services/SharePointGroupResolver.cs` implementing `ISharePointGroupResolver`:
|
||||||
|
- Constructor takes `GraphClientFactory` (same pattern as `BulkMemberService`)
|
||||||
|
- `ResolveGroupsAsync` iterates group names (`.Distinct(StringComparer.OrdinalIgnoreCase)`)
|
||||||
|
- Per group: CSOM `ctx.Web.SiteGroups.GetByName(name).Users` with `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct)`
|
||||||
|
- Per user: check `IsAadGroup(loginName)` — if true, extract GUID via `ExtractAadGroupId` and call `ResolveAadGroupAsync` via Graph
|
||||||
|
- `ResolveAadGroupAsync`: `graphClient.Groups[aadGroupId].TransitiveMembers.GraphUser.GetAsync()` with `PageIterator` for pagination — same pattern as `GraphUserDirectoryService`
|
||||||
|
- De-duplicate members by Login (OrdinalIgnoreCase) using `.DistinctBy(m => m.Login, StringComparer.OrdinalIgnoreCase)`
|
||||||
|
- Entire per-group block wrapped in try/catch — on any exception, log warning via `Serilog.Log.Warning` and set `result[groupName] = Array.Empty<ResolvedMember>()`
|
||||||
|
- Result dictionary created with `StringComparer.OrdinalIgnoreCase`
|
||||||
|
- Make `IsAadGroup`, `ExtractAadGroupId`, and `StripClaims` internal static (with `InternalsVisibleTo` already set for test project) so they are testable
|
||||||
|
- `IsAadGroup` pattern: `login.StartsWith("c:0t.c|", StringComparison.OrdinalIgnoreCase)`
|
||||||
|
- `ExtractAadGroupId`: `login[(login.LastIndexOf('|') + 1)..]`
|
||||||
|
- `StripClaims`: `login[(login.LastIndexOf('|') + 1)..]` (same substring after last pipe)
|
||||||
|
|
||||||
|
4. Create `SharePointGroupResolverTests.cs`:
|
||||||
|
- Test `IsAadGroup` with true/false cases (see behavior above)
|
||||||
|
- Test `ExtractAadGroupId` extraction
|
||||||
|
- Test `StripClaims` extraction
|
||||||
|
- Test `ResolveGroupsAsync` with empty list returns empty dict (needs mock ClientContext — use `[Fact(Skip="Requires CSOM ClientContext mock")]` if CSOM types cannot be easily mocked; alternatively test the static helpers only and add a skip-marked integration test)
|
||||||
|
- Test case-insensitive dict: create resolver, call with known group, verify lookup with different casing works. If full resolution cannot be unit tested without live CSOM, mark as `[Fact(Skip="Requires live SP tenant")]` and focus unit tests on the three static helpers
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet test --filter "FullyQualifiedName~SharePointGroupResolverTests" --no-build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>ResolvedMember record exists, ISharePointGroupResolver interface defined, SharePointGroupResolver compiles with CSOM + Graph resolution, static helpers (IsAadGroup, ExtractAadGroupId, StripClaims) have green unit tests</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: DI registration in App.xaml.cs</name>
|
||||||
|
<files>SharepointToolbox/App.xaml.cs</files>
|
||||||
|
<action>
|
||||||
|
Add DI registration for `ISharePointGroupResolver` in `App.xaml.cs` after the Phase 4 Bulk Members block (or near other service registrations):
|
||||||
|
```csharp
|
||||||
|
// Phase 17: Group Expansion
|
||||||
|
services.AddTransient<ISharePointGroupResolver, SharePointGroupResolver>();
|
||||||
|
```
|
||||||
|
Add the `using SharepointToolbox.Services;` if not already present (it should be since `IPermissionsService` is already registered from the same namespace).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet build --no-restore 2>&1 | tail -5</automated>
|
||||||
|
</verify>
|
||||||
|
<done>ISharePointGroupResolver registered in DI container, solution builds with 0 errors</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `dotnet build` — 0 errors, 0 warnings
|
||||||
|
- `dotnet test --filter "FullyQualifiedName~SharePointGroupResolverTests"` — all tests pass
|
||||||
|
- `dotnet test` — full suite green (no regressions)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- ResolvedMember record exists at Core/Models/ResolvedMember.cs
|
||||||
|
- ISharePointGroupResolver interface defines ResolveGroupsAsync contract
|
||||||
|
- SharePointGroupResolver implements CSOM group user loading + Graph transitive resolution
|
||||||
|
- Static helpers (IsAadGroup, ExtractAadGroupId, StripClaims) have passing unit tests
|
||||||
|
- DI registration wired in App.xaml.cs
|
||||||
|
- Full test suite green
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/17-group-expansion-html-reports/17-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
288
.planning/phases/17-group-expansion-html-reports/17-02-PLAN.md
Normal file
288
.planning/phases/17-group-expansion-html-reports/17-02-PLAN.md
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
---
|
||||||
|
phase: 17-group-expansion-html-reports
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["17-01"]
|
||||||
|
files_modified:
|
||||||
|
- SharepointToolbox/Services/Export/HtmlExportService.cs
|
||||||
|
- SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
|
||||||
|
- SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs
|
||||||
|
autonomous: true
|
||||||
|
requirements: [RPT-01, RPT-02]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "SharePoint group pills in the HTML report are clickable and expand to show group members"
|
||||||
|
- "When group members are resolved, clicking the pill reveals member names inline"
|
||||||
|
- "When group resolution fails, the pill expands to show 'members unavailable' label"
|
||||||
|
- "When no groupMembers dict is passed, HTML output is identical to pre-Phase 17 output"
|
||||||
|
- "toggleGroup() JS function exists in HtmlExportService inline JS"
|
||||||
|
- "PermissionsViewModel calls ISharePointGroupResolver before HTML export and passes results to BuildHtml"
|
||||||
|
artifacts:
|
||||||
|
- path: "SharepointToolbox/Services/Export/HtmlExportService.cs"
|
||||||
|
provides: "Expandable group pill rendering + toggleGroup JS"
|
||||||
|
contains: "toggleGroup"
|
||||||
|
- path: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
|
||||||
|
provides: "Group resolution orchestration before export"
|
||||||
|
contains: "_groupResolver"
|
||||||
|
- path: "SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs"
|
||||||
|
provides: "Tests for group pill expansion and backward compatibility"
|
||||||
|
contains: "grpmem"
|
||||||
|
key_links:
|
||||||
|
- from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
|
||||||
|
to: "ISharePointGroupResolver"
|
||||||
|
via: "constructor injection + call in ExportHtmlAsync"
|
||||||
|
pattern: "_groupResolver.ResolveGroupsAsync"
|
||||||
|
- from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
|
||||||
|
to: "HtmlExportService.BuildHtml"
|
||||||
|
via: "passing groupMembers dict"
|
||||||
|
pattern: "groupMembers"
|
||||||
|
- from: "SharepointToolbox/Services/Export/HtmlExportService.cs"
|
||||||
|
to: "toggleGroup JS"
|
||||||
|
via: "inline script block"
|
||||||
|
pattern: "function toggleGroup"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Wire group member expansion into HtmlExportService rendering and PermissionsViewModel export flow.
|
||||||
|
|
||||||
|
Purpose: This is the user-visible feature — SharePoint group pills become clickable in HTML reports, expanding to show resolved members or a "members unavailable" fallback.
|
||||||
|
Output: Modified HtmlExportService (both overloads), modified PermissionsViewModel, new HTML export tests.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/17-group-expansion-html-reports/17-RESEARCH.md
|
||||||
|
@.planning/phases/17-group-expansion-html-reports/17-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From Plan 01 outputs -->
|
||||||
|
|
||||||
|
From SharepointToolbox/Core/Models/ResolvedMember.cs:
|
||||||
|
```csharp
|
||||||
|
public record ResolvedMember(string DisplayName, string Login);
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Services/ISharePointGroupResolver.cs:
|
||||||
|
```csharp
|
||||||
|
public interface ISharePointGroupResolver
|
||||||
|
{
|
||||||
|
Task<IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>> ResolveGroupsAsync(
|
||||||
|
ClientContext ctx, string clientId,
|
||||||
|
IReadOnlyList<string> groupNames, CancellationToken ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Existing signatures that will be modified -->
|
||||||
|
|
||||||
|
From SharepointToolbox/Services/Export/HtmlExportService.cs:
|
||||||
|
```csharp
|
||||||
|
public class HtmlExportService
|
||||||
|
{
|
||||||
|
// Overload 1 — standard PermissionEntry
|
||||||
|
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null)
|
||||||
|
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||||
|
|
||||||
|
// Overload 2 — simplified
|
||||||
|
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null)
|
||||||
|
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct, ReportBranding? branding = null)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs:
|
||||||
|
```csharp
|
||||||
|
public partial class PermissionsViewModel : FeatureViewModelBase
|
||||||
|
{
|
||||||
|
private readonly HtmlExportService? _htmlExportService;
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IBrandingService? _brandingService;
|
||||||
|
private TenantProfile? _currentProfile;
|
||||||
|
|
||||||
|
public PermissionsViewModel(
|
||||||
|
IPermissionsService permissionsService,
|
||||||
|
ISiteListService siteListService,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
CsvExportService csvExportService,
|
||||||
|
HtmlExportService htmlExportService,
|
||||||
|
IBrandingService brandingService,
|
||||||
|
ILogger<FeatureViewModelBase> logger)
|
||||||
|
|
||||||
|
// Test constructor (no export services)
|
||||||
|
internal PermissionsViewModel(
|
||||||
|
IPermissionsService permissionsService,
|
||||||
|
ISiteListService siteListService,
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
ILogger<FeatureViewModelBase> logger,
|
||||||
|
IBrandingService? brandingService = null)
|
||||||
|
|
||||||
|
private async Task ExportHtmlAsync()
|
||||||
|
{
|
||||||
|
// Currently calls: _htmlExportService.WriteAsync(entries, path, ct, branding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs (toggleGroup JS to copy):
|
||||||
|
```javascript
|
||||||
|
function toggleGroup(id) {
|
||||||
|
var rows = document.querySelectorAll('tr[data-group="' + id + '"]');
|
||||||
|
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Extend HtmlExportService with groupMembers parameter and expandable group pills</name>
|
||||||
|
<files>
|
||||||
|
SharepointToolbox/Services/Export/HtmlExportService.cs,
|
||||||
|
SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- RPT-01-a: BuildHtml with no groupMembers (null/omitted) produces output identical to pre-Phase 17
|
||||||
|
- RPT-01-b: BuildHtml with groupMembers containing a group name renders clickable pill with onclick="toggleGroup('grpmem0')" and class "group-expandable"
|
||||||
|
- RPT-01-c: BuildHtml with resolved members renders hidden sub-row (data-group="grpmem0", display:none) containing member display names
|
||||||
|
- RPT-01-d: BuildHtml with empty member list (resolution failed) renders sub-row with "members unavailable" italic label
|
||||||
|
- RPT-01-e: BuildHtml output contains "function toggleGroup" in inline JS
|
||||||
|
- RPT-01-f: Simplified BuildHtml overload also accepts groupMembers and renders expandable pills identically
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. Add `groupMembers` optional parameter to BOTH `BuildHtml` overloads and BOTH `WriteAsync` methods:
|
||||||
|
```csharp
|
||||||
|
public string BuildHtml(IReadOnlyList<PermissionEntry> entries,
|
||||||
|
ReportBranding? branding = null,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||||
|
|
||||||
|
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath,
|
||||||
|
CancellationToken ct, ReportBranding? branding = null,
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
|
||||||
|
```
|
||||||
|
Same for the `SimplifiedPermissionEntry` overloads. `WriteAsync` passes `groupMembers` through to `BuildHtml`.
|
||||||
|
|
||||||
|
2. Add CSS for `.group-expandable` in the inline `<style>` block (both overloads):
|
||||||
|
```css
|
||||||
|
.group-expandable { cursor: pointer; }
|
||||||
|
.group-expandable:hover { opacity: 0.8; }
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Modify the user-pill rendering loop in BOTH `BuildHtml` overloads. The logic:
|
||||||
|
- Track a `int grpMemIdx = 0` counter and a `StringBuilder memberSubRows` outside the foreach loop
|
||||||
|
- For each pill: check if `entry.PrincipalType == "SharePointGroup"` AND `groupMembers != null` AND `groupMembers.TryGetValue(name, out var members)`:
|
||||||
|
- YES with `members.Count > 0`: render `<span class="user-pill group-expandable" onclick="toggleGroup('grpmem{idx}')">Name ▼</span>` + append hidden member sub-row to `memberSubRows`
|
||||||
|
- YES with `members.Count == 0`: render same expandable pill + append sub-row with `<em style="color:#888">members unavailable</em>`
|
||||||
|
- NO (not in dict or groupMembers is null): render existing plain pill (no change)
|
||||||
|
- After `</tr>`, append `memberSubRows` content and clear it
|
||||||
|
- Member sub-row format: `<tr data-group="grpmem{idx}" style="display:none"><td colspan="7" style="padding-left:2em;font-size:.8rem;color:#555">Alice <alice@co.com> • Bob <bob@co.com></td></tr>`
|
||||||
|
|
||||||
|
4. Add `toggleGroup()` JS function to the inline `<script>` block (both overloads), right after `filterTable()`:
|
||||||
|
```javascript
|
||||||
|
function toggleGroup(id) {
|
||||||
|
var rows = document.querySelectorAll('tr[data-group="' + id + '"]');
|
||||||
|
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
NOTE: Also update the `filterTable()` function to skip hidden member sub-rows (rows with `data-group` attribute) so they are not shown/hidden by the text filter. Add a guard: `if (row.hasAttribute('data-group')) return;` at the start of the forEach callback. Use `rows.forEach(function(row) { if (row.hasAttribute('data-group')) return; ... })`.
|
||||||
|
|
||||||
|
5. Write tests in `HtmlExportServiceTests.cs`:
|
||||||
|
- `BuildHtml_NoGroupMembers_IdenticalToDefault`: call BuildHtml(entries) and BuildHtml(entries, null, null) — output must match
|
||||||
|
- `BuildHtml_WithGroupMembers_RendersExpandablePill`: create a PermissionEntry with PrincipalType="SharePointGroup" and Users="Site Members", pass groupMembers dict with "Site Members" -> [ResolvedMember("Alice", "alice@co.com")], assert output contains `onclick="toggleGroup('grpmem0')"` and `class="user-pill group-expandable"`
|
||||||
|
- `BuildHtml_WithGroupMembers_RendersHiddenMemberSubRow`: same setup, assert output contains `data-group="grpmem0"` and `display:none` and `Alice` and `alice@co.com`
|
||||||
|
- `BuildHtml_WithEmptyMemberList_RendersMembersUnavailable`: pass groupMembers with "Site Members" -> empty list, assert output contains `members unavailable`
|
||||||
|
- `BuildHtml_ContainsToggleGroupJs`: assert output contains `function toggleGroup`
|
||||||
|
- `BuildHtml_Simplified_WithGroupMembers_RendersExpandablePill`: same test for the simplified overload
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests" --no-build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Both BuildHtml overloads render expandable group pills with toggleGroup JS, backward compatibility preserved when groupMembers is null, 6+ new tests pass</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Wire PermissionsViewModel to call ISharePointGroupResolver before HTML export</name>
|
||||||
|
<files>SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs</files>
|
||||||
|
<action>
|
||||||
|
1. Add `ISharePointGroupResolver?` field and inject it via constructor:
|
||||||
|
- Add `private readonly ISharePointGroupResolver? _groupResolver;` field
|
||||||
|
- Main constructor: add `ISharePointGroupResolver? groupResolver = null` as the LAST parameter (optional so existing DI still works if resolver not registered yet — but it will be registered from Plan 01)
|
||||||
|
- Assign `_groupResolver = groupResolver;`
|
||||||
|
- Test constructor: no change needed (resolver is null by default = no group expansion in tests)
|
||||||
|
|
||||||
|
2. Modify `ExportHtmlAsync()` to resolve groups before export:
|
||||||
|
```csharp
|
||||||
|
// After branding resolution, before WriteAsync calls:
|
||||||
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null;
|
||||||
|
if (_groupResolver != null && Results.Count > 0)
|
||||||
|
{
|
||||||
|
var groupNames = Results
|
||||||
|
.Where(r => r.PrincipalType == "SharePointGroup")
|
||||||
|
.SelectMany(r => r.Users.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
.Select(n => n.Trim())
|
||||||
|
.Where(n => n.Length > 0)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (groupNames.Count > 0 && _currentProfile != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ctx = await _sessionManager.GetOrCreateContextAsync(
|
||||||
|
_currentProfile, CancellationToken.None);
|
||||||
|
groupMembers = await _groupResolver.ResolveGroupsAsync(
|
||||||
|
ctx, _currentProfile.ClientId, groupNames, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Group resolution failed — exporting without member expansion.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Update both WriteAsync call sites to pass `groupMembers`:
|
||||||
|
```csharp
|
||||||
|
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
|
||||||
|
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding, groupMembers);
|
||||||
|
else
|
||||||
|
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding, groupMembers);
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add `using SharepointToolbox.Core.Models;` if not already present (for `ResolvedMember`).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>dotnet build --no-restore 2>&1 | tail -5</automated>
|
||||||
|
</verify>
|
||||||
|
<done>PermissionsViewModel collects group names from Results, calls ISharePointGroupResolver, passes resolved dict to HtmlExportService.WriteAsync for both standard and simplified paths, gracefully handles resolution failure</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `dotnet build` — 0 errors, 0 warnings
|
||||||
|
- `dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests"` — all tests pass (including 6+ new group expansion tests)
|
||||||
|
- `dotnet test` — full suite green (no regressions)
|
||||||
|
- HTML output with groupMembers=null is identical to pre-Phase 17 output
|
||||||
|
- HTML output with groupMembers contains clickable group pills and hidden member sub-rows
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- HtmlExportService both overloads accept optional groupMembers parameter
|
||||||
|
- Group pills render as expandable with toggleGroup JS when groupMembers is provided
|
||||||
|
- Empty member lists show "members unavailable" label
|
||||||
|
- Null/missing groupMembers preserves exact pre-Phase 17 output
|
||||||
|
- PermissionsViewModel resolves groups before export and passes dict to service
|
||||||
|
- All new tests green, full suite green
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/17-group-expansion-html-reports/17-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user