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