--- phase: 18-auto-take-ownership plan: 02 type: execute wave: 2 depends_on: ["18-01"] files_modified: - SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs - SharepointToolbox/Views/Tabs/PermissionsView.xaml - SharepointToolbox/Localization/Strings.resx - SharepointToolbox/Localization/Strings.fr.resx - SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs autonomous: true requirements: - OWN-02 must_haves: truths: - "When toggle OFF, access-denied exceptions propagate normally (no elevation attempt)" - "When toggle ON and scan hits access denied, app calls ElevateAsync once then retries ScanSiteAsync" - "Successful scans never call ElevateAsync" - "Auto-elevated entries have WasAutoElevated=true in the results" - "Auto-elevated rows are visually distinct in the DataGrid (amber highlight)" artifacts: - path: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs" provides: "Scan-loop catch/elevate/retry logic" contains: "ServerUnauthorizedAccessException" - path: "SharepointToolbox/Views/Tabs/PermissionsView.xaml" provides: "Visual differentiation for auto-elevated rows" contains: "WasAutoElevated" - path: "SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs" provides: "Unit tests for elevation behavior" contains: "PermissionsViewModelOwnershipTests" key_links: - from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs" to: "SharepointToolbox/Services/IOwnershipElevationService.cs" via: "ElevateAsync call inside catch block" pattern: "ElevateAsync" - from: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs" to: "SharepointToolbox/Services/SettingsService.cs" via: "Reading AutoTakeOwnership toggle state" pattern: "AutoTakeOwnership" - from: "SharepointToolbox/Views/Tabs/PermissionsView.xaml" to: "SharepointToolbox/Core/Models/PermissionEntry.cs" via: "DataTrigger on WasAutoElevated" pattern: "WasAutoElevated" --- Wire the auto-elevation logic into the permission scan loop: catch ServerUnauthorizedAccessException, call Tenant.SetSiteAdmin via IOwnershipElevationService, retry the scan, and tag returned entries with WasAutoElevated=true. Add visual differentiation in the DataGrid. Purpose: Complete OWN-02 — scans no longer block on access-denied sites when the toggle is ON. Output: PermissionsViewModel catches access denied and auto-elevates, DataGrid shows amber highlight for elevated rows. @C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/18-auto-take-ownership/18-RESEARCH.md @.planning/phases/18-auto-take-ownership/18-01-SUMMARY.md From SharepointToolbox/Services/IOwnershipElevationService.cs: ```csharp public interface IOwnershipElevationService { Task ElevateAsync(ClientContext tenantAdminCtx, string siteUrl, string loginName, CancellationToken ct); } ``` From SharepointToolbox/Core/Models/PermissionEntry.cs (after Plan 01): ```csharp public record PermissionEntry( string ObjectType, string Title, string Url, bool HasUniquePermissions, string Users, string UserLogins, string PermissionLevels, string GrantedThrough, string PrincipalType, bool WasAutoElevated = false ); ``` From SharepointToolbox/Core/Models/AppSettings.cs (after Plan 01): ```csharp public class AppSettings { public string DataFolder { get; set; } = string.Empty; public string Lang { get; set; } = "en"; public bool AutoTakeOwnership { get; set; } = false; } ``` From SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs (existing scan loop): ```csharp // Lines 202-237: RunOperationAsync — foreach (var url in nonEmpty) // Creates TenantProfile per URL, gets ctx via _sessionManager.GetOrCreateContextAsync // Calls _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct) // Adds entries to allEntries // Constructor (production): IPermissionsService, ISiteListService, ISessionManager, // CsvExportService, HtmlExportService, IBrandingService, ILogger, ISharePointGroupResolver? // Constructor (test): IPermissionsService, ISiteListService, ISessionManager, ILogger, IBrandingService? ``` From SharepointToolbox/App.xaml.cs (DI): ```csharp // Line 119-124: PermissionsViewModel registered as AddTransient // IOwnershipElevationService registered by Plan 01 ``` Task 1: Scan-loop elevation logic + PermissionsViewModel wiring + tests SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs, SharepointToolbox/App.xaml.cs, SharepointToolbox.Tests/ViewModels/PermissionsViewModelOwnershipTests.cs - Test: When AutoTakeOwnership=false and ScanSiteAsync throws ServerUnauthorizedAccessException, the exception propagates (not caught) - Test: When AutoTakeOwnership=true and ScanSiteAsync throws ServerUnauthorizedAccessException, ElevateAsync is called once with correct args, then ScanSiteAsync retried - Test: When ScanSiteAsync succeeds on first try, ElevateAsync is never called - Test: After successful elevation+retry, returned PermissionEntry items have WasAutoElevated=true - Test: If elevation itself throws, the exception propagates (no infinite retry) 1. Add `IOwnershipElevationService? _ownershipService` and `SettingsService _settingsService` fields to `PermissionsViewModel`. Inject `IOwnershipElevationService?` as an optional last parameter in the production constructor (matching the `ISharePointGroupResolver?` pattern). Inject `SettingsService` as a required parameter. 2. Update both constructors: - Production constructor: add `SettingsService settingsService` (required) and `IOwnershipElevationService? ownershipService = null` (optional, last). - Test constructor: add `SettingsService? settingsService = null` and `IOwnershipElevationService? ownershipService = null` as optional params. 3. Update DI registration in `App.xaml.cs` — no change needed if using optional injection (DI resolves registered services automatically). But verify the constructor parameter order matches DI expectations. If needed, add explicit resolution. 4. Add a `DeriveAdminUrl` internal static helper method in `PermissionsViewModel`: ```csharp internal static string DeriveAdminUrl(string tenantUrl) { var uri = new Uri(tenantUrl.TrimEnd('/')); var host = uri.Host; if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase)) return tenantUrl; var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase); return $"{uri.Scheme}://{adminHost}"; } ``` 5. Modify `RunOperationAsync` scan loop (lines 221-237). Replace the direct `ScanSiteAsync` call with a try/catch pattern. Catch BOTH `ServerUnauthorizedAccessException` and `WebException` with 403 status (see Pitfall 4 in RESEARCH.md). Use `when` filter to check toggle state: ```csharp foreach (var url in nonEmpty) { ct.ThrowIfCancellationRequested(); progress.Report(new OperationProgress(i, nonEmpty.Count, $"Scanning {url}...")); var profile = new TenantProfile { TenantUrl = url, ClientId = _currentProfile?.ClientId ?? string.Empty, Name = _currentProfile?.Name ?? string.Empty }; bool wasElevated = false; IReadOnlyList siteEntries; try { var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct); siteEntries = await _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct); } catch (Exception ex) when (IsAccessDenied(ex) && _ownershipService != null && await IsAutoTakeOwnershipEnabled()) { _logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", url); var adminUrl = DeriveAdminUrl(_currentProfile?.TenantUrl ?? url); var adminProfile = new TenantProfile { TenantUrl = adminUrl, ClientId = _currentProfile?.ClientId ?? string.Empty, Name = _currentProfile?.Name ?? string.Empty }; var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct); // Get current user login from the site context var siteProfile = profile; var siteCtx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct); siteCtx.Load(siteCtx.Web, w => w.CurrentUser); await siteCtx.ExecuteQueryAsync(); var loginName = siteCtx.Web.CurrentUser.LoginName; await _ownershipService.ElevateAsync(adminCtx, url, loginName, ct); // Retry scan with fresh context var retryCtx = await _sessionManager.GetOrCreateContextAsync(profile, ct); siteEntries = await _permissionsService.ScanSiteAsync(retryCtx, scanOptions, progress, ct); wasElevated = true; } if (wasElevated) allEntries.AddRange(siteEntries.Select(e => e with { WasAutoElevated = true })); else allEntries.AddRange(siteEntries); i++; } ``` 6. Add the `IsAccessDenied` helper (private static): ```csharp private static bool IsAccessDenied(Exception ex) { if (ex is Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) return true; if (ex is System.Net.WebException webEx && webEx.Response is System.Net.HttpWebResponse resp && resp.StatusCode == System.Net.HttpStatusCode.Forbidden) return true; return false; } ``` 7. Add the `IsAutoTakeOwnershipEnabled` helper (private async): ```csharp private async Task IsAutoTakeOwnershipEnabled() { if (_settingsService == null) return false; var settings = await _settingsService.GetSettingsAsync(); return settings.AutoTakeOwnership; } ``` 8. Create `PermissionsViewModelOwnershipTests.cs` with mock IPermissionsService, ISessionManager, IOwnershipElevationService, and SettingsService. Test all 5 behaviors listed above. Use the internal test constructor. For the "throws ServerUnauthorizedAccessException" test, configure mock ScanSiteAsync to throw on first call then return entries on second call. IMPORTANT: The `when` clause with `await` requires C# 8+ async exception filters. If the compiler rejects `await` in `when`, refactor to check settings BEFORE the try block: `var autoOwn = await IsAutoTakeOwnershipEnabled();` then use `when (IsAccessDenied(ex) && _ownershipService != null && autoOwn)`. dotnet build SharepointToolbox.sln && dotnet test SharepointToolbox.Tests --no-build --filter "FullyQualifiedName~PermissionsViewModelOwnership" PermissionsViewModel catches access-denied during scans, auto-elevates via IOwnershipElevationService when toggle is ON, retries the scan, and tags entries with WasAutoElevated=true. Toggle OFF = no change in behavior. All 5 test scenarios pass. Task 2: DataGrid visual differentiation + localization for elevated rows SharepointToolbox/Views/Tabs/PermissionsView.xaml, SharepointToolbox/Localization/Strings.resx, SharepointToolbox/Localization/Strings.fr.resx 1. In `PermissionsView.xaml`, add a DataTrigger for `WasAutoElevated` inside the `DataGrid.RowStyle` (after the existing RiskLevel triggers, around line 234): ```xml ``` Note: WasAutoElevated is on `PermissionEntry` (raw mode). When simplified mode is active, `SimplifiedPermissionEntry` wraps `PermissionEntry` — check whether `SimplifiedPermissionEntry.WrapAll` preserves the `WasAutoElevated` flag. If `SimplifiedPermissionEntry` does not expose it, the trigger only applies in raw mode (acceptable for v2.3). 2. Add a small indicator column in the DataGrid columns (before "Object Type"), showing a lock icon for elevated rows: ```xml ``` If `BoolToVisibilityConverter` is not registered, use a DataTrigger style instead: ```xml ``` 3. Add localization keys to `Strings.resx`: - `permissions.elevated.tooltip` = "This site was automatically elevated — ownership was taken to complete the scan" 4. Add French translation to `Strings.fr.resx`: - `permissions.elevated.tooltip` = "Ce site a ete eleve automatiquement — la propriete a ete prise pour completer le scan" dotnet build SharepointToolbox.sln && dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~LocaleCompleteness" --no-build DataGrid rows with WasAutoElevated=true show amber background + warning icon column. Tooltip explains the elevation. EN + FR localization keys present. LocaleCompletenessTests pass. 1. `dotnet build SharepointToolbox.sln` — zero errors 2. `dotnet test SharepointToolbox.Tests --no-build` — full suite green 3. `dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~PermissionsViewModelOwnership" --no-build` — elevation tests pass 4. `dotnet test SharepointToolbox.Tests --filter "FullyQualifiedName~LocaleCompleteness" --no-build` — locale keys complete - Access-denied during scan with toggle ON triggers auto-elevation + retry - Access-denied during scan with toggle OFF propagates normally - Successful scans never attempt elevation - Elevated entries tagged with WasAutoElevated=true - Elevated rows visually distinct in DataGrid (amber + icon) - Full test suite green with no regressions After completion, create `.planning/phases/18-auto-take-ownership/18-02-SUMMARY.md`