chore: release v2.4

- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager)
- Add InputDialog, Spinner common view
- Add DuplicatesCsvExportService
- Refresh views, dialogs, and view models across tabs
- Update localization strings (en/fr)
- Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-20 11:23:11 +02:00
parent 8f30a60d2a
commit 12dd1de9f2
93 changed files with 8708 additions and 1159 deletions
@@ -11,12 +11,41 @@ namespace SharepointToolbox.Services;
/// Manages Azure AD app registration and removal using the Microsoft Graph API.
/// All operations use <see cref="GraphClientFactory"/> for token acquisition.
/// </summary>
/// <remarks>
/// <para>GraphServiceClient lifecycle: a fresh client is created per public call
/// (<see cref="IsGlobalAdminAsync"/>, <see cref="RegisterAsync"/>,
/// <see cref="RollbackAsync"/>, <see cref="RemoveAsync"/>). This is intentional —
/// each call may use different scopes (RegistrationScopes vs. default) and target
/// a different tenant, so a cached per-service instance would bind the wrong
/// authority. The factory itself caches the underlying MSAL PCA and token cache,
/// so client construction is cheap (no network hit when tokens are valid).</para>
/// <para>Do not cache a GraphServiceClient at call sites — always go through
/// <see cref="GraphClientFactory"/> so tenant pinning and scope selection stay
/// correct.</para>
/// </remarks>
public class AppRegistrationService : IAppRegistrationService
{
// Entra built-in directory role template IDs are global constants shared across all tenants.
// GlobalAdminTemplateId: "Global Administrator" directoryRoleTemplate.
// See https://learn.microsoft.com/entra/identity/role-based-access-control/permissions-reference#global-administrator
private const string GlobalAdminTemplateId = "62e90394-69f5-4237-9190-012177145e10";
// First-party Microsoft service appIds (constant across tenants).
private const string GraphAppId = "00000003-0000-0000-c000-000000000000";
private const string SharePointAppId = "00000003-0000-0ff1-ce00-000000000000";
// Explicit scopes for the registration flow. The bootstrap client
// (Microsoft Graph Command Line Tools) does not pre-consent these, so
// requesting `.default` returns a token without them → POST /applications
// fails with 403 even for a Global Admin. Requesting them explicitly
// triggers the admin-consent prompt on first use.
private static readonly string[] RegistrationScopes = new[]
{
"https://graph.microsoft.com/Application.ReadWrite.All",
"https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All",
"https://graph.microsoft.com/Directory.Read.All",
};
private readonly AppGraphClientFactory _graphFactory;
private readonly AppMsalClientFactory _msalFactory;
private readonly ISessionManager _sessionManager;
@@ -35,38 +64,33 @@ public class AppRegistrationService : IAppRegistrationService
}
/// <inheritdoc/>
public async Task<bool> IsGlobalAdminAsync(string clientId, CancellationToken ct)
public async Task<bool> IsGlobalAdminAsync(string clientId, string tenantUrl, CancellationToken ct)
{
try
{
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
var roles = await graphClient.Me.TransitiveMemberOf.GetAsync(req =>
{
req.QueryParameters.Filter = "isof('microsoft.graph.directoryRole')";
}, ct);
// No $filter: isof() on directoryObject requires advanced query params
// (ConsistencyLevel: eventual + $count=true) and fails with 400 otherwise.
// The user's membership list is small; filtering client-side is fine.
var tenantId = ResolveTenantId(tenantUrl);
var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, ct);
var memberships = await graphClient.Me.TransitiveMemberOf.GetAsync(cancellationToken: ct);
return roles?.Value?
.OfType<DirectoryRole>()
.Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId,
StringComparison.OrdinalIgnoreCase)) ?? false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "IsGlobalAdminAsync failed — treating as non-admin. ClientId={ClientId}", clientId);
return false;
}
return memberships?.Value?
.OfType<DirectoryRole>()
.Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId,
StringComparison.OrdinalIgnoreCase)) ?? false;
}
/// <inheritdoc/>
public async Task<AppRegistrationResult> RegisterAsync(
string clientId,
string tenantUrl,
string tenantDisplayName,
CancellationToken ct)
{
var tenantId = ResolveTenantId(tenantUrl);
Application? createdApp = null;
try
{
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct);
// Step 1: Create Application object
var appRequest = new Application
@@ -78,7 +102,10 @@ public class AppRegistrationService : IAppRegistrationService
{
RedirectUris = new List<string>
{
"https://login.microsoftonline.com/common/oauth2/nativeclient"
// Loopback URI for MSAL desktop default (any port accepted by Entra).
"http://localhost",
// Legacy native-client URI for embedded WebView fallback.
"https://login.microsoftonline.com/common/oauth2/nativeclient",
}
},
RequiredResourceAccess = BuildRequiredResourceAccess()
@@ -131,34 +158,45 @@ public class AppRegistrationService : IAppRegistrationService
_logger.LogInformation("App registration complete. AppId={AppId}", createdApp.AppId);
return AppRegistrationResult.Success(createdApp.AppId!);
}
catch (Microsoft.Graph.Models.ODataErrors.ODataError odataEx)
when (odataEx.ResponseStatusCode == 401 || odataEx.ResponseStatusCode == 403)
{
_logger.LogWarning(odataEx,
"RegisterAsync refused by Graph (status {Status}) — user lacks role/consent. Surfacing fallback.",
odataEx.ResponseStatusCode);
await RollbackAsync(createdApp, clientId, tenantId, ct);
return AppRegistrationResult.FallbackRequired();
}
catch (Exception ex)
{
_logger.LogError(ex, "RegisterAsync failed. Attempting rollback.");
if (createdApp?.Id is not null)
{
try
{
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
await graphClient.Applications[createdApp.Id].DeleteAsync(cancellationToken: ct);
_logger.LogInformation("Rollback: deleted Application {ObjectId}", createdApp.Id);
}
catch (Exception rollbackEx)
{
_logger.LogWarning(rollbackEx, "Rollback failed for Application {ObjectId}", createdApp.Id);
}
}
await RollbackAsync(createdApp, clientId, tenantId, ct);
return AppRegistrationResult.Failure(ex.Message);
}
}
private async Task RollbackAsync(Application? createdApp, string clientId, string tenantId, CancellationToken ct)
{
if (createdApp?.Id is null) return;
try
{
var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct);
await graphClient.Applications[createdApp.Id].DeleteAsync(cancellationToken: ct);
_logger.LogInformation("Rollback: deleted Application {ObjectId}", createdApp.Id);
}
catch (Exception rollbackEx)
{
_logger.LogWarning(rollbackEx, "Rollback failed for Application {ObjectId}", createdApp.Id);
}
}
/// <inheritdoc/>
public async Task RemoveAsync(string clientId, string appId, CancellationToken ct)
public async Task RemoveAsync(string clientId, string tenantUrl, string appId, CancellationToken ct)
{
try
{
var graphClient = await _graphFactory.CreateClientAsync(clientId, ct);
var tenantId = ResolveTenantId(tenantUrl);
var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct);
await graphClient.Applications[$"(appId='{appId}')"].DeleteAsync(cancellationToken: ct);
_logger.LogInformation("Removed Application appId={AppId}", appId);
}
@@ -168,6 +206,32 @@ public class AppRegistrationService : IAppRegistrationService
}
}
/// <summary>
/// Derives a tenant identifier (domain) from a SharePoint tenant URL so MSAL
/// can pin the authority to the correct tenant. Examples:
/// https://contoso.sharepoint.com → contoso.onmicrosoft.com
/// https://contoso-admin.sharepoint.com → contoso.onmicrosoft.com
/// Throws <see cref="ArgumentException"/> when the URL is not a recognisable
/// SharePoint URL — falling back to /common would silently route registration
/// to the signed-in user's home tenant, which is the bug this guards against.
/// </summary>
internal static string ResolveTenantId(string tenantUrl)
{
if (!Uri.TryCreate(tenantUrl, UriKind.Absolute, out var uri))
throw new ArgumentException($"Invalid tenant URL: '{tenantUrl}'", nameof(tenantUrl));
var host = uri.Host;
var firstDot = host.IndexOf('.');
if (firstDot <= 0)
throw new ArgumentException($"Cannot derive tenant from host '{host}'", nameof(tenantUrl));
var prefix = host.Substring(0, firstDot);
if (prefix.EndsWith("-admin", StringComparison.OrdinalIgnoreCase))
prefix = prefix.Substring(0, prefix.Length - "-admin".Length);
return $"{prefix}.onmicrosoft.com";
}
/// <inheritdoc/>
public async Task ClearMsalSessionAsync(string clientId, string tenantUrl)
{