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>
This commit is contained in:
221
.planning/milestones/v1.0-phases/02-permissions/02-03-PLAN.md
Normal file
221
.planning/milestones/v1.0-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>
|
||||
Reference in New Issue
Block a user