From 55819bd0594fe30f8012024ea07ecaac6d9bf45d Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 2 Apr 2026 13:38:09 +0200 Subject: [PATCH] =?UTF-8?q?docs(02-permissions):=20create=20phase=202=20pl?= =?UTF-8?q?an=20=E2=80=94=207=20plans=20across=204=20waves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .planning/ROADMAP.md | 13 +- .planning/phases/02-permissions/02-01-PLAN.md | 224 ++++++++++++ .planning/phases/02-permissions/02-02-PLAN.md | 307 ++++++++++++++++ .planning/phases/02-permissions/02-03-PLAN.md | 221 ++++++++++++ .planning/phases/02-permissions/02-04-PLAN.md | 250 +++++++++++++ .planning/phases/02-permissions/02-05-PLAN.md | 171 +++++++++ .planning/phases/02-permissions/02-06-PLAN.md | 332 ++++++++++++++++++ .planning/phases/02-permissions/02-07-PLAN.md | 252 +++++++++++++ 8 files changed, 1768 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/02-permissions/02-01-PLAN.md create mode 100644 .planning/phases/02-permissions/02-02-PLAN.md create mode 100644 .planning/phases/02-permissions/02-03-PLAN.md create mode 100644 .planning/phases/02-permissions/02-04-PLAN.md create mode 100644 .planning/phases/02-permissions/02-05-PLAN.md create mode 100644 .planning/phases/02-permissions/02-06-PLAN.md create mode 100644 .planning/phases/02-permissions/02-07-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index eb49c62..1cf01e2 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 | - | diff --git a/.planning/phases/02-permissions/02-01-PLAN.md b/.planning/phases/02-permissions/02-01-PLAN.md new file mode 100644 index 0000000..9a627c9 --- /dev/null +++ b/.planning/phases/02-permissions/02-01-PLAN.md @@ -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" +--- + + +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). + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/phases/02-permissions/02-RESEARCH.md +@.planning/phases/02-permissions/02-VALIDATION.md + + + + + +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> ScanSiteAsync( + Microsoft.SharePoint.Client.ClientContext ctx, + ScanOptions options, + IProgress progress, + CancellationToken ct); +} + +// SharepointToolbox/Services/Export/CsvExportService.cs +namespace SharepointToolbox.Services.Export; +public class CsvExportService +{ + public string BuildCsv(IReadOnlyList entries); + public Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct); +} + +// SharepointToolbox/Services/Export/HtmlExportService.cs +namespace SharepointToolbox.Services.Export; +public class HtmlExportService +{ + public string BuildHtml(IReadOnlyList entries); + public Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct); +} +``` + + + + + + + Task 1: Scaffold PermissionsService and ViewModel test stubs + + SharepointToolbox.Tests/Services/PermissionsServiceTests.cs + SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs + SharepointToolbox.Tests/Services/PermissionEntryClassificationTests.cs + + + 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 + + + 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 FilterPermissionLevels(IEnumerable 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. + + + dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~PermissionEntryClassificationTests" -x + + PermissionEntryClassificationTests pass (3 tests green). PermissionsServiceTests and PermissionsViewModelTests compile but skip. No new test failures in the existing suite. + + + + Task 2: Scaffold export service test stubs + + SharepointToolbox.Tests/Services/Export/CsvExportServiceTests.cs + SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs + + + 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. + + + 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`. + + + dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -x 2>&1 | tail -20 + + 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. + + + + + +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/` + + + +- 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 + + + +After completion, create `.planning/phases/02-permissions/02-01-SUMMARY.md` + diff --git a/.planning/phases/02-permissions/02-02-PLAN.md b/.planning/phases/02-permissions/02-02-PLAN.md new file mode 100644 index 0000000..dfbe265 --- /dev/null +++ b/.planning/phases/02-permissions/02-02-PLAN.md @@ -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" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/phases/02-permissions/02-RESEARCH.md + + + + +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 GetAllItemsAsync( + ClientContext ctx, + List list, + CamlQuery baseQuery, + IProgress 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 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 FilterPermissionLevels(IEnumerable 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. +``` + + + + + + + Task 1: Define data models and IPermissionsService interface + + SharepointToolbox/Core/Models/PermissionEntry.cs + SharepointToolbox/Core/Models/ScanOptions.cs + SharepointToolbox/Services/IPermissionsService.cs + + + - 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<IReadOnlyList<PermissionEntry>> + - Existing Plan 01 test stubs that reference these types now compile (no more "type not found" errors) + + + 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: " + 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> ScanSiteAsync( + ClientContext ctx, + ScanOptions options, + IProgress progress, + CancellationToken ct); + } + ``` + + + dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~PermissionsServiceTests" -x 2>&1 | tail -10 + + PermissionsServiceTests compiles (no CS0246 errors). Tests that reference IPermissionsService now skip cleanly rather than failing to compile. dotnet build produces 0 errors. + + + + Task 2: Implement PermissionsService scan engine + + SharepointToolbox/Services/PermissionsService.cs + + + - 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) + + + 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 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> ScanSiteAsync( + ClientContext ctx, ScanOptions options, + IProgress progress, CancellationToken ct) + { ... } + + // Private: get site collection admins → PermissionEntry with ObjectType="Site Collection" + private async Task> GetSiteCollectionAdminsAsync( + ClientContext ctx, IProgress progress, CancellationToken ct) { ... } + + // Private: port of Get-PnPPermissions for a Web object + private async Task> GetWebPermissionsAsync( + ClientContext ctx, Web web, ScanOptions options, + IProgress progress, CancellationToken ct) { ... } + + // Private: port of Get-PnPPermissions for a List object + private async Task> GetListPermissionsAsync( + ClientContext ctx, List list, ScanOptions options, + IProgress progress, CancellationToken ct) { ... } + + // Private: enumerate folders in list via SharePointPaginationHelper, get permissions per folder + private async Task> GetFolderPermissionsAsync( + ClientContext ctx, List list, ScanOptions options, + IProgress progress, CancellationToken ct) { ... } + + // Private: core per-object extractor — batched ctx.Load + ExecuteQueryRetryAsync + private async Task> ExtractPermissionsAsync( + ClientContext ctx, SecurableObject obj, string objectType, string title, + string url, ScanOptions options, + IProgress 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 `` 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` + + + dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~PermissionsServiceTests|FullyQualifiedName~PermissionEntryClassificationTests" -x + + Classification tests (3) still pass. PermissionsServiceTests skips cleanly (no compile errors). `dotnet build SharepointToolbox.slnx` succeeds with 0 errors. The service implements IPermissionsService. + + + + + +- `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`) + + + +- 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) + + + +After completion, create `.planning/phases/02-permissions/02-02-SUMMARY.md` + diff --git a/.planning/phases/02-permissions/02-03-PLAN.md b/.planning/phases/02-permissions/02-03-PLAN.md new file mode 100644 index 0000000..d5d09a7 --- /dev/null +++ b/.planning/phases/02-permissions/02-03-PLAN.md @@ -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" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/phases/02-permissions/02-RESEARCH.md + + + + +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 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 +``` + + + + + + + Task 1: Implement ISiteListService and SiteListService + + SharepointToolbox/Services/ISiteListService.cs + SharepointToolbox/Services/SiteListService.cs + + + - ISiteListService.GetSitesAsync(TenantProfile profile, IProgress<OperationProgress> progress, CancellationToken ct) returns Task<IReadOnlyList<SiteInfo>> + - 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 + + + 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> GetSitesAsync( + TenantProfile profile, + IProgress 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> GetSitesAsync( + TenantProfile profile, IProgress 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); + } + ``` + + + dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~SiteListServiceTests" -x + + SiteListServiceTests: DeriveAdminUrl tests (2) pass. SiteListService and ISiteListService compile. dotnet build 0 errors. + + + + + +- `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 + + + +- 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 + + + +After completion, create `.planning/phases/02-permissions/02-03-SUMMARY.md` + diff --git a/.planning/phases/02-permissions/02-04-PLAN.md b/.planning/phases/02-permissions/02-04-PLAN.md new file mode 100644 index 0000000..2bcb16c --- /dev/null +++ b/.planning/phases/02-permissions/02-04-PLAN.md @@ -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" +--- + + +Implement the two export services: `CsvExportService` (port of PS `Merge-PermissionRows` + `Export-Csv`) and `HtmlExportService` (port of PS `Export-PermissionsToHTML`). Both services consume `IReadOnlyList` 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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/phases/02-permissions/02-RESEARCH.md + + + +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: " + 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 + + + + + + + Task 1: Implement CsvExportService + + SharepointToolbox/Services/Export/CsvExportService.cs + + + - BuildCsv(IReadOnlyList<PermissionEntry> 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 + + + 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 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 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`. + + + dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~CsvExportServiceTests" -x + + All 3 CsvExportServiceTests pass (header row present, merge works, empty list returns header only). dotnet build 0 errors. + + + + Task 2: Implement HtmlExportService + + SharepointToolbox/Services/Export/HtmlExportService.cs + + + - BuildHtml(IReadOnlyList<PermissionEntry> 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 <link> or <script src=...> 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 + + + 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 `` with: + - `{ObjectType}` + - `{Title}` + - `Link` + - `{Unique/Inherited}` + - `` + user pills: split UserLogins by ';', split Users by ';', zip them, render `{name}` + - `{PermissionLevels}` + - `{GrantedThrough}` + 5. Inline JS: filterTable() function that iterates `` 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`. + + + dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~HtmlExportServiceTests" -x + + All 3 HtmlExportServiceTests pass (user name present, empty list produces valid HTML, external user gets external-user class). dotnet build 0 errors. + + + + + +- `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) + + + +- 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) + + + +After completion, create `.planning/phases/02-permissions/02-04-SUMMARY.md` + diff --git a/.planning/phases/02-permissions/02-05-PLAN.md b/.planning/phases/02-permissions/02-05-PLAN.md new file mode 100644 index 0000000..573ed65 --- /dev/null +++ b/.planning/phases/02-permissions/02-05-PLAN.md @@ -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" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/phases/02-permissions/02-RESEARCH.md + + + + + + + + +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 +``` + + + + + + + Task 1: Add Phase 2 localization keys to resx files and Designer + + SharepointToolbox/Localization/Strings.resx + SharepointToolbox/Localization/Strings.fr.resx + SharepointToolbox/Localization/Strings.Designer.cs + + + 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 `` entries to Strings.resx (English), inside the `` element, after the last existing `` block: + ```xml + Scan Options + Scan Folders + Recursive (subsites) + Folder depth: + Maximum (all levels) + Include Inherited Permissions + Export Format + CSV + HTML + Generate Report + Open Report + View Sites + Site URL: + or select multiple sites: + {0} site(s) selected + ``` + + Step 2: Add the same 15 `` 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; + ``` + + + dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~LocalizationTests" -x + + 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. + + + + + +- `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 + + + +- 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 + + + +After completion, create `.planning/phases/02-permissions/02-05-SUMMARY.md` + diff --git a/.planning/phases/02-permissions/02-06-PLAN.md b/.planning/phases/02-permissions/02-06-PLAN.md new file mode 100644 index 0000000..c4106e2 --- /dev/null +++ b/.planning/phases/02-permissions/02-06-PLAN.md @@ -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" + - "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" +--- + + +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). + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/phases/02-permissions/02-RESEARCH.md + + +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 progress); + protected virtual void OnTenantSwitched(TenantProfile profile) { } +} +``` + +From SharepointToolbox/Services/IPermissionsService.cs (Plan 02): +```csharp +public interface IPermissionsService +{ + Task> ScanSiteAsync( + ClientContext ctx, ScanOptions options, + IProgress progress, CancellationToken ct); +} +``` + +From SharepointToolbox/Services/ISiteListService.cs (Plan 03): +```csharp +public interface ISiteListService +{ + Task> GetSitesAsync( + TenantProfile profile, IProgress progress, CancellationToken ct); +} +public record SiteInfo(string Url, string Title); +``` + +From SharepointToolbox/Services/Export/: +```csharp +public class CsvExportService +{ + public Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct); +} +public class HtmlExportService +{ + public Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct); +} +``` + +Dialog factory pattern (established in Phase 1 — ProfileManagementViewModel): +```csharp +// ViewModel exposes a Func? property set by the View layer: +public Func? 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. +``` + + + + + + + Task 1: Implement PermissionsViewModel + + SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs + + + - Extends FeatureViewModelBase; implements RunOperationAsync + - [ObservableProperty] SiteUrl (string) — single-site mode input + - [ObservableProperty] ScanOptions (ScanOptions) — bound to UI checkboxes (IncludeInherited, ScanFolders, FolderDepth) + - [ObservableProperty] Results (ObservableCollection<PermissionEntry>) — bound to DataGrid + - [ObservableProperty] SelectedSites (ObservableCollection<SiteInfo>) — 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 + + + Create `SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs`. + + Constructor: inject `IPermissionsService`, `ISiteListService`, `CsvExportService`, `HtmlExportService`, `SessionManager`, `ILogger`. + + Key implementation: + ```csharp + protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) + { + var urls = SelectedSites.Count > 0 + ? SelectedSites.Select(s => s.Url).ToList() + : new List { SiteUrl }; + + if (urls.All(string.IsNullOrWhiteSpace)) + { + StatusMessage = "Enter a site URL or select sites."; + return; + } + + var allEntries = new List(); + 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(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`. + + + dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~PermissionsViewModelTests" -x + + PermissionsViewModelTests: StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl passes (using mock IPermissionsService). dotnet build 0 errors. + + + + Task 2: Implement SitePickerDialog XAML and code-behind + + SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml + SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml.cs + + + 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 _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 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(), 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. + + + dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx 2>&1 | tail -5 + + dotnet build succeeds with 0 errors. SitePickerDialog.xaml and .cs exist and compile. No XAML parse errors. + + + + + +- `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 + + + +- 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 + + + +After completion, create `.planning/phases/02-permissions/02-06-SUMMARY.md` + diff --git a/.planning/phases/02-permissions/02-07-PLAN.md b/.planning/phases/02-permissions/02-07-PLAN.md new file mode 100644 index 0000000..ef7dabe --- /dev/null +++ b/.planning/phases/02-permissions/02-07-PLAN.md @@ -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()" + 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" +--- + + +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. + + + +@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md +@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/phases/02-permissions/02-RESEARCH.md + + + + + + + +From MainWindow.xaml (current stub — line 45 is the Permissions tab): +```xml + + + + + +``` + +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(); + DataContext = vm; + // Wire dialog factory — avoids Window/DI coupling in ViewModel (Phase 1 pattern) + vm.OpenSitePickerDialog = () => serviceProvider.GetRequiredService(); + } +} +``` + +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>()(vm.CurrentProfile!); +// Register in DI: services.AddTransient>(sp => +// profile => new SitePickerDialog(sp.GetRequiredService(), 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}}` + + + + + + + Task 1: Create PermissionsView XAML + code-behind and register DI + + SharepointToolbox/Views/Tabs/PermissionsView.xaml + SharepointToolbox/Views/Tabs/PermissionsView.xaml.cs + SharepointToolbox/App.xaml.cs + SharepointToolbox/MainWindow.xaml + SharepointToolbox/MainWindow.xaml.cs + + + 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(); + DataContext = vm; + vm.OpenSitePickerDialog = () => + { + var factory = serviceProvider.GetRequiredService>(); + 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(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient>(sp => + profile => new SitePickerDialog(sp.GetRequiredService(), profile)); + ``` + + Step 4 — Update MainWindow.xaml: replace the FIRST `` (the Permissions tab) with: + ```xml + + + + + + + ``` + 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). + + + dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx 2>&1 | tail -5 + + dotnet build succeeds with 0 errors. All services registered in DI. PermissionsView compiles. MainWindow.xaml has `<views:PermissionsView />` instead of `<controls:FeatureTabBase />` for the Permissions tab. + + + + Checkpoint: Visual verification of Permissions tab in running application + Human verifies the running application visually as described in how-to-verify below. + + HUMAN — run app and confirm checklist: tab visible, scan options present, export buttons disabled, French locale works + + Human types "approved" confirming all 7 checklist items pass. + + 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. + + + 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 + + Type "approved" if all verifications pass, or describe what is wrong + + + + + +- `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 `` for the Permissions tab position +- App.xaml.cs contains `AddTransient()` +- Human confirms: Permissions tab visible and functional in running app + + + +- 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) + + + +After completion, create `.planning/phases/02-permissions/02-07-SUMMARY.md` +