---
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&lt;OperationProgress&gt; progress, CancellationToken ct) returns Task&lt;IReadOnlyList&lt;SiteInfo&gt;&gt;
    - 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>
