276 lines
13 KiB
Markdown
276 lines
13 KiB
Markdown
# 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.5–2.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
|