Files
Sharepoint-Toolbox/.planning/milestones/v1.0-phases/02-permissions/02-03-PLAN.md
Dev 724fdc550d chore: complete v1.0 milestone
Archive 5 phases (36 plans) to milestones/v1.0-phases/.
Archive roadmap, requirements, and audit to milestones/.
Evolve PROJECT.md with shipped state and validated requirements.
Collapse ROADMAP.md to one-line milestone summary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:19:03 +02:00

9.3 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-permissions 03 execute 1
02-01
SharepointToolbox/Services/SiteListService.cs
SharepointToolbox/Services/ISiteListService.cs
true
PERM-02
truths artifacts key_links
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.comhttps://contoso-admin.sharepoint.com
path provides exports
SharepointToolbox/Services/ISiteListService.cs Interface for ViewModel mocking
ISiteListService
path provides exports
SharepointToolbox/Services/SiteListService.cs Tenant admin API wrapper for listing all sites
SiteListService
from to via pattern
SiteListService.cs SessionManager.GetOrCreateContextAsync admin context acquisition GetOrCreateContextAsync
from to via pattern
SiteListService.cs Microsoft.Online.SharePoint.TenantAdministration.Tenant GetSitePropertiesFromSharePoint 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.

<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>

@.planning/PROJECT.md @.planning/phases/02-permissions/02-RESEARCH.md

From SharepointToolbox/Services/SessionManager.cs:

// 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:

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):

// 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):

// 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<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);
}
```
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

<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>
After completion, create `.planning/phases/02-permissions/02-03-SUMMARY.md`