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:
@@ -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 | - |
|
||||
|
||||
224
.planning/phases/02-permissions/02-01-PLAN.md
Normal file
224
.planning/phases/02-permissions/02-01-PLAN.md
Normal 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>
|
||||
307
.planning/phases/02-permissions/02-02-PLAN.md
Normal file
307
.planning/phases/02-permissions/02-02-PLAN.md
Normal 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<IReadOnlyList<PermissionEntry>>
|
||||
- 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>
|
||||
221
.planning/phases/02-permissions/02-03-PLAN.md
Normal file
221
.planning/phases/02-permissions/02-03-PLAN.md
Normal 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<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
|
||||
</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>
|
||||
250
.planning/phases/02-permissions/02-04-PLAN.md
Normal file
250
.planning/phases/02-permissions/02-04-PLAN.md
Normal 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<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
|
||||
</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<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
|
||||
</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>
|
||||
171
.planning/phases/02-permissions/02-05-PLAN.md
Normal file
171
.planning/phases/02-permissions/02-05-PLAN.md
Normal 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>
|
||||
332
.planning/phases/02-permissions/02-06-PLAN.md
Normal file
332
.planning/phases/02-permissions/02-06-PLAN.md
Normal 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<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
|
||||
</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>
|
||||
252
.planning/phases/02-permissions/02-07-PLAN.md
Normal file
252
.planning/phases/02-permissions/02-07-PLAN.md
Normal 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 `<views:PermissionsView />` instead of `<controls:FeatureTabBase />` 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>
|
||||
Reference in New Issue
Block a user