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
|
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
|
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
|
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
|
### 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.
|
**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 |
|
| Phase | Plans Complete | Status | Completed |
|
||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Foundation | 8/8 | Complete | 2026-04-02 |
|
| 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 | - |
|
| 3. Storage and File Operations | 0/? | Not started | - |
|
||||||
| 4. Bulk Operations and Provisioning | 0/? | Not started | - |
|
| 4. Bulk Operations and Provisioning | 0/? | Not started | - |
|
||||||
| 5. Distribution and Hardening | 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