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

276 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```csharp
// 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.
```csharp
// 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.
```csharp
// 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:**
```csharp
// 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:**
```csharp
// 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.
```csharp
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<T> 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