Files
Sharepoint-Toolbox/.planning/research/STACK.md
2026-04-08 10:57:27 +02:00

13 KiB
Raw Blame History

Technology Stack

Project: SharePoint Toolbox v2 Researched: 2026-04-08 (updated for v2.2 milestone)


v2.2 Stack Additions

This section covers only the NEW capability needs for v2.2 (Report Branding + User Directory). The full existing stack is documented in the section below. The short answer: no new NuGet packages are needed for either feature.


Feature 1: HTML Report Branding (Logo Embedding)

Requirement: Embed MSP logo (global) and client logo (per-tenant) into the self-contained HTML reports that already exist.

Approach: Base64 data URI — BCL only

The existing HTML export services (HtmlExportService, UserAccessHtmlExportService, etc.) produce fully self-contained HTML files using StringBuilder with all CSS and JS inlined. Logo images follow the same pattern: convert image bytes to a Base64 string and embed as an HTML <img> data URI.

// In a LogoEmbedHelper or directly in each export service:
byte[] bytes = await File.ReadAllBytesAsync(logoFilePath, ct);
string mime  = Path.GetExtension(logoFilePath).ToLowerInvariant() switch
{
    ".png"  => "image/png",
    ".jpg"  => "image/jpeg",
    ".jpeg" => "image/jpeg",
    ".gif"  => "image/gif",
    ".svg"  => "image/svg+xml",
    ".webp" => "image/webp",
    _       => "image/png"
};
string dataUri = $"data:{mime};base64,{Convert.ToBase64String(bytes)}";
// In HTML: <img src="{dataUri}" alt="Logo" style="height:48px;" />

Why this approach:

  • Zero new dependencies. File.ReadAllBytesAsync, Convert.ToBase64String, and Path.GetExtension are all BCL.
  • The existing "no external dependencies" constraint on HTML reports is preserved.
  • Self-contained EXE constraint is preserved — no logo file paths can break because the bytes are embedded in the HTML at export time.
  • Base64 increases image size by ~33% but logos are small (< 50 KB typical); the impact on HTML file size is negligible.

Logo storage strategy — store file path, embed at export time:

Store the logo file path (not the base64) in AppSettings (global MSP logo) and TenantProfile (per-client logo). At export time, the export service reads the file and embeds it. This keeps JSON settings files small and lets the user swap logos without re-entering settings.

  • AppSettings.MspLogoPath: string? — path to MSP logo file
  • TenantProfile.ClientLogoPath: string? — path to client logo file for this tenant

The settings UI uses WPF OpenFileDialog (already used in multiple ViewModels) to browse for image files — filter to *.png;*.jpg;*.jpeg;*.gif;*.svg.

Logo preview in WPF UI: Use BitmapImage (built into System.Windows.Media.Imaging, already in scope for any WPF project). Bind a WPF Image control's Source to a BitmapImage loaded from the file path.

// In ViewModel — logo preview
[ObservableProperty]
private BitmapImage? _mspLogoPreview;

partial void OnMspLogoPathChanged(string? value)
{
    if (string.IsNullOrWhiteSpace(value) || !File.Exists(value))
    {
        MspLogoPreview = null;
        return;
    }
    var bmp = new BitmapImage();
    bmp.BeginInit();
    bmp.UriSource    = new Uri(value, UriKind.Absolute);
    bmp.CacheOption  = BitmapCacheOption.OnLoad; // close file handle immediately
    bmp.EndInit();
    MspLogoPreview = bmp;
}

No new library needed: BitmapImage lives in the WPF PresentationCore assembly, which is already a transitive dependency of any <UseWPF>true</UseWPF> project.


Feature 2: User Directory Browse Mode (Graph API)

Requirement: In the User Access Audit tab, add a "Browse" mode alternative to the people-picker search. Shows a paginated list of all users in the tenant — no search query, just the full directory — allowing the admin to pick users by scrolling/filtering locally.

Graph API endpoint: GET /users (no filter)

The existing GraphUserSearchService calls GET /users?$filter=startsWith(...) with ConsistencyLevel: eventual. Full directory listing removes the $filter and uses $select for the fields needed.

Minimum required fields for directory browse:

displayName, userPrincipalName, mail, jobTitle, department, userType, accountEnabled
  • userType: distinguish "Member" from "Guest" — useful for MSP admin context
  • accountEnabled: allow filtering out disabled accounts
  • jobTitle / department: helps admin identify the right user in large directories

Permissions required (confirmed from Microsoft Learn):

Scope type Minimum permission
Delegated (work/school) User.Read.All

The existing auth uses https://graph.microsoft.com/.default which resolves to whatever scopes the Azure AD app registration has consented. If the MSP's app has User.Read.All consented (required for the existing people-picker to work), no new permission is needed — GET /users without $filter uses the same User.Read.All scope.

Pagination — PageIterator pattern:

GET /users returns a default page size of 100 with a maximum of 999 via $top. For tenants with hundreds or thousands of users, pagination via @odata.nextLink is mandatory.

The Microsoft.Graph 5.x SDK (already installed at 5.74.0) includes PageIterator<TEntity, TCollectionResponse> in Microsoft.Graph.Core. No version upgrade required.

// In a new IGraphUserDirectoryService / GraphUserDirectoryService:
var firstPage = await graphClient.Users.GetAsync(config =>
{
    config.QueryParameters.Select = new[]
    {
        "displayName", "userPrincipalName", "mail",
        "jobTitle", "department", "userType", "accountEnabled"
    };
    config.QueryParameters.Top   = 999; // max page size
    config.QueryParameters.Orderby = new[] { "displayName" };
    config.Headers.Add("ConsistencyLevel", "eventual");
    config.QueryParameters.Count = true; // required for $orderby with eventual
}, ct);

var allUsers = new List<DirectoryUserResult>();

var pageIterator = PageIterator<User, UserCollectionResponse>.CreatePageIterator(
    graphClient,
    firstPage,
    user =>
    {
        if (user.AccountEnabled == true) // optionally skip disabled
            allUsers.Add(new DirectoryUserResult(
                user.DisplayName ?? user.UserPrincipalName ?? string.Empty,
                user.UserPrincipalName ?? string.Empty,
                user.Mail,
                user.JobTitle,
                user.Department,
                user.UserType == "Guest"));
        return true; // continue iteration
    });

await pageIterator.IterateAsync(ct);

Why PageIterator over manual nextLink loop:

The Graph SDK's PageIterator correctly handles the DirectoryPageTokenNotFoundException pitfall — it uses the token from the last successful non-retry response for the next page request. Manual loops using withUrl(nextLink) are susceptible to this error if any retry occurs mid-iteration. The SDK pattern is the documented recommendation (Microsoft Learn, updated 2025-08-06).

Performance consideration — large tenants:

A tenant with 5,000 users fetching $top=999 requires 5 API round-trips. At ~300-500 ms per call, this is 1.52.5 seconds total. This is acceptable for a browse-on-demand operation with a loading indicator. Do NOT load the directory automatically on tab open — require an explicit "Load Directory" button click.

Local filtering after load:

Once the full directory is in memory (as an ObservableCollection<DirectoryUserResult>), use ICollectionView with a Filter predicate for instant local text-filter — the same pattern already used in the PermissionsViewModel and StorageViewModel. No server round-trips needed for filtering once the list is loaded. This is already in-process for the existing ViewModels and requires no new library.

New model record:

// Core/Models/DirectoryUserResult.cs — or extend GraphUserResult
public record DirectoryUserResult(
    string DisplayName,
    string UserPrincipalName,
    string? Mail,
    string? JobTitle,
    string? Department,
    bool IsGuest);

New service interface:

// Services/IGraphUserDirectoryService.cs
public interface IGraphUserDirectoryService
{
    Task<IReadOnlyList<DirectoryUserResult>> GetAllUsersAsync(
        string clientId,
        bool includeGuests = true,
        bool includeDisabled = false,
        CancellationToken ct = default);
}

The implementation follows the same GraphClientFactory + GraphServiceClient pattern as GraphUserSearchService. Wire it in DI alongside the existing search service.


No New NuGet Packages Required

Feature What's needed How provided
Logo file → Base64 data URI Convert.ToBase64String, File.ReadAllBytesAsync BCL (.NET 10)
Logo preview in WPF settings BitmapImage, Image control WPF / PresentationCore
Logo file picker OpenFileDialog WPF / Microsoft.Win32
Store logo path in settings AppSettings.MspLogoPath, TenantProfile.ClientLogoPath Extend existing models
User directory listing graphClient.Users.GetAsync() + PageIterator Microsoft.Graph 5.74.0 (already installed)
Local filtering of directory list ICollectionView.Filter WPF / System.Windows.Data

Do NOT add:

  • Any HTML template engine (Razor, Scriban, Handlebars) — StringBuilder is sufficient for logo injection
  • Any image processing library (ImageSharp, SkiaSharp standalone, Magick.NET) — no image transformation is needed, only raw bytes → Base64
  • Any new Graph SDK packages — Microsoft.Graph 5.74.0 already includes PageIterator

Impact on Existing Services

HTML Export Services

Each existing export service (HtmlExportService, UserAccessHtmlExportService, StorageHtmlExportService, DuplicatesHtmlExportService, SearchHtmlExportService) needs a logo injection point. Two options:

Option A (recommended): ReportBrandingContext parameter

Introduce a small record carrying resolved logo data URIs. Export services accept it as an optional parameter; when null, no logo header is rendered. This keeps the services testable without file I/O.

public record ReportBrandingContext(
    string? MspLogoDataUri,    // "data:image/png;base64,..." or null
    string? ClientLogoDataUri, // "data:image/png;base64,..." or null
    string? MspName,
    string? ClientName);

A ReportBrandingService converts file paths to data URIs. ViewModels call it before invoking the export service.

Option B: Inject branding directly into all BuildHtml signatures

Less clean — modifies every export service signature and every call site.

Option A is preferred: it isolates file I/O from HTML generation and keeps existing tests passing without changes.

UserAccessAuditViewModel

Add a BrowseMode boolean property (bound to a RadioButton or ToggleButton). When true, show the directory list panel instead of the people-picker search box. The IGraphUserDirectoryService is injected alongside the existing IGraphUserSearchService.


Existing Stack (Unchanged)

The full stack as validated through v1.1:

Technology Version Purpose
.NET 10 10.x Target runtime (LTS until Nov 2028)
WPF built-in UI framework
C# 13 built-in Language
PnP.Framework 1.18.0 SharePoint CSOM, provisioning engine
Microsoft.Graph 5.74.0 Graph API (users, Teams, Groups)
Microsoft.Identity.Client (MSAL) 4.83.3 Multi-tenant auth, token acquisition
Microsoft.Identity.Client.Extensions.Msal 4.83.3 Token cache persistence
Microsoft.Identity.Client.Broker 4.82.1 Windows broker (WAM)
CommunityToolkit.Mvvm 8.4.2 MVVM base classes, source generators
Microsoft.Extensions.Hosting 10.0.0 DI container, app lifetime
LiveCharts2 (SkiaSharpView.WPF) 2.0.0-rc5.4 Storage charts (in use, stable enough)
Serilog 4.3.1 Structured logging
Serilog.Extensions.Hosting 10.0.0 ILogger bridge
Serilog.Sinks.File 7.0.0 Rolling file output
CsvHelper 33.1.0 CSV export
System.Text.Json built-in JSON settings/profiles/templates
xUnit 2.9.3 Unit tests
Moq 4.20.72 Test mocking

Sources

  • Microsoft Learn — List users (Graph v1.0): https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0 — permissions, $top max 999, $orderby with ConsistencyLevel, default fields (HIGH confidence, updated 2025-07-23)
  • Microsoft Learn — Page through a collection (Graph SDKs): https://learn.microsoft.com/en-us/graph/sdks/paging — PageIterator C# pattern, DirectoryPageTokenNotFoundException warning (HIGH confidence, updated 2025-08-06)
  • Microsoft Learn — Get organizationalBranding: https://learn.microsoft.com/en-us/graph/api/organizationalbranding-get?view=graph-rest-1.0 — branding stream retrieval via localizations/default/bannerLogo (HIGH confidence, updated 2025-11-08) — note: tenant branding pull is optional/future, not required for v2.2 which relies on user-supplied logo files
  • .NET Perls / BCL docs — Convert.ToBase64String + data URI pattern: confirmed BCL, no library needed (HIGH confidence)
  • Existing codebase inspection: GraphClientFactory.cs, GraphUserSearchService.cs, HtmlExportService.cs, UserAccessHtmlExportService.cs, TenantProfile.cs, AppSettings.cs — confirmed exact integration points