- Replace GetSitePropertiesFromSharePoint("", true) with modern
GetSitePropertiesFromSharePointByFilters using null StartIndex
- Use ctx.Clone(adminUrl) instead of creating new AuthenticationManager
for admin URL, eliminating second browser auth prompt
Resolves: UAT issue "Must specify valid information for parsing in the string"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
86 lines
3.0 KiB
C#
86 lines
3.0 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Online.SharePoint.TenantAdministration;
|
|
using Microsoft.SharePoint.Client;
|
|
using SharepointToolbox.Core.Models;
|
|
|
|
namespace SharepointToolbox.Services;
|
|
|
|
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..."));
|
|
|
|
// Obtain the already-authenticated context for the tenant URL, then clone it to
|
|
// the admin URL. Cloning reuses the existing token — no second interactive login.
|
|
ClientContext ctx;
|
|
try
|
|
{
|
|
ctx = await _sessionManager.GetOrCreateContextAsync(profile, 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 adminUrl = DeriveAdminUrl(profile.TenantUrl);
|
|
using var adminCtx = ctx.Clone(adminUrl);
|
|
|
|
var results = new List<SiteInfo>();
|
|
var tenant = new Tenant(adminCtx);
|
|
SPOSitePropertiesEnumerable? batch = null;
|
|
|
|
// Paginate through all site collections using GetSitePropertiesFromSharePointByFilters.
|
|
// StartIndex = null on the first call; subsequent calls use NextStartIndexFromSharePoint.
|
|
while (batch == null || batch.NextStartIndexFromSharePoint != null)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var filter = new SPOSitePropertiesEnumerableFilter
|
|
{
|
|
IncludePersonalSite = PersonalSiteFilter.UseServerDefault,
|
|
StartIndex = batch?.NextStartIndexFromSharePoint,
|
|
IncludeDetail = true
|
|
};
|
|
|
|
batch = tenant.GetSitePropertiesFromSharePointByFilters(filter);
|
|
adminCtx.Load(batch);
|
|
await adminCtx.ExecuteQueryAsync();
|
|
|
|
foreach (var s in batch)
|
|
{
|
|
if (s.Status == "Active"
|
|
&& !s.Url.Contains("-my.sharepoint.com", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
results.Add(new SiteInfo(s.Url, s.Title));
|
|
}
|
|
}
|
|
}
|
|
|
|
return results.OrderBy(s => s.Url).ToList();
|
|
}
|
|
|
|
internal static string DeriveAdminUrl(string tenantUrl)
|
|
=> Regex.Replace(tenantUrl.TrimEnd('/'),
|
|
@"(https://[^.]+)(\.sharepoint\.com)",
|
|
"$1-admin$2",
|
|
RegexOptions.IgnoreCase);
|
|
}
|