docs(02-permissions): create phase 2 plan — 7 plans across 4 waves

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-02 13:38:09 +02:00
parent 031a7dbc0f
commit 55819bd059
8 changed files with 1768 additions and 2 deletions

View File

@@ -60,7 +60,16 @@ Plans:
3. User can export the permissions results to a CSV file with all raw permission data
4. User can export the permissions results to an interactive HTML report where rows are sortable, filterable, and groupable by user
5. Scanning a library with more than 5,000 items completes successfully — the tool paginates automatically and does not silently truncate or fail
**Plans**: TBD
**Plans**: 7 plans
Plans:
- [ ] 02-01-PLAN.md — Wave 0: test scaffolds (PermissionsService, ViewModel, classification, CSV, HTML export tests) + PermissionEntryHelper
- [ ] 02-02-PLAN.md — Core models + PermissionsService scan engine (PermissionEntry, ScanOptions, IPermissionsService, PermissionsService)
- [ ] 02-03-PLAN.md — SiteListService: tenant admin site listing for multi-site picker (ISiteListService, SiteListService, SiteInfo)
- [ ] 02-04-PLAN.md — Export services: CsvExportService (with row merging) + HtmlExportService (self-contained HTML)
- [ ] 02-05-PLAN.md — Localization: 15 Phase 2 EN/FR keys in Strings.resx, Strings.fr.resx, Strings.Designer.cs
- [ ] 02-06-PLAN.md — PermissionsViewModel + SitePickerDialog (XAML + code-behind)
- [ ] 02-07-PLAN.md — DI wiring + PermissionsView XAML + MainWindow tab replacement + visual checkpoint
### Phase 3: Storage and File Operations
**Goal**: Users can view and export storage metrics per site and library, search for files across sites using multiple criteria, and detect duplicate files and folders — all with consistent export options and no silent failures on large datasets.
@@ -105,7 +114,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Foundation | 8/8 | Complete | 2026-04-02 |
| 2. Permissions | 0/? | Not started | - |
| 2. Permissions | 0/7 | Not started | - |
| 3. Storage and File Operations | 0/? | Not started | - |
| 4. Bulk Operations and Provisioning | 0/? | Not started | - |
| 5. Distribution and Hardening | 0/? | Not started | - |

View File

@@ -0,0 +1,224 @@
---
phase: 02-permissions
plan: 01
type: execute
wave: 0
depends_on: []
files_modified:
- SharepointToolbox.Tests/Services/PermissionsServiceTests.cs
- SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs
- SharepointToolbox.Tests/Services/PermissionEntryClassificationTests.cs
- SharepointToolbox.Tests/Services/Export/CsvExportServiceTests.cs
- SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs
autonomous: true
requirements:
- PERM-01
- PERM-02
- PERM-03
- PERM-04
- PERM-05
- PERM-06
must_haves:
truths:
- "Running the test suite produces no compilation errors — all test stubs compile against not-yet-existing types using forward-declared interfaces"
- "Each test file contains at least one [Fact] method that is marked [Fact(Skip=...)] or calls a stub that returns a known value — no test file is empty"
- "dotnet test reports N tests found (not 0) after Wave 0 plans complete"
artifacts:
- path: "SharepointToolbox.Tests/Services/PermissionsServiceTests.cs"
provides: "Test stubs for PERM-01 and PERM-04"
- path: "SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs"
provides: "Test stubs for PERM-02 multi-site loop"
- path: "SharepointToolbox.Tests/Services/PermissionEntryClassificationTests.cs"
provides: "Test stubs for PERM-03 external user detection"
- path: "SharepointToolbox.Tests/Services/Export/CsvExportServiceTests.cs"
provides: "Test stubs for PERM-05 CSV output"
- path: "SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs"
provides: "Test stubs for PERM-06 HTML output"
key_links:
- from: "PermissionsServiceTests.cs"
to: "IPermissionsService"
via: "mock interface"
pattern: "IPermissionsService"
- from: "PermissionsViewModelTests.cs"
to: "IPermissionsService"
via: "mock injection"
pattern: "IPermissionsService"
---
<objective>
Create the Wave 0 test scaffold: all test files needed so that every implementation task in subsequent plans has an automated verify command that references a real test class. Tests are failing stubs (the types they reference do not exist yet), but they must compile once the interfaces and models are defined in Plan 02.
Purpose: Nyquist compliance — no implementation task is written without a prior test. Tests define the contract, implementation fills it.
Output: 5 test files covering PERM-01 through PERM-06 (PERM-07 already covered by Phase 1 SharePointPaginationHelperTests).
</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/phases/02-permissions/02-RESEARCH.md
@.planning/phases/02-permissions/02-VALIDATION.md
<interfaces>
<!-- Key types from Phase 1 that tests will reference. -->
<!-- These are the contracts — executor should use these directly. -->
From SharepointToolbox/Core/Models/OperationProgress.cs:
```csharp
namespace SharepointToolbox.Core.Models;
public record OperationProgress(int Current, int Total, string Message)
{
public static OperationProgress Indeterminate(string message) => new(0, 0, message);
}
```
Types that WILL EXIST after Plan 02 (write stubs that reference these — they compile once Plan 02 runs):
```csharp
// SharepointToolbox/Core/Models/PermissionEntry.cs
namespace SharepointToolbox.Core.Models;
public record PermissionEntry(
string ObjectType, string Title, string Url,
bool HasUniquePermissions, string Users, string UserLogins,
string PermissionLevels, string GrantedThrough, string PrincipalType);
// SharepointToolbox/Core/Models/ScanOptions.cs
namespace SharepointToolbox.Core.Models;
public record ScanOptions(
bool IncludeInherited = false, bool ScanFolders = true,
int FolderDepth = 1, bool IncludeSubsites = false);
// SharepointToolbox/Services/IPermissionsService.cs
namespace SharepointToolbox.Services;
public interface IPermissionsService
{
Task<IReadOnlyList<PermissionEntry>> ScanSiteAsync(
Microsoft.SharePoint.Client.ClientContext ctx,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
// SharepointToolbox/Services/Export/CsvExportService.cs
namespace SharepointToolbox.Services.Export;
public class CsvExportService
{
public string BuildCsv(IReadOnlyList<PermissionEntry> entries);
public Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct);
}
// SharepointToolbox/Services/Export/HtmlExportService.cs
namespace SharepointToolbox.Services.Export;
public class HtmlExportService
{
public string BuildHtml(IReadOnlyList<PermissionEntry> entries);
public Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct);
}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Scaffold PermissionsService and ViewModel test stubs</name>
<files>
SharepointToolbox.Tests/Services/PermissionsServiceTests.cs
SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs
SharepointToolbox.Tests/Services/PermissionEntryClassificationTests.cs
</files>
<behavior>
PermissionsServiceTests:
- Test: ScanSiteAsync_WithIncludeInheritedFalse_SkipsItemsWithoutUniquePermissions — verifies PERM-04 (stub: [Fact(Skip="Requires Plan 02 implementation")])
- Test: ScanSiteAsync_ReturnsPermissionEntries_ForMockedSite — verifies PERM-01 (stub)
PermissionsViewModelTests:
- Test: StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl — verifies PERM-02 (stub)
PermissionEntryClassificationTests:
- Test: IsExternalUser_WithExtHashInLoginName_ReturnsTrue — verifies PERM-03 (real test, no stub needed — pure static logic)
- Test: IsExternalUser_WithNormalLoginName_ReturnsFalse
- Test: PermissionEntry_FiltersOutLimitedAccess_WhenOnlyPermissionIsLimitedAccess
</behavior>
<action>
Create three test files. Each file uses `using SharepointToolbox.Core.Models;` and `using SharepointToolbox.Services;`.
For PermissionsServiceTests.cs and PermissionsViewModelTests.cs: write stubs with `[Fact(Skip = "Requires live CSOM context — covered by Plan 02 implementation")]`. These compile against `IPermissionsService` which will exist after Plan 02.
For PermissionEntryClassificationTests.cs: write REAL [Fact] tests that test static helper methods. Define a static helper class `PermissionEntryHelper` in the MAIN project at `SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs` with:
- `static bool IsExternalUser(string loginName)` — returns `loginName.Contains("#EXT#", StringComparison.OrdinalIgnoreCase)`
- `static IReadOnlyList<string> FilterPermissionLevels(IEnumerable<string> levels)` — removes "Limited Access", returns remaining; returns empty list if all removed
- `static bool IsSharingLinksGroup(string loginName)` — returns `loginName.StartsWith("SharingLinks.", StringComparison.OrdinalIgnoreCase) || loginName.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase)`
These are pure functions — tests can run immediately without stubs. Use `Assert.True`, `Assert.False`, `Assert.Empty`, `Assert.Equal`.
Test file namespace: `SharepointToolbox.Tests.Services` for service tests, `SharepointToolbox.Tests.ViewModels` for VM tests.
Also create `SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs` in the main project so the classification tests compile immediately.
</action>
<verify>
<automated>dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~PermissionEntryClassificationTests" -x</automated>
</verify>
<done>PermissionEntryClassificationTests pass (3 tests green). PermissionsServiceTests and PermissionsViewModelTests compile but skip. No new test failures in the existing suite.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Scaffold export service test stubs</name>
<files>
SharepointToolbox.Tests/Services/Export/CsvExportServiceTests.cs
SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs
</files>
<behavior>
CsvExportServiceTests:
- Test: BuildCsv_WithKnownEntries_ProducesHeaderRow — verifies CSV has "Object,Title,URL,HasUniquePermissions,Users,UserLogins,Type,Permissions,GrantedThrough" header
- Test: BuildCsv_WithDuplicateUserPermissionGrantedThrough_MergesLocations — verifies Merge-PermissionRows behavior: two entries with same Users+PermissionLevels+GrantedThrough but different URLs are merged into one row with URLs pipe-joined
- Test: BuildCsv_WithEmptyList_ReturnsHeaderOnly
HtmlExportServiceTests:
- Test: BuildHtml_WithKnownEntries_ContainsUserNames — verifies user names appear in HTML output
- Test: BuildHtml_WithEmptyList_ReturnsValidHtml — HTML still renders without entries
- Test: BuildHtml_WithExternalUser_ContainsExtHashMarker — verifies external users are distinguishable in HTML
All tests are REAL [Fact] tests (not stubs) — they will fail until CsvExportService and HtmlExportService are implemented in Plan 03. Write them now so the automated verify in Plan 03 is already defined.
</behavior>
<action>
Create the `SharepointToolbox.Tests/Services/Export/` directory.
For both test files: reference `SharepointToolbox.Services.Export` namespace and `SharepointToolbox.Core.Models.PermissionEntry`.
In CsvExportServiceTests.cs: construct sample PermissionEntry instances (hardcoded test data) and call `new CsvExportService().BuildCsv(entries)`. Assert on the resulting string.
Sample data for merge test: two entries where Users="alice@contoso.com", PermissionLevels="Contribute", GrantedThrough="Direct Permissions", but with Url="https://contoso.sharepoint.com/sites/A" and "…/sites/B". Merged row must contain "sites/A | sites/B" in URL column.
In HtmlExportServiceTests.cs: construct a PermissionEntry with Users="Bob Smith", UserLogins="bob@contoso.com", and assert the output HTML contains "Bob Smith". For external user test: UserLogins="ext_user_domain.com#EXT#@contoso.onmicrosoft.com" and assert HTML contains "EXT" or a distinguishing marker.
These tests will initially FAIL with "type not found" until Plan 03 creates the services. That is expected — they become the automated verify for Plan 03.
Namespace: `SharepointToolbox.Tests.Services.Export`.
</action>
<verify>
<automated>dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x 2>&1 | tail -20</automated>
</verify>
<done>All existing tests still pass. PermissionEntryClassificationTests (3 tests) pass. CsvExportServiceTests and HtmlExportServiceTests compile but fail with "type not found" — expected until Plan 03. Full test count visible in output.</done>
</task>
</tasks>
<verification>
After both tasks:
- `dotnet test SharepointToolbox.slnx` — existing 44+1 tests still pass (no regressions)
- `dotnet test --filter "FullyQualifiedName~PermissionEntryClassificationTests"` — 3 tests green
- Test files for PermissionsService, PermissionsViewModel, CsvExport, HtmlExport exist on disk
- `PermissionEntryHelper.cs` exists in `SharepointToolbox/Core/Helpers/`
</verification>
<success_criteria>
- PermissionEntryHelper (IsExternalUser, FilterPermissionLevels, IsSharingLinksGroup) implemented and all 3 classification tests pass
- 5 test scaffold files exist — each references types in namespaces that Plan 02/03 will create
- No existing Phase 1 tests broken
- Every subsequent plan's automated verify command points to a test class that exists in one of these 5 files
</success_criteria>
<output>
After completion, create `.planning/phases/02-permissions/02-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,307 @@
---
phase: 02-permissions
plan: 02
type: execute
wave: 1
depends_on:
- 02-01
files_modified:
- SharepointToolbox/Core/Models/PermissionEntry.cs
- SharepointToolbox/Core/Models/ScanOptions.cs
- SharepointToolbox/Services/IPermissionsService.cs
- SharepointToolbox/Services/PermissionsService.cs
autonomous: true
requirements:
- PERM-01
- PERM-03
- PERM-04
- PERM-07
must_haves:
truths:
- "PermissionsService.ScanSiteAsync returns at least one PermissionEntry for a site that has permission assignments (verified in test via mock)"
- "With IncludeInherited=false, items where HasUniqueRoleAssignments=false produce zero PermissionEntry rows"
- "External users (LoginName contains #EXT#) are represented with PrincipalType='External User' in the returned entries"
- "Limited Access permission level is filtered out — entries containing only Limited Access are dropped entirely"
- "System lists (App Packages, Workflow History, etc.) produce zero entries"
- "Folder enumeration always uses SharePointPaginationHelper.GetAllItemsAsync — never raw list enumeration"
artifacts:
- path: "SharepointToolbox/Core/Models/PermissionEntry.cs"
provides: "Flat record for one permission assignment"
exports: ["PermissionEntry"]
- path: "SharepointToolbox/Core/Models/ScanOptions.cs"
provides: "Immutable scan configuration value object"
exports: ["ScanOptions"]
- path: "SharepointToolbox/Services/IPermissionsService.cs"
provides: "Interface enabling ViewModel mocking"
exports: ["IPermissionsService"]
- path: "SharepointToolbox/Services/PermissionsService.cs"
provides: "CSOM scan engine — port of PS Generate-PnPSitePermissionRpt"
exports: ["PermissionsService"]
key_links:
- from: "PermissionsService.cs"
to: "SharePointPaginationHelper.GetAllItemsAsync"
via: "folder enumeration"
pattern: "SharePointPaginationHelper\\.GetAllItemsAsync"
- from: "PermissionsService.cs"
to: "ExecuteQueryRetryHelper.ExecuteQueryRetryAsync"
via: "CSOM round-trips"
pattern: "ExecuteQueryRetryHelper\\.ExecuteQueryRetryAsync"
- from: "PermissionsService.cs"
to: "PermissionEntryHelper.IsExternalUser"
via: "user classification"
pattern: "PermissionEntryHelper\\.IsExternalUser"
---
<objective>
Create the core data models and the `PermissionsService` scan engine — a faithful C# port of the PowerShell `Generate-PnPSitePermissionRpt` / `Get-PnPPermissions` functions. This is the most technically dense plan in Phase 2; every other plan depends on these types and this service.
Purpose: Establish the contracts (PermissionEntry, ScanOptions, IPermissionsService) that all subsequent plans build against, then implement the scan logic.
Output: 4 files — 2 models, 1 interface, 1 service implementation.
</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/phases/02-permissions/02-RESEARCH.md
<interfaces>
<!-- Phase 1 helpers that PermissionsService MUST use. -->
From SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs:
```csharp
namespace SharepointToolbox.Core.Helpers;
public static class SharePointPaginationHelper
{
// Yields all items in a SharePoint list using ListItemCollectionPosition pagination.
// ALWAYS use this for folder/item enumeration — never raw list enumeration.
public static async IAsyncEnumerable<ListItem> GetAllItemsAsync(
ClientContext ctx,
List list,
CamlQuery baseQuery,
IProgress<OperationProgress> progress,
[EnumeratorCancellation] CancellationToken ct);
}
```
From SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs:
```csharp
namespace SharepointToolbox.Core.Helpers;
public static class ExecuteQueryRetryHelper
{
// Executes ctx.ExecuteQueryAsync with automatic retry on 429/503.
// ALWAYS use instead of ctx.ExecuteQueryAsync directly.
public static async Task ExecuteQueryRetryAsync(
ClientContext ctx,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
```
From SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs (created in Plan 01):
```csharp
namespace SharepointToolbox.Core.Helpers;
public static class PermissionEntryHelper
{
public static bool IsExternalUser(string loginName);
public static IReadOnlyList<string> FilterPermissionLevels(IEnumerable<string> levels);
public static bool IsSharingLinksGroup(string loginName);
}
```
From SharepointToolbox/Services/SessionManager.cs:
```csharp
// ClientContext is obtained via SessionManager.GetOrCreateContextAsync(profile, ct)
// PermissionsService receives an already-obtained ClientContext — it never calls SessionManager directly.
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Define data models and IPermissionsService interface</name>
<files>
SharepointToolbox/Core/Models/PermissionEntry.cs
SharepointToolbox/Core/Models/ScanOptions.cs
SharepointToolbox/Services/IPermissionsService.cs
</files>
<behavior>
- PermissionEntry is a record with 9 string/bool positional fields matching the PS reference `$entry` object (ObjectType, Title, Url, HasUniquePermissions, Users, UserLogins, PermissionLevels, GrantedThrough, PrincipalType)
- ScanOptions is a record with defaults: IncludeInherited=false, ScanFolders=true, FolderDepth=1, IncludeSubsites=false
- IPermissionsService has exactly one method: ScanSiteAsync returning Task&lt;IReadOnlyList&lt;PermissionEntry&gt;&gt;
- Existing Plan 01 test stubs that reference these types now compile (no more "type not found" errors)
</behavior>
<action>
Create PermissionEntry.cs in `SharepointToolbox/Core/Models/`:
```csharp
namespace SharepointToolbox.Core.Models;
public record PermissionEntry(
string ObjectType, // "Site Collection" | "Site" | "List" | "Folder"
string Title,
string Url,
bool HasUniquePermissions,
string Users, // Semicolon-joined display names
string UserLogins, // Semicolon-joined login names
string PermissionLevels, // Semicolon-joined role names (Limited Access already removed)
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: <name>"
string PrincipalType // "SharePointGroup" | "User" | "External User"
);
```
Create ScanOptions.cs in `SharepointToolbox/Core/Models/`:
```csharp
namespace SharepointToolbox.Core.Models;
public record ScanOptions(
bool IncludeInherited = false,
bool ScanFolders = true,
int FolderDepth = 1,
bool IncludeSubsites = false
);
```
Create IPermissionsService.cs in `SharepointToolbox/Services/`:
```csharp
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface IPermissionsService
{
Task<IReadOnlyList<PermissionEntry>> ScanSiteAsync(
ClientContext ctx,
ScanOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
```
</action>
<verify>
<automated>dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~PermissionsServiceTests" -x 2>&1 | tail -10</automated>
</verify>
<done>PermissionsServiceTests compiles (no CS0246 errors). Tests that reference IPermissionsService now skip cleanly rather than failing to compile. dotnet build produces 0 errors.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement PermissionsService scan engine</name>
<files>
SharepointToolbox/Services/PermissionsService.cs
</files>
<behavior>
- ScanSiteAsync returns PermissionEntry rows for Site Collection admins, Web, Lists, and (if ScanFolders) Folders
- With IncludeInherited=false: objects where HasUniqueRoleAssignments=false produce zero rows
- With IncludeInherited=true: all objects regardless of inheritance produce rows
- SharingLinks groups and "Limited Access System Group" are skipped entirely
- Limited Access permission level is removed from PermissionLevels; if all levels removed, the row is dropped
- External users (LoginName contains #EXT#) have PrincipalType="External User"
- System lists (see ExcludedLists set) produce zero entries
- Folder enumeration uses SharePointPaginationHelper.GetAllItemsAsync (never raw iteration)
- Every CSOM round-trip uses ExecuteQueryRetryHelper.ExecuteQueryRetryAsync
- CSOM Load uses batched Include() in one call per object (not N+1)
</behavior>
<action>
Create `SharepointToolbox/Services/PermissionsService.cs`. This is a faithful port of PS `Generate-PnPSitePermissionRpt` and `Get-PnPPermissions` (PS reference lines 1361-1989).
Class structure:
```csharp
public class PermissionsService : IPermissionsService
{
// Port of PS lines 1914-1926
private static readonly HashSet<string> ExcludedLists = new(StringComparer.OrdinalIgnoreCase)
{ "Access Requests", "App Packages", "appdata", "appfiles", "Apps in Testing",
"Cache Profiles", "Composed Looks", "Content and Structure Reports",
"Content type publishing error log", "Converted Forms", "Device Channels",
"Form Templates", "fpdatasources", "List Template Gallery",
"Long Running Operation Status", "Maintenance Log Library", "Images",
"site collection images", "Master Docs", "Master Page Gallery", "MicroFeed",
"NintexFormXml", "Quick Deploy Items", "Relationships List", "Reusable Content",
"Reporting Metadata", "Reporting Templates", "Search Config List", "Site Assets",
"Preservation Hold Library", "Site Pages", "Solution Gallery", "Style Library",
"Suggested Content Browser Locations", "Theme Gallery", "TaxonomyHiddenList",
"User Information List", "Web Part Gallery", "wfpub", "wfsvc",
"Workflow History", "Workflow Tasks", "Pages" };
public async Task<IReadOnlyList<PermissionEntry>> ScanSiteAsync(
ClientContext ctx, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct)
{ ... }
// Private: get site collection admins → PermissionEntry with ObjectType="Site Collection"
private async Task<IEnumerable<PermissionEntry>> GetSiteCollectionAdminsAsync(
ClientContext ctx, IProgress<OperationProgress> progress, CancellationToken ct) { ... }
// Private: port of Get-PnPPermissions for a Web object
private async Task<IEnumerable<PermissionEntry>> GetWebPermissionsAsync(
ClientContext ctx, Web web, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct) { ... }
// Private: port of Get-PnPPermissions for a List object
private async Task<IEnumerable<PermissionEntry>> GetListPermissionsAsync(
ClientContext ctx, List list, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct) { ... }
// Private: enumerate folders in list via SharePointPaginationHelper, get permissions per folder
private async Task<IEnumerable<PermissionEntry>> GetFolderPermissionsAsync(
ClientContext ctx, List list, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct) { ... }
// Private: core per-object extractor — batched ctx.Load + ExecuteQueryRetryAsync
private async Task<IEnumerable<PermissionEntry>> ExtractPermissionsAsync(
ClientContext ctx, SecurableObject obj, string objectType, string title,
string url, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct) { ... }
}
```
Implementation notes:
- CSOM batched load pattern (one round-trip per object):
```csharp
ctx.Load(obj,
o => o.HasUniqueRoleAssignments,
o => o.RoleAssignments.Include(
ra => ra.Member.Title, ra => ra.Member.Email,
ra => ra.Member.LoginName, ra => ra.Member.PrincipalType,
ra => ra.RoleDefinitionBindings.Include(rdb => rdb.Name)));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
```
- Skip if !HasUniqueRoleAssignments when IncludeInherited=false
- For each RoleAssignment: skip if IsSharingLinksGroup(Member.LoginName)
- Build permission levels list, call FilterPermissionLevels, skip row if empty
- Determine PrincipalType: if IsExternalUser(LoginName) → "External User"; else if Member.PrincipalType == PrincipalType.SharePointGroup → "SharePointGroup"; else → "User"
- GrantedThrough: if PrincipalType is SharePointGroup → "SharePoint Group: {Member.Title}"; else → "Direct Permissions"
- For Folder enumeration: CAML query is `<OrderBy><FieldRef Name='ID'/></OrderBy>` with ViewAttributes `Scope='RecursiveAll'` limited by FolderDepth (if FolderDepth != 999, filter by folder depth level)
- Site collection admins: `ctx.Load(ctx.Web, w => w.SiteUsers)` then filter where `siteUser.IsSiteAdmin == true`
- FolderDepth: folders at depth > options.FolderDepth are skipped (depth = URL segment count relative to list root)
- ct must be checked via `ct.ThrowIfCancellationRequested()` at the start of each private method
Namespace: `SharepointToolbox.Services`
Usings: `Microsoft.SharePoint.Client`, `SharepointToolbox.Core.Models`, `SharepointToolbox.Core.Helpers`
</action>
<verify>
<automated>dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~PermissionsServiceTests|FullyQualifiedName~PermissionEntryClassificationTests" -x</automated>
</verify>
<done>Classification tests (3) still pass. PermissionsServiceTests skips cleanly (no compile errors). `dotnet build SharepointToolbox.slnx` succeeds with 0 errors. The service implements IPermissionsService.</done>
</task>
</tasks>
<verification>
- `dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx` → 0 errors
- `dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx` → all Phase 1 tests pass, classification tests pass, new stubs skip
- PermissionsService.cs references SharePointPaginationHelper.GetAllItemsAsync for folder enumeration (grep verifiable)
- PermissionsService implements IPermissionsService (grep: `class PermissionsService : IPermissionsService`)
</verification>
<success_criteria>
- PermissionEntry, ScanOptions, IPermissionsService defined and exported
- PermissionsService fully implements the scan logic (all 5 scan paths: site collection admins, web, lists, folders, subsites)
- All Phase 1 tests remain green
- CsvExportServiceTests and HtmlExportServiceTests now compile (they reference PermissionEntry which exists)
</success_criteria>
<output>
After completion, create `.planning/phases/02-permissions/02-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,221 @@
---
phase: 02-permissions
plan: 03
type: execute
wave: 1
depends_on:
- 02-01
files_modified:
- SharepointToolbox/Services/SiteListService.cs
- SharepointToolbox/Services/ISiteListService.cs
autonomous: true
requirements:
- PERM-02
must_haves:
truths:
- "SiteListService.GetSitesAsync connects to the -admin URL and returns a list of site URLs and titles"
- "When the user does not have SharePoint admin rights, GetSitesAsync throws or returns a structured error — it does not return an empty list silently"
- "Admin URL is correctly derived: https://contoso.sharepoint.com → https://contoso-admin.sharepoint.com"
artifacts:
- path: "SharepointToolbox/Services/ISiteListService.cs"
provides: "Interface for ViewModel mocking"
exports: ["ISiteListService"]
- path: "SharepointToolbox/Services/SiteListService.cs"
provides: "Tenant admin API wrapper for listing all sites"
exports: ["SiteListService"]
key_links:
- from: "SiteListService.cs"
to: "SessionManager.GetOrCreateContextAsync"
via: "admin context acquisition"
pattern: "GetOrCreateContextAsync"
- from: "SiteListService.cs"
to: "Microsoft.Online.SharePoint.TenantAdministration.Tenant"
via: "GetSitePropertiesFromSharePoint"
pattern: "Tenant"
---
<objective>
Create `SiteListService` — the tenant admin API wrapper that loads the full list of SharePoint sites for the multi-site picker (PERM-02). This runs in Wave 1 parallel to Plan 02 because it shares no files with the scan engine.
Purpose: The SitePickerDialog (Plan 06) needs a service that can enumerate all sites in a tenant via the SharePoint admin URL. This plan creates that service.
Output: ISiteListService interface + SiteListService implementation.
</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/phases/02-permissions/02-RESEARCH.md
<interfaces>
<!-- Key contracts from Phase 1 -->
From SharepointToolbox/Services/SessionManager.cs:
```csharp
// SessionManager.GetOrCreateContextAsync(TenantProfile profile, CancellationToken ct)
// To get the admin context, pass a TenantProfile whose TenantUrl is the admin URL.
// SessionManager treats admin URL as a separate cache key — it will trigger a new
// interactive login if not already cached.
public async Task<ClientContext> GetOrCreateContextAsync(TenantProfile profile, CancellationToken ct);
```
From SharepointToolbox/Core/Models/TenantProfile.cs:
```csharp
namespace SharepointToolbox.Core.Models;
public class TenantProfile
{
public string Name { get; set; } = string.Empty;
public string TenantUrl { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
}
```
Admin URL derivation (from PS reference line 333):
```csharp
// https://contoso.sharepoint.com → https://contoso-admin.sharepoint.com
static string DeriveAdminUrl(string tenantUrl)
=> Regex.Replace(tenantUrl.TrimEnd('/'),
@"(https://[^.]+)(\.sharepoint\.com)",
"$1-admin$2",
RegexOptions.IgnoreCase);
```
Tenant API (PnP.Framework 1.18.0 includes Microsoft.Online.SharePoint.TenantAdministration):
```csharp
// Requires connecting to the -admin URL
var tenant = new Tenant(adminCtx);
var siteProps = tenant.GetSitePropertiesFromSharePoint("", true);
adminCtx.Load(siteProps);
await adminCtx.ExecuteQueryAsync();
// Each SiteProperties has: .Url, .Title, .Status
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Implement ISiteListService and SiteListService</name>
<files>
SharepointToolbox/Services/ISiteListService.cs
SharepointToolbox/Services/SiteListService.cs
</files>
<behavior>
- ISiteListService.GetSitesAsync(TenantProfile profile, IProgress&lt;OperationProgress&gt; progress, CancellationToken ct) returns Task&lt;IReadOnlyList&lt;SiteInfo&gt;&gt;
- SiteInfo is a simple record with Url (string) and Title (string) — defined inline or in Core/Models
- SiteListService derives the admin URL from profile.TenantUrl using the Regex pattern
- SiteListService calls SessionManager.GetOrCreateContextAsync with a synthetic TenantProfile whose TenantUrl is the admin URL and ClientId matches the original profile
- On ServerException with "Access denied": wraps and rethrows as InvalidOperationException with message "Site listing requires SharePoint administrator permissions. Connect with an admin account."
- Returns only Active sites (Status == "Active") — skips OneDrive personal sites, redirect sites
- Progress is reported as indeterminate while the single query is running
</behavior>
<action>
First, add a `SiteInfo` record to `SharepointToolbox/Core/Models/SiteInfo.cs`:
```csharp
namespace SharepointToolbox.Core.Models;
public record SiteInfo(string Url, string Title);
```
Create `SharepointToolbox/Services/ISiteListService.cs`:
```csharp
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface ISiteListService
{
Task<IReadOnlyList<SiteInfo>> GetSitesAsync(
TenantProfile profile,
IProgress<OperationProgress> progress,
CancellationToken ct);
}
```
Create `SharepointToolbox/Services/SiteListService.cs`:
```csharp
public class SiteListService : ISiteListService
{
private readonly SessionManager _sessionManager;
public SiteListService(SessionManager sessionManager) { _sessionManager = sessionManager; }
public async Task<IReadOnlyList<SiteInfo>> GetSitesAsync(
TenantProfile profile, IProgress<OperationProgress> progress, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
progress.Report(OperationProgress.Indeterminate("Loading sites..."));
var adminUrl = DeriveAdminUrl(profile.TenantUrl);
var adminProfile = new TenantProfile { Name = profile.Name, TenantUrl = adminUrl, ClientId = profile.ClientId };
ClientContext adminCtx;
try { adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct); }
catch (ServerException ex) when (ex.Message.Contains("Access denied", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
"Site listing requires SharePoint administrator permissions. Connect with an admin account.", ex);
}
var tenant = new Tenant(adminCtx);
var siteProps = tenant.GetSitePropertiesFromSharePoint("", true);
adminCtx.Load(siteProps);
await adminCtx.ExecuteQueryAsync();
ct.ThrowIfCancellationRequested();
return siteProps
.Where(s => s.Status == "Active"
&& !s.Url.Contains("-my.sharepoint.com", StringComparison.OrdinalIgnoreCase))
.Select(s => new SiteInfo(s.Url, s.Title))
.OrderBy(s => s.Url)
.ToList();
}
internal static string DeriveAdminUrl(string tenantUrl)
=> Regex.Replace(tenantUrl.TrimEnd('/'),
@"(https://[^.]+)(\.sharepoint\.com)",
"$1-admin$2",
RegexOptions.IgnoreCase);
}
```
Usings: `Microsoft.Online.SharePoint.TenantAdministration`, `Microsoft.SharePoint.Client`, `SharepointToolbox.Core.Models`, `System.Text.RegularExpressions`.
Note: DeriveAdminUrl is `internal static` so it can be tested directly without needing a live tenant.
Also add a test for DeriveAdminUrl in `SharepointToolbox.Tests/Services/SiteListServiceTests.cs`:
```csharp
[Fact]
public void DeriveAdminUrl_WithStandardUrl_ReturnsAdminUrl()
{
var result = SiteListService.DeriveAdminUrl("https://contoso.sharepoint.com");
Assert.Equal("https://contoso-admin.sharepoint.com", result);
}
[Fact]
public void DeriveAdminUrl_WithTrailingSlash_ReturnsAdminUrl()
{
var result = SiteListService.DeriveAdminUrl("https://contoso.sharepoint.com/");
Assert.Equal("https://contoso-admin.sharepoint.com", result);
}
```
</action>
<verify>
<automated>dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SiteListServiceTests" -x</automated>
</verify>
<done>SiteListServiceTests: DeriveAdminUrl tests (2) pass. SiteListService and ISiteListService compile. dotnet build 0 errors.</done>
</task>
</tasks>
<verification>
- `dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx` → 0 errors
- `dotnet test --filter "FullyQualifiedName~SiteListServiceTests"` → 2 tests pass
- SiteListService.DeriveAdminUrl correctly transforms standard and trailing-slash URLs
- ISiteListService.GetSitesAsync signature matches the interface contract
</verification>
<success_criteria>
- ISiteListService and SiteListService exist and compile
- DeriveAdminUrl produces correct admin URL for standard and trailing-slash inputs (verified by automated tests)
- ServerException "Access denied" wraps to InvalidOperationException with actionable message
- SiteInfo model created and exported from Core/Models
</success_criteria>
<output>
After completion, create `.planning/phases/02-permissions/02-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,250 @@
---
phase: 02-permissions
plan: 04
type: execute
wave: 2
depends_on:
- 02-02
files_modified:
- SharepointToolbox/Services/Export/CsvExportService.cs
- SharepointToolbox/Services/Export/HtmlExportService.cs
autonomous: true
requirements:
- PERM-05
- PERM-06
must_haves:
truths:
- "CsvExportService.BuildCsv produces a valid CSV string with the correct 9-column header and one data row per merged permission entry"
- "Entries with identical Users + PermissionLevels + GrantedThrough but different URLs are merged into one row with pipe-joined URLs (Merge-PermissionRows port)"
- "HtmlExportService.BuildHtml produces a self-contained HTML file (no external CSS/JS dependencies) that contains all user display names from the input"
- "HTML report includes stats cards: Total Entries, Unique Permission Sets, Distinct Users/Groups"
- "CSV fields with commas or quotes are correctly escaped per RFC 4180"
artifacts:
- path: "SharepointToolbox/Services/Export/CsvExportService.cs"
provides: "Merges PermissionEntry rows and writes CSV"
exports: ["CsvExportService"]
- path: "SharepointToolbox/Services/Export/HtmlExportService.cs"
provides: "Generates self-contained interactive HTML report"
exports: ["HtmlExportService"]
key_links:
- from: "CsvExportService.cs"
to: "PermissionEntry"
via: "groups by (Users, PermissionLevels, GrantedThrough)"
pattern: "GroupBy"
- from: "HtmlExportService.cs"
to: "PermissionEntry"
via: "iterates all entries to build HTML rows"
pattern: "foreach.*PermissionEntry"
---
<objective>
Implement the two export services: `CsvExportService` (port of PS `Merge-PermissionRows` + `Export-Csv`) and `HtmlExportService` (port of PS `Export-PermissionsToHTML`). Both services consume `IReadOnlyList<PermissionEntry>` and write files.
Purpose: Deliver PERM-05 (CSV export) and PERM-06 (HTML export). These are pure data-transformation services with no UI dependency — they can be verified fully by the automated test stubs created in Plan 01.
Output: 2 export service files.
</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/phases/02-permissions/02-RESEARCH.md
<interfaces>
<!-- PermissionEntry defined in Plan 02 -->
From SharepointToolbox/Core/Models/PermissionEntry.cs:
```csharp
public record PermissionEntry(
string ObjectType, // "Site Collection" | "Site" | "List" | "Folder"
string Title,
string Url,
bool HasUniquePermissions,
string Users, // Semicolon-joined display names
string UserLogins, // Semicolon-joined login names
string PermissionLevels, // Semicolon-joined role names
string GrantedThrough, // "Direct Permissions" | "SharePoint Group: <name>"
string PrincipalType // "SharePointGroup" | "User" | "External User"
);
```
CSV merge logic (port of PS Merge-PermissionRows):
- Group by key: (Users, PermissionLevels, GrantedThrough)
- For each group: collect all Urls, join with " | "
- Collect all Titles, join with " | "
- Take first ObjectType, HasUniquePermissions from group
CSV columns (9 total): Object, Title, URL, HasUniquePermissions, Users, UserLogins, Type, Permissions, GrantedThrough
CSV escaping: enclose every field in double quotes, escape internal quotes by doubling them.
HTML report key features (port of PS Export-PermissionsToHTML):
- Stats cards: Total Entries (count of entries), Unique Permission Sets (count of distinct PermissionLevels values), Distinct Users/Groups (count of distinct users across all UserLogins)
- Filter input (vanilla JS filterTable())
- Type badge: color-coded span for ObjectType ("Site Collection"=blue, "Site"=green, "List"=yellow, "Folder"=gray)
- Unique vs Inherited badge per row (HasUniquePermissions → green "Unique", else gray "Inherited")
- User pills with data-email attribute for each login in UserLogins (split by ;)
- Self-contained: all CSS and JS inline in the HTML string — no external file dependencies
- Table columns: Object, Title, URL, Unique, Users, Permissions, Granted Through
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Implement CsvExportService</name>
<files>
SharepointToolbox/Services/Export/CsvExportService.cs
</files>
<behavior>
- BuildCsv(IReadOnlyList&lt;PermissionEntry&gt; entries) returns string
- Header row: Object,Title,URL,HasUniquePermissions,Users,UserLogins,Type,Permissions,GrantedThrough (all quoted)
- Merge rows: entries grouped by (Users, PermissionLevels, GrantedThrough) → one output row per group with URLs pipe-joined
- Fields with commas, double quotes, or newlines are wrapped in double quotes with internal quotes doubled
- WriteAsync(entries, filePath, ct) calls BuildCsv then writes UTF-8 with BOM (for Excel compatibility)
- The test from Plan 01 (BuildCsv_WithDuplicateUserPermissionGrantedThrough_MergesLocations) passes
</behavior>
<action>
Create `SharepointToolbox/Services/Export/` directory if it doesn't exist.
Create `SharepointToolbox/Services/Export/CsvExportService.cs`:
```csharp
namespace SharepointToolbox.Services.Export;
public class CsvExportService
{
private const string Header =
"\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
public string BuildCsv(IReadOnlyList<PermissionEntry> entries)
{
var sb = new StringBuilder();
sb.AppendLine(Header);
// Merge: group by (Users, PermissionLevels, GrantedThrough)
var merged = entries
.GroupBy(e => (e.Users, e.PermissionLevels, e.GrantedThrough))
.Select(g => new
{
ObjectType = g.First().ObjectType,
Title = string.Join(" | ", g.Select(e => e.Title).Distinct()),
Url = string.Join(" | ", g.Select(e => e.Url).Distinct()),
HasUnique = g.First().HasUniquePermissions,
Users = g.Key.Users,
UserLogins = g.First().UserLogins,
PrincipalType= g.First().PrincipalType,
Permissions = g.Key.PermissionLevels,
GrantedThrough = g.Key.GrantedThrough
});
foreach (var row in merged)
sb.AppendLine(string.Join(",", new[]
{
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins),
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.GrantedThrough)
}));
return sb.ToString();
}
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct)
{
var csv = BuildCsv(entries);
await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct);
}
private static string Csv(string value)
{
if (string.IsNullOrEmpty(value)) return "\"\"";
return $"\"{value.Replace("\"", "\"\"")}\"";
}
}
```
Usings: `System.IO`, `System.Text`, `SharepointToolbox.Core.Models`.
Namespace: `SharepointToolbox.Services.Export`.
</action>
<verify>
<automated>dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~CsvExportServiceTests" -x</automated>
</verify>
<done>All 3 CsvExportServiceTests pass (header row present, merge works, empty list returns header only). dotnet build 0 errors.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Implement HtmlExportService</name>
<files>
SharepointToolbox/Services/Export/HtmlExportService.cs
</files>
<behavior>
- BuildHtml(IReadOnlyList&lt;PermissionEntry&gt; entries) returns a self-contained HTML string
- Output contains user display names from the input (test: BuildHtml_WithKnownEntries_ContainsUserNames passes)
- Output contains all inline CSS and JS — no &lt;link&gt; or &lt;script src=...&gt; tags
- Stats cards reflect: Total Entries count, Unique Permission Sets (distinct PermissionLevels values), Distinct Users (distinct entries in UserLogins split by semicolon)
- Type badge CSS classes: site-coll, site, list, folder — color-coded
- Unique/Inherited badge based on HasUniquePermissions
- Filter input calls JS filterTable() on keyup — filters by any visible text in the row
- External user tag: if UserLogins contains "#EXT#", user pill gets class "external-user" and data-email attribute
- WriteAsync(entries, filePath, ct) writes UTF-8 (no BOM for HTML)
- The test from Plan 01 (BuildHtml_WithExternalUser_ContainsExtHashMarker) passes — HTML contains "external-user" class
</behavior>
<action>
Create `SharepointToolbox/Services/Export/HtmlExportService.cs`.
Structure the HTML report as a multi-line C# string literal inside BuildHtml(). Use `StringBuilder` to assemble:
1. HTML head (with inline CSS): table styles, badge styles (site-coll=blue, site=green, list=amber, folder=gray, unique=green, inherited=gray), user pill styles, external-user pill style (orange border), stats card styles, filter input style
2. Body open: h1 "SharePoint Permissions Report", stats cards div (compute counts from entries), filter input
3. Table with columns: Object | Title | URL | Unique | Users/Groups | Permission Level | Granted Through
4. For each entry: one `<tr>` with:
- `<td><span class="{objectTypeCss}">{ObjectType}</span></td>`
- `<td>{Title}</td>`
- `<td><a href="{Url}" target="_blank">Link</a></td>`
- `<td><span class="{uniqueCss}">{Unique/Inherited}</span></td>`
- `<td>` + user pills: split UserLogins by ';', split Users by ';', zip them, render `<span class="user-pill {externalClass}" data-email="{login}">{name}</span>`
- `<td>{PermissionLevels}</td>`
- `<td>{GrantedThrough}</td>`
5. Inline JS: filterTable() function that iterates `<tr>` elements and shows/hides based on input text match against `tr.textContent`
6. Close body/html
Helper method `private static string ObjectTypeCss(string t)`:
- "Site Collection" → "badge site-coll"
- "Site" → "badge site"
- "List" → "badge list"
- "Folder" → "badge folder"
- else → "badge"
Stats computation:
```csharp
var totalEntries = entries.Count;
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
var distinctUsers = entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(u => u.Trim()).Where(u => u.Length > 0).Distinct().Count();
```
Namespace: `SharepointToolbox.Services.Export`.
Usings: `System.IO`, `System.Text`, `SharepointToolbox.Core.Models`.
</action>
<verify>
<automated>dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~HtmlExportServiceTests" -x</automated>
</verify>
<done>All 3 HtmlExportServiceTests pass (user name present, empty list produces valid HTML, external user gets external-user class). dotnet build 0 errors.</done>
</task>
</tasks>
<verification>
- `dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx` → all tests pass (Phase 1 + new export tests)
- CsvExportServiceTests: 3 green
- HtmlExportServiceTests: 3 green
- HTML output contains no external script/link tags (grep verifiable: no `src=` or `href=` outside the table)
</verification>
<success_criteria>
- CsvExportService merges rows by (Users, PermissionLevels, GrantedThrough) before writing
- CSV uses UTF-8 with BOM for Excel compatibility
- HtmlExportService produces self-contained HTML with inline CSS and JS
- HTML correctly marks external users with "external-user" CSS class
- All 6 export tests pass (3 CSV + 3 HTML)
</success_criteria>
<output>
After completion, create `.planning/phases/02-permissions/02-04-SUMMARY.md`
</output>

View File

@@ -0,0 +1,171 @@
---
phase: 02-permissions
plan: 05
type: execute
wave: 1
depends_on:
- 02-01
files_modified:
- SharepointToolbox/Localization/Strings.resx
- SharepointToolbox/Localization/Strings.fr.resx
- SharepointToolbox/Localization/Strings.Designer.cs
autonomous: true
requirements:
- PERM-01
- PERM-02
- PERM-04
- PERM-05
- PERM-06
must_haves:
truths:
- "All Phase 2 UI string keys exist in Strings.resx with English values"
- "All Phase 2 UI string keys exist in Strings.fr.resx with French values (no English fallback — all keys translated)"
- "Strings.Designer.cs contains a static property for each new key"
- "Application still builds and existing localization tests pass"
artifacts:
- path: "SharepointToolbox/Localization/Strings.resx"
provides: "English strings for Phase 2 UI"
contains: "grp.scan.opts"
- path: "SharepointToolbox/Localization/Strings.fr.resx"
provides: "French translations for Phase 2 UI"
contains: "grp.scan.opts"
- path: "SharepointToolbox/Localization/Strings.Designer.cs"
provides: "Static C# accessors for all string keys"
key_links:
- from: "PermissionsView.xaml"
to: "Strings.Designer.cs"
via: "TranslationSource binding"
pattern: "TranslationSource"
---
<objective>
Add all Phase 2 localization keys (EN + FR) to the existing resx files and update Strings.Designer.cs. This plan runs in Wave 1 parallel to Plans 02 and 03 because it only touches localization files.
Purpose: Phase 2 UI views reference localization keys. All keys must exist before the Views and ViewModels can bind to them.
Output: Updated Strings.resx, Strings.fr.resx, Strings.Designer.cs with 15 new keys.
</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/phases/02-permissions/02-RESEARCH.md
<interfaces>
<!-- Localization key naming convention from Phase 1 -->
<!-- Keys use dot.notation: prefix.noun or prefix.verb.noun -->
<!-- Values in Strings.resx are English; Strings.fr.resx are French -->
<!-- Strings.Designer.cs is maintained manually (ResXFileCodeGenerator is VS-only) -->
<!-- Example existing key: btn.login="Log In" / btn.connexion="Se connecter" -->
Phase 2 keys to add (from PS reference lines 2751-2761):
```
Key English Value French Value
---- ---------------- ---------------
grp.scan.opts Scan Options Options d'analyse
chk.scan.folders Scan Folders Analyser les dossiers
chk.recursive Recursive (subsites) Récursif (sous-sites)
lbl.folder.depth Folder depth: Profondeur des dossiers :
chk.max.depth Maximum (all levels) Maximum (tous les niveaux)
chk.inherited.perms Include Inherited Permissions Inclure les permissions héritées
grp.export.fmt Export Format Format d'export
rad.csv.perms CSV CSV
rad.html.perms HTML HTML
btn.gen.perms Generate Report Générer le rapport
btn.open.perms Open Report Ouvrir le rapport
btn.view.sites View Sites Voir les sites
perm.site.url Site URL: URL du site :
perm.or.select or select multiple sites: ou sélectionnez plusieurs sites :
perm.sites.selected {0} site(s) selected {0} site(s) sélectionné(s)
```
Strings.Designer.cs pattern (existing):
```csharp
// Each key becomes a static property:
public static string grp_scan_opts => ResourceManager.GetString("grp.scan.opts", resourceCulture) ?? string.Empty;
// Note: dots in key names become underscores in C# property names
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add Phase 2 localization keys to resx files and Designer</name>
<files>
SharepointToolbox/Localization/Strings.resx
SharepointToolbox/Localization/Strings.fr.resx
SharepointToolbox/Localization/Strings.Designer.cs
</files>
<action>
IMPORTANT: These are XML files — read each file first before modifying to understand the existing structure and avoid breaking it.
Step 1: Add the following 15 `<data>` entries to Strings.resx (English), inside the `<root>` element, after the last existing `<data>` block:
```xml
<data name="grp.scan.opts" xml:space="preserve"><value>Scan Options</value></data>
<data name="chk.scan.folders" xml:space="preserve"><value>Scan Folders</value></data>
<data name="chk.recursive" xml:space="preserve"><value>Recursive (subsites)</value></data>
<data name="lbl.folder.depth" xml:space="preserve"><value>Folder depth:</value></data>
<data name="chk.max.depth" xml:space="preserve"><value>Maximum (all levels)</value></data>
<data name="chk.inherited.perms" xml:space="preserve"><value>Include Inherited Permissions</value></data>
<data name="grp.export.fmt" xml:space="preserve"><value>Export Format</value></data>
<data name="rad.csv.perms" xml:space="preserve"><value>CSV</value></data>
<data name="rad.html.perms" xml:space="preserve"><value>HTML</value></data>
<data name="btn.gen.perms" xml:space="preserve"><value>Generate Report</value></data>
<data name="btn.open.perms" xml:space="preserve"><value>Open Report</value></data>
<data name="btn.view.sites" xml:space="preserve"><value>View Sites</value></data>
<data name="perm.site.url" xml:space="preserve"><value>Site URL:</value></data>
<data name="perm.or.select" xml:space="preserve"><value>or select multiple sites:</value></data>
<data name="perm.sites.selected" xml:space="preserve"><value>{0} site(s) selected</value></data>
```
Step 2: Add the same 15 `<data>` entries to Strings.fr.resx (French) with the French values from the table above. All values must be genuine French — no copying English values.
Step 3: Add 15 static properties to Strings.Designer.cs, following the exact pattern of existing properties. Dots in key names become underscores:
```csharp
public static string grp_scan_opts => ResourceManager.GetString("grp.scan.opts", resourceCulture) ?? string.Empty;
public static string chk_scan_folders => ResourceManager.GetString("chk.scan.folders", resourceCulture) ?? string.Empty;
public static string chk_recursive => ResourceManager.GetString("chk.recursive", resourceCulture) ?? string.Empty;
public static string lbl_folder_depth => ResourceManager.GetString("lbl.folder.depth", resourceCulture) ?? string.Empty;
public static string chk_max_depth => ResourceManager.GetString("chk.max.depth", resourceCulture) ?? string.Empty;
public static string chk_inherited_perms => ResourceManager.GetString("chk.inherited.perms", resourceCulture) ?? string.Empty;
public static string grp_export_fmt => ResourceManager.GetString("grp.export.fmt", resourceCulture) ?? string.Empty;
public static string rad_csv_perms => ResourceManager.GetString("rad.csv.perms", resourceCulture) ?? string.Empty;
public static string rad_html_perms => ResourceManager.GetString("rad.html.perms", resourceCulture) ?? string.Empty;
public static string btn_gen_perms => ResourceManager.GetString("btn.gen.perms", resourceCulture) ?? string.Empty;
public static string btn_open_perms => ResourceManager.GetString("btn.open.perms", resourceCulture) ?? string.Empty;
public static string btn_view_sites => ResourceManager.GetString("btn.view.sites", resourceCulture) ?? string.Empty;
public static string perm_site_url => ResourceManager.GetString("perm.site.url", resourceCulture) ?? string.Empty;
public static string perm_or_select => ResourceManager.GetString("perm.or.select", resourceCulture) ?? string.Empty;
public static string perm_sites_selected => ResourceManager.GetString("perm.sites.selected", resourceCulture) ?? string.Empty;
```
</action>
<verify>
<automated>dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~LocalizationTests" -x</automated>
</verify>
<done>Existing LocalizationTests pass. `dotnet build SharepointToolbox.slnx` succeeds. All 15 keys exist in both resx files with correct translations. Strings.Designer.cs has 15 new static properties.</done>
</task>
</tasks>
<verification>
- `dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx` → 0 errors
- `dotnet test --filter "FullyQualifiedName~LocalizationTests"` → all pass
- Strings.resx and Strings.fr.resx contain the key `grp.scan.opts` (grep verifiable)
- Strings.fr.resx value for `chk.recursive` is "Récursif (sous-sites)" not English
</verification>
<success_criteria>
- All 15 Phase 2 localization keys present in EN and FR resx with genuine translations (no English fallback in FR)
- Strings.Designer.cs has 15 corresponding static properties
- No existing localization tests broken
- Keys are accessible via `TranslationSource.Instance["grp.scan.opts"]` at runtime
</success_criteria>
<output>
After completion, create `.planning/phases/02-permissions/02-05-SUMMARY.md`
</output>

View File

@@ -0,0 +1,332 @@
---
phase: 02-permissions
plan: 06
type: execute
wave: 3
depends_on:
- 02-02
- 02-03
- 02-04
- 02-05
files_modified:
- SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
- SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml
- SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml.cs
autonomous: true
requirements:
- PERM-01
- PERM-02
- PERM-04
- PERM-05
- PERM-06
must_haves:
truths:
- "PermissionsViewModel.RunOperationAsync calls PermissionsService.ScanSiteAsync for each selected site URL"
- "Single-site mode uses the URL from the SiteUrl property; multi-site mode uses the list from SelectedSites"
- "After scan completes, Results is a non-null ObservableCollection<PermissionEntry>"
- "Export commands are only enabled when Results.Count > 0 (CanExecute guard)"
- "SitePickerDialog shows a list of sites (loaded via SiteListService) with checkboxes and a filter textbox"
- "PermissionsViewModel.ScanOptions property exposes IncludeInherited, ScanFolders, FolderDepth bound to UI checkboxes"
artifacts:
- path: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs"
provides: "FeatureViewModelBase subclass for the Permissions tab"
exports: ["PermissionsViewModel"]
- path: "SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml"
provides: "Multi-site selection dialog with checkboxes and filter"
- path: "SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml.cs"
provides: "Code-behind: loads sites on Open, exposes SelectedUrls"
key_links:
- from: "PermissionsViewModel.cs"
to: "IPermissionsService.ScanSiteAsync"
via: "RunOperationAsync loop per site"
pattern: "_permissionsService\\.ScanSiteAsync"
- from: "PermissionsViewModel.cs"
to: "CsvExportService.WriteAsync"
via: "ExportCsvCommand handler"
pattern: "_csvExportService\\.WriteAsync"
- from: "PermissionsViewModel.cs"
to: "HtmlExportService.WriteAsync"
via: "ExportHtmlCommand handler"
pattern: "_htmlExportService\\.WriteAsync"
- from: "SitePickerDialog.xaml.cs"
to: "ISiteListService.GetSitesAsync"
via: "Window.Loaded handler"
pattern: "_siteListService\\.GetSitesAsync"
---
<objective>
Implement `PermissionsViewModel` (the full feature orchestrator) and `SitePickerDialog` (the multi-site picker UI). After this plan, all business logic for the Permissions tab is complete — only DI wiring and tab replacement remain (Plan 07).
Purpose: Wire all services (scan, site list, export) into the ViewModel, and create the SitePickerDialog used for PERM-02.
Output: PermissionsViewModel + SitePickerDialog (XAML + code-behind).
</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/phases/02-permissions/02-RESEARCH.md
<interfaces>
From SharepointToolbox/ViewModels/FeatureViewModelBase.cs:
```csharp
public abstract partial class FeatureViewModelBase : ObservableRecipient
{
[ObservableProperty] private bool _isRunning;
[ObservableProperty] private string _statusMessage = string.Empty;
[ObservableProperty] private int _progressValue;
public IAsyncRelayCommand RunCommand { get; } // calls ExecuteAsync → RunOperationAsync
public RelayCommand CancelCommand { get; }
protected abstract Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress);
protected virtual void OnTenantSwitched(TenantProfile profile) { }
}
```
From SharepointToolbox/Services/IPermissionsService.cs (Plan 02):
```csharp
public interface IPermissionsService
{
Task<IReadOnlyList<PermissionEntry>> ScanSiteAsync(
ClientContext ctx, ScanOptions options,
IProgress<OperationProgress> progress, CancellationToken ct);
}
```
From SharepointToolbox/Services/ISiteListService.cs (Plan 03):
```csharp
public interface ISiteListService
{
Task<IReadOnlyList<SiteInfo>> GetSitesAsync(
TenantProfile profile, IProgress<OperationProgress> progress, CancellationToken ct);
}
public record SiteInfo(string Url, string Title);
```
From SharepointToolbox/Services/Export/:
```csharp
public class CsvExportService
{
public Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct);
}
public class HtmlExportService
{
public Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct);
}
```
Dialog factory pattern (established in Phase 1 — ProfileManagementViewModel):
```csharp
// ViewModel exposes a Func<Window>? property set by the View layer:
public Func<Window>? OpenSitePickerDialog { get; set; }
// ViewModel calls: var dlg = OpenSitePickerDialog?.Invoke(); dlg?.ShowDialog();
// This avoids Window/DI coupling in the ViewModel.
```
SessionManager usage in ViewModel (established pattern):
```csharp
// At scan start, ViewModel calls SessionManager.GetOrCreateContextAsync per site URL:
var profile = new TenantProfile { TenantUrl = siteUrl, ClientId = _currentProfile.ClientId, Name = _currentProfile.Name };
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
// Each site URL gets its own context from SessionManager's cache.
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Implement PermissionsViewModel</name>
<files>
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
</files>
<behavior>
- Extends FeatureViewModelBase; implements RunOperationAsync
- [ObservableProperty] SiteUrl (string) — single-site mode input
- [ObservableProperty] ScanOptions (ScanOptions) — bound to UI checkboxes (IncludeInherited, ScanFolders, FolderDepth)
- [ObservableProperty] Results (ObservableCollection&lt;PermissionEntry&gt;) — bound to DataGrid
- [ObservableProperty] SelectedSites (ObservableCollection&lt;SiteInfo&gt;) — multi-site picker result
- ExportCsvCommand: AsyncRelayCommand, only enabled when Results.Count > 0
- ExportHtmlCommand: AsyncRelayCommand, only enabled when Results.Count > 0
- OpenSitePickerCommand: RelayCommand, opens SitePickerDialog via dialog factory
- Multi-site mode: if SelectedSites.Count > 0, scan each URL; else scan SiteUrl
- RunOperationAsync: for each site URL, get ClientContext from SessionManager, call PermissionsService.ScanSiteAsync, accumulate results, set Results on UI thread via Dispatcher
- OnTenantSwitched: clear Results, SiteUrl, SelectedSites
- Multi-site test from Plan 01 (StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl) should pass using a mock IPermissionsService
</behavior>
<action>
Create `SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs`.
Constructor: inject `IPermissionsService`, `ISiteListService`, `CsvExportService`, `HtmlExportService`, `SessionManager`, `ILogger<PermissionsViewModel>`.
Key implementation:
```csharp
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
var urls = SelectedSites.Count > 0
? SelectedSites.Select(s => s.Url).ToList()
: new List<string> { SiteUrl };
if (urls.All(string.IsNullOrWhiteSpace))
{
StatusMessage = "Enter a site URL or select sites.";
return;
}
var allEntries = new List<PermissionEntry>();
int i = 0;
foreach (var url in urls.Where(u => !string.IsNullOrWhiteSpace(u)))
{
ct.ThrowIfCancellationRequested();
progress.Report(new OperationProgress(i, urls.Count, $"Scanning {url}..."));
var profile = new TenantProfile { TenantUrl = url, ClientId = _currentProfile!.ClientId, Name = _currentProfile.Name };
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
var siteEntries = await _permissionsService.ScanSiteAsync(ctx, ScanOptions, progress, ct);
allEntries.AddRange(siteEntries);
i++;
}
await Application.Current.Dispatcher.InvokeAsync(() =>
Results = new ObservableCollection<PermissionEntry>(allEntries));
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
}
```
Export commands open SaveFileDialog (Microsoft.Win32), then call the respective service WriteAsync. After writing, call `Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true })` to open the file.
OpenSitePickerCommand: `OpenSitePickerDialog?.Invoke()?.ShowDialog()` — if dialog returns true, update SelectedSites from the dialog's SelectedUrls.
_currentProfile: received via WeakReferenceMessenger TenantSwitchedMessage (same as Phase 1 pattern). OnTenantSwitched sets _currentProfile.
ObservableProperty ScanOptions default: `new ScanOptions()` (IncludeInherited=false, ScanFolders=true, FolderDepth=1, IncludeSubsites=false).
Note: ScanOptions is a record — individual bool/int properties bound in UI must be via wrapper properties or a ScanOptionsViewModel. For simplicity, expose flat [ObservableProperty] booleans (IncludeInherited, ScanFolders, IncludeSubsites, FolderDepth) and build the ScanOptions record in RunOperationAsync from these flat properties.
Namespace: `SharepointToolbox.ViewModels.Tabs`.
</action>
<verify>
<automated>dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~PermissionsViewModelTests" -x</automated>
</verify>
<done>PermissionsViewModelTests: StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl passes (using mock IPermissionsService). dotnet build 0 errors.</done>
</task>
<task type="auto">
<name>Task 2: Implement SitePickerDialog XAML and code-behind</name>
<files>
SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml
SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml.cs
</files>
<action>
Create `SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml`:
- Window Title bound to "Select Sites" (hardcoded or localized)
- Width=600, Height=500, WindowStartupLocation=CenterOwner
- Layout: StackPanel (DockPanel or Grid)
- Top: TextBlock "Filter:" + TextBox (x:Name="FilterBox") with TextChanged binding to filter the list
- Middle: ListView (x:Name="SiteList", SelectionMode=Multiple) with CheckBox column and Site URL/Title columns
- Use `DataTemplate` with `CheckBox` bound to `IsSelected` on the list item wrapper
- Columns: checkbox, Title, URL
- Bottom buttons row: "Load Sites" button, "Select All", "Deselect All", "OK" (IsDefault=True), "Cancel" (IsCancel=True)
- Status TextBlock for loading/error messages
Create `SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml.cs`:
```csharp
public partial class SitePickerDialog : Window
{
private readonly ISiteListService _siteListService;
private readonly TenantProfile _profile;
private List<SitePickerItem> _allItems = new();
// SitePickerItem is a local class: record SitePickerItem(string Url, string Title) with bool IsSelected property (not record so it can be mutable)
public IReadOnlyList<SiteInfo> SelectedUrls =>
_allItems.Where(i => i.IsSelected).Select(i => new SiteInfo(i.Url, i.Title)).ToList();
public SitePickerDialog(ISiteListService siteListService, TenantProfile profile)
{
InitializeComponent();
_siteListService = siteListService;
_profile = profile;
}
private async void Window_Loaded(object sender, RoutedEventArgs e) => await LoadSitesAsync();
private async Task LoadSitesAsync()
{
StatusText.Text = "Loading sites...";
LoadButton.IsEnabled = false;
try
{
var sites = await _siteListService.GetSitesAsync(_profile,
new Progress<OperationProgress>(), CancellationToken.None);
_allItems = sites.Select(s => new SitePickerItem(s.Url, s.Title)).ToList();
ApplyFilter();
StatusText.Text = $"{_allItems.Count} sites loaded.";
}
catch (InvalidOperationException ex) { StatusText.Text = ex.Message; }
catch (Exception ex) { StatusText.Text = $"Error: {ex.Message}"; }
finally { LoadButton.IsEnabled = true; }
}
private void ApplyFilter()
{
var filter = FilterBox.Text.Trim();
SiteList.ItemsSource = string.IsNullOrEmpty(filter)
? _allItems
: _allItems.Where(i => i.Url.Contains(filter, StringComparison.OrdinalIgnoreCase)
|| i.Title.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList();
}
private void FilterBox_TextChanged(object s, TextChangedEventArgs e) => ApplyFilter();
private void SelectAll_Click(object s, RoutedEventArgs e) { foreach (var i in _allItems) i.IsSelected = true; ApplyFilter(); }
private void DeselectAll_Click(object s, RoutedEventArgs e) { foreach (var i in _allItems) i.IsSelected = false; ApplyFilter(); }
private async void LoadButton_Click(object s, RoutedEventArgs e) => await LoadSitesAsync();
private void OK_Click(object s, RoutedEventArgs e) { DialogResult = true; Close(); }
}
public class SitePickerItem : INotifyPropertyChanged
{
private bool _isSelected;
public string Url { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public bool IsSelected
{
get => _isSelected;
set { _isSelected = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSelected))); }
}
public event PropertyChangedEventHandler? PropertyChanged;
}
```
The SitePickerDialog is registered as Transient in DI (Plan 07). PermissionsViewModel's OpenSitePickerDialog factory is set in PermissionsView code-behind.
</action>
<verify>
<automated>dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx 2>&1 | tail -5</automated>
</verify>
<done>dotnet build succeeds with 0 errors. SitePickerDialog.xaml and .cs exist and compile. No XAML parse errors.</done>
</task>
</tasks>
<verification>
- `dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx` → 0 errors
- `dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx` → all tests pass
- PermissionsViewModelTests: StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl passes
- PermissionsViewModel references _permissionsService.ScanSiteAsync (grep verifiable)
- SitePickerDialog.xaml exists and has a ListView with checkboxes
</verification>
<success_criteria>
- PermissionsViewModel extends FeatureViewModelBase and implements all required commands (RunCommand inherited, ExportCsvCommand, ExportHtmlCommand, OpenSitePickerCommand)
- Multi-site scan loops over SelectedSites, single-site scan uses SiteUrl
- SitePickerDialog loads sites from ISiteListService on Window.Loaded
- ExportCsv and ExportHtml commands are disabled when Results is empty
- OnTenantSwitched clears Results, SiteUrl, SelectedSites
</success_criteria>
<output>
After completion, create `.planning/phases/02-permissions/02-06-SUMMARY.md`
</output>

View File

@@ -0,0 +1,252 @@
---
phase: 02-permissions
plan: 07
type: execute
wave: 4
depends_on:
- 02-06
files_modified:
- SharepointToolbox/Views/Tabs/PermissionsView.xaml
- SharepointToolbox/Views/Tabs/PermissionsView.xaml.cs
- SharepointToolbox/App.xaml.cs
- SharepointToolbox/MainWindow.xaml
- SharepointToolbox/MainWindow.xaml.cs
autonomous: false
requirements:
- PERM-01
- PERM-02
- PERM-03
- PERM-04
- PERM-05
- PERM-06
- PERM-07
must_haves:
truths:
- "The Permissions tab in the running application shows PermissionsView — not the 'Coming soon' FeatureTabBase stub"
- "User can enter a site URL, click Generate Report, see progress, and results appear in a DataGrid"
- "User can click Export CSV and Export HTML — file save dialog appears and file is created"
- "Scan Options panel shows checkboxes for Scan Folders, Include Inherited Permissions, and a Folder Depth input"
- "View Sites button opens SitePickerDialog and selected sites appear as '{N} site(s) selected' label"
- "Cancel button stops the scan mid-operation"
artifacts:
- path: "SharepointToolbox/Views/Tabs/PermissionsView.xaml"
provides: "Complete Permissions tab UI"
- path: "SharepointToolbox/Views/Tabs/PermissionsView.xaml.cs"
provides: "Code-behind: sets DataContext, wires dialog factory"
- path: "SharepointToolbox/App.xaml.cs"
provides: "DI registration for Phase 2 services"
contains: "PermissionsViewModel"
- path: "SharepointToolbox/MainWindow.xaml"
provides: "Permissions TabItem uses PermissionsView instead of FeatureTabBase stub"
key_links:
- from: "PermissionsView.xaml.cs"
to: "PermissionsViewModel"
via: "DataContext = ServiceProvider.GetRequiredService<PermissionsViewModel>()"
pattern: "GetRequiredService.*PermissionsViewModel"
- from: "PermissionsView.xaml.cs"
to: "SitePickerDialog"
via: "viewModel.OpenSitePickerDialog factory"
pattern: "OpenSitePickerDialog"
- from: "App.xaml.cs"
to: "PermissionsViewModel, PermissionsService, SiteListService, CsvExportService, HtmlExportService"
via: "services.AddTransient / AddScoped"
pattern: "AddTransient.*Permissions"
---
<objective>
Create PermissionsView XAML, wire it into MainWindow replacing the FeatureTabBase stub, register all Phase 2 services in DI, and checkpoint with a human visual verification of the running application.
Purpose: This is the integration plan — all services exist, ViewModel exists, now wire everything together and confirm the full feature works end-to-end in the UI.
Output: PermissionsView.xaml + .cs, updated App.xaml.cs DI, updated MainWindow.xaml.
</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/phases/02-permissions/02-RESEARCH.md
<interfaces>
<!-- DI registration pattern from Phase 1 (App.xaml.cs) -->
<!-- ProfileManagementDialog and SettingsView are registered as Transient -->
<!-- MainWindowViewModel is registered as Singleton -->
<!-- IServiceProvider is injected into MainWindow constructor -->
From MainWindow.xaml (current stub — line 45 is the Permissions tab):
```xml
<!-- TabControl: 7 stub tabs use FeatureTabBase; Settings tab wired in plan 01-07 -->
<!-- Tab order: Permissions, Storage, File Search, Duplicates, Templates, Folder Structure, Bulk Ops -->
<TabItem Header="Permissions">
<controls:FeatureTabBase /> <!-- REPLACE THIS with <views:PermissionsView /> -->
</TabItem>
```
PermissionsView code-behind wiring pattern (same as SettingsView from Phase 1):
```csharp
public partial class PermissionsView : UserControl
{
public PermissionsView(IServiceProvider serviceProvider)
{
InitializeComponent();
var vm = serviceProvider.GetRequiredService<PermissionsViewModel>();
DataContext = vm;
// Wire dialog factory — avoids Window/DI coupling in ViewModel (Phase 1 pattern)
vm.OpenSitePickerDialog = () => serviceProvider.GetRequiredService<SitePickerDialog>();
}
}
```
SitePickerDialog needs the current TenantProfile — pass it via a factory:
```csharp
// In PermissionsView code-behind, the dialog factory must pass the current profile from the ViewModel:
vm.OpenSitePickerDialog = () => serviceProvider.GetRequiredService<Func<TenantProfile, SitePickerDialog>>()(vm.CurrentProfile!);
// Register in DI: services.AddTransient<Func<TenantProfile, SitePickerDialog>>(sp =>
// profile => new SitePickerDialog(sp.GetRequiredService<ISiteListService>(), profile));
```
PermissionsView DataGrid columns (results binding):
- Object Type (ObjectType)
- Title
- URL (as hyperlink or plain text)
- Has Unique Permissions (HasUniquePermissions — bool, display as Yes/No)
- Users
- Permission Levels (PermissionLevels)
- Granted Through (GrantedThrough)
- Principal Type (PrincipalType)
All text in XAML uses TranslationSource binding: `{Binding [btn.gen.perms], Source={x:Static loc:TranslationSource.Instance}}`
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Create PermissionsView XAML + code-behind and register DI</name>
<files>
SharepointToolbox/Views/Tabs/PermissionsView.xaml
SharepointToolbox/Views/Tabs/PermissionsView.xaml.cs
SharepointToolbox/App.xaml.cs
SharepointToolbox/MainWindow.xaml
SharepointToolbox/MainWindow.xaml.cs
</files>
<action>
READ App.xaml.cs and MainWindow.xaml before modifying to understand existing structure.
Step 1 — Create PermissionsView.xaml:
WPF UserControl. Layout with a Grid split into:
- Left panel (~280px): Scan configuration
- GroupBox "Scan Options" (bound to `[grp.scan.opts]`):
- TextBlock + TextBox for SiteUrl (bound to `{Binding SiteUrl}`)
- Button "View Sites" (bound to `{Binding [btn.view.sites]}`, Command=`{Binding OpenSitePickerCommand}`)
- TextBlock showing `{Binding SitesSelectedLabel}` (e.g., "3 site(s) selected") — expose this as [ObservableProperty] in ViewModel
- CheckBox "Scan Folders" (bound to `{Binding ScanFolders}`)
- CheckBox "Include Inherited Permissions" (bound to `{Binding IncludeInherited}`)
- CheckBox "Recursive (subsites)" (bound to `{Binding IncludeSubsites}`)
- Label + TextBox for FolderDepth (bound to `{Binding FolderDepth}`)
- CheckBox "Maximum (all levels)" — when checked sets FolderDepth to 999
- Buttons row: "Generate Report" (bound to `{Binding RunCommand}`), "Cancel" (bound to `{Binding CancelCommand}`)
- Buttons row: "Export CSV" (bound to `{Binding ExportCsvCommand}`), "Export HTML" (bound to `{Binding ExportHtmlCommand}`)
- Right panel (remaining space): Results DataGrid
- DataGrid bound to `{Binding Results}`, AutoGenerateColumns=False, IsReadOnly=True, VirtualizingPanel.IsVirtualizing=True, EnableRowVirtualization=True
- Columns: ObjectType, Title, Url, HasUniquePermissions (display Yes/No via StringFormat or converter), Users, PermissionLevels, GrantedThrough, PrincipalType
- Bottom StatusBar: ProgressBar (bound to `{Binding ProgressValue}`) + TextBlock (bound to `{Binding StatusMessage}`)
Step 2 — Create PermissionsView.xaml.cs code-behind:
```csharp
public partial class PermissionsView : UserControl
{
public PermissionsView(IServiceProvider serviceProvider)
{
InitializeComponent();
var vm = serviceProvider.GetRequiredService<PermissionsViewModel>();
DataContext = vm;
vm.OpenSitePickerDialog = () =>
{
var factory = serviceProvider.GetRequiredService<Func<TenantProfile, SitePickerDialog>>();
return factory(vm.CurrentProfile ?? new TenantProfile());
};
}
}
```
Step 3 — Update App.xaml.cs DI registrations. Add inside the `ConfigureServices` method:
```csharp
// Phase 2: Permissions
services.AddTransient<IPermissionsService, PermissionsService>();
services.AddTransient<ISiteListService, SiteListService>();
services.AddTransient<CsvExportService>();
services.AddTransient<HtmlExportService>();
services.AddTransient<PermissionsViewModel>();
services.AddTransient<PermissionsView>();
services.AddTransient<SitePickerDialog>();
services.AddTransient<Func<TenantProfile, SitePickerDialog>>(sp =>
profile => new SitePickerDialog(sp.GetRequiredService<ISiteListService>(), profile));
```
Step 4 — Update MainWindow.xaml: replace the FIRST `<controls:FeatureTabBase />` (the Permissions tab) with:
```xml
<TabItem>
<TabItem.Header>
<TextBlock Text="{Binding [tab.permissions], Source={x:Static loc:TranslationSource.Instance}}"/>
</TabItem.Header>
<views:PermissionsView />
</TabItem>
```
Add `xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs"` to the Window namespaces if not already present.
Add localization key `tab.permissions` = "Permissions" (EN) / "Permissions" (FR — same word) to resx files and Strings.Designer.cs.
Step 5 — Update MainWindow.xaml.cs if needed to resolve PermissionsView from DI (same pattern used for SettingsView — check existing code for how the Settings tab UserControl is created).
</action>
<verify>
<automated>dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx 2>&1 | tail -5</automated>
</verify>
<done>dotnet build succeeds with 0 errors. All services registered in DI. PermissionsView compiles. MainWindow.xaml has `&lt;views:PermissionsView /&gt;` instead of `&lt;controls:FeatureTabBase /&gt;` for the Permissions tab.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Checkpoint: Visual verification of Permissions tab in running application</name>
<action>Human verifies the running application visually as described in how-to-verify below.</action>
<verify>
<automated>HUMAN — run app and confirm checklist: tab visible, scan options present, export buttons disabled, French locale works</automated>
</verify>
<done>Human types "approved" confirming all 7 checklist items pass.</done>
<what-built>
Full Permissions tab: scan configuration panel, DataGrid results display, export buttons, site picker dialog. All Phase 2 services registered in DI. The tab replaces the previous "Coming soon" stub.
</what-built>
<how-to-verify>
1. Run the application (F5 or `dotnet run --project SharepointToolbox`)
2. The Permissions tab is visible in the tab bar — it shows the scan options panel and an empty DataGrid (not "Coming soon")
3. The Scan Options panel shows: Site URL input, View Sites button, Scan Folders checkbox, Include Inherited Permissions checkbox, Recursive checkbox, Folder Depth input, Generate Report button, Cancel button, Export CSV button, Export HTML button
4. Click "View Sites" — SitePickerDialog opens (may fail with auth error if not connected to a tenant — that is expected; verify the dialog opens and shows a loading state or error, not a crash)
5. Export CSV / Export HTML buttons are disabled (grayed out) when no results are loaded
6. Switch the language to French (Settings tab) — all Permissions tab labels change to French text (no English fallback visible)
7. The Cancel button exists and is disabled when no scan is running
</how-to-verify>
<resume-signal>Type "approved" if all verifications pass, or describe what is wrong</resume-signal>
</task>
</tasks>
<verification>
- `dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx` → 0 errors
- `dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx` → all tests pass (Phase 1 + Phase 2)
- MainWindow.xaml no longer has `<controls:FeatureTabBase />` for the Permissions tab position
- App.xaml.cs contains `AddTransient<IPermissionsService, PermissionsService>()`
- Human confirms: Permissions tab visible and functional in running app
</verification>
<success_criteria>
- Running application shows Permissions tab with full UI (not a stub)
- All Phase 2 services registered in DI: IPermissionsService, ISiteListService, CsvExportService, HtmlExportService
- Language switching works — all Phase 2 labels translate to French
- Export buttons are disabled when no results; enabled after scan completes
- Full test suite passes (Phase 1 + Phase 2: target ~50+ tests passing)
</success_criteria>
<output>
After completion, create `.planning/phases/02-permissions/02-07-SUMMARY.md`
</output>