From 78b3d4f759fa339ef7c50bdfc9490027d87cfb46 Mon Sep 17 00:00:00 2001 From: Dev Date: Thu, 2 Apr 2026 13:50:35 +0200 Subject: [PATCH] feat(02-03): implement ISiteListService and SiteListService with admin URL derivation - SiteInfo record added to Core/Models - ISiteListService interface with GetSitesAsync signature - SiteListService derives admin URL via Regex, connects via SessionManager - Filters to Active sites only, excludes OneDrive personal (-my.sharepoint.com) - Access denied ServerException wrapped as InvalidOperationException with actionable message - DeriveAdminUrl marked internal static for unit testability - InternalsVisibleTo added to AssemblyInfo.cs to expose internal to test project - 2 DeriveAdminUrl tests pass; full suite: 53 pass, 4 skip, 0 fail --- SharepointToolbox/AssemblyInfo.cs | 13 ++++ SharepointToolbox/Core/Models/SiteInfo.cs | 3 + .../Services/ISiteListService.cs | 11 +++ SharepointToolbox/Services/SiteListService.cs | 69 +++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 SharepointToolbox/AssemblyInfo.cs create mode 100644 SharepointToolbox/Core/Models/SiteInfo.cs create mode 100644 SharepointToolbox/Services/ISiteListService.cs create mode 100644 SharepointToolbox/Services/SiteListService.cs diff --git a/SharepointToolbox/AssemblyInfo.cs b/SharepointToolbox/AssemblyInfo.cs new file mode 100644 index 0000000..aa97970 --- /dev/null +++ b/SharepointToolbox/AssemblyInfo.cs @@ -0,0 +1,13 @@ +using System.Runtime.CompilerServices; +using System.Windows; + +[assembly: InternalsVisibleTo("SharepointToolbox.Tests")] + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/SharepointToolbox/Core/Models/SiteInfo.cs b/SharepointToolbox/Core/Models/SiteInfo.cs new file mode 100644 index 0000000..9408e09 --- /dev/null +++ b/SharepointToolbox/Core/Models/SiteInfo.cs @@ -0,0 +1,3 @@ +namespace SharepointToolbox.Core.Models; + +public record SiteInfo(string Url, string Title); diff --git a/SharepointToolbox/Services/ISiteListService.cs b/SharepointToolbox/Services/ISiteListService.cs new file mode 100644 index 0000000..9086be9 --- /dev/null +++ b/SharepointToolbox/Services/ISiteListService.cs @@ -0,0 +1,11 @@ +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services; + +public interface ISiteListService +{ + Task> GetSitesAsync( + TenantProfile profile, + IProgress progress, + CancellationToken ct); +} diff --git a/SharepointToolbox/Services/SiteListService.cs b/SharepointToolbox/Services/SiteListService.cs new file mode 100644 index 0000000..2311e97 --- /dev/null +++ b/SharepointToolbox/Services/SiteListService.cs @@ -0,0 +1,69 @@ +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> 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); +}