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
@@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
@@ -27,6 +28,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
private readonly UserAccessHtmlExportService? _htmlExportService;
private readonly IBrandingService? _brandingService;
private readonly IGraphUserDirectoryService? _graphUserDirectoryService;
private readonly IOwnershipElevationService? _ownershipService;
private readonly SettingsService? _settingsService;
private readonly ILogger<FeatureViewModelBase> _logger;
// ── People picker debounce ──────────────────────────────────────────────
@@ -105,6 +108,19 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
[ObservableProperty]
private bool _mergePermissions;
/// <summary>0 = Single file, 1 = Split by site, 2 = Split by user.</summary>
[ObservableProperty] private int _splitModeIndex;
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
[ObservableProperty] private int _htmlLayoutIndex;
private ReportSplitMode CurrentSplit => SplitModeIndex switch
{
1 => ReportSplitMode.BySite,
2 => ReportSplitMode.ByUser,
_ => ReportSplitMode.Single
};
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
private CancellationTokenSource? _directoryCts = null;
// ── Computed summary properties ─────────────────────────────────────────
@@ -163,7 +179,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
UserAccessHtmlExportService htmlExportService,
IBrandingService brandingService,
IGraphUserDirectoryService graphUserDirectoryService,
ILogger<FeatureViewModelBase> logger)
ILogger<FeatureViewModelBase> logger,
IOwnershipElevationService? ownershipService = null,
SettingsService? settingsService = null)
: base(logger)
{
_auditService = auditService;
@@ -173,6 +191,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
_htmlExportService = htmlExportService;
_brandingService = brandingService;
_graphUserDirectoryService = graphUserDirectoryService;
_ownershipService = ownershipService;
_settingsService = settingsService;
_logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
@@ -248,13 +268,13 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
{
if (SelectedUsers.Count == 0)
{
StatusMessage = "Add at least one user to audit.";
StatusMessage = TranslationSource.Instance["err.no_users_selected"];
return;
}
if (GlobalSites.Count == 0)
{
StatusMessage = "Select at least one site from the toolbar.";
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return;
}
@@ -269,10 +289,39 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
if (_currentProfile == null)
{
StatusMessage = "No tenant profile selected. Please connect first.";
StatusMessage = TranslationSource.Instance["err.no_profile_selected"];
return;
}
var autoOwnership = await IsAutoTakeOwnershipEnabled();
Func<string, CancellationToken, Task<bool>>? onAccessDenied = null;
if (_ownershipService != null && autoOwnership)
{
onAccessDenied = async (siteUrl, token) =>
{
try
{
_logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", siteUrl);
var adminUrl = DeriveAdminUrl(_currentProfile?.TenantUrl ?? siteUrl);
var adminProfile = new TenantProfile
{
TenantUrl = adminUrl,
ClientId = _currentProfile?.ClientId ?? string.Empty,
Name = _currentProfile?.Name ?? string.Empty
};
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, token);
await _ownershipService.ElevateAsync(adminCtx, siteUrl, string.Empty, token);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Auto-elevation failed for {Url}", siteUrl);
return false;
}
};
}
var entries = await _auditService.AuditUsersAsync(
_sessionManager,
_currentProfile,
@@ -280,7 +329,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
effectiveSites,
scanOptions,
progress,
ct);
ct,
onAccessDenied);
// Update Results on the UI thread — clear + repopulate (not replace)
// so the CollectionViewSource bound to ResultsView stays connected.
@@ -307,6 +357,26 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
ExportHtmlCommand.NotifyCanExecuteChanged();
}
// ── Auto-ownership helpers ───────────────────────────────────────────────
private async Task<bool> IsAutoTakeOwnershipEnabled()
{
if (_settingsService == null) return false;
var settings = await _settingsService.GetSettingsAsync();
return settings.AutoTakeOwnership;
}
internal static string DeriveAdminUrl(string tenantUrl)
{
var uri = new Uri(tenantUrl.TrimEnd('/'));
var host = uri.Host;
if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase))
return tenantUrl;
var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com",
StringComparison.OrdinalIgnoreCase);
return $"{uri.Scheme}://{adminHost}";
}
// ── Tenant switching ─────────────────────────────────────────────────────
protected override void OnTenantSwitched(TenantProfile profile)
@@ -402,7 +472,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
var clientId = _currentProfile?.ClientId;
if (string.IsNullOrEmpty(clientId))
{
StatusMessage = "No tenant profile selected. Please connect first.";
StatusMessage = TranslationSource.Instance["err.no_profile_selected"];
return;
}
@@ -496,7 +566,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
if (dialog.ShowDialog() != true) return;
try
{
await _csvExportService.WriteSingleFileAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions);
await _csvExportService.WriteAsync((IReadOnlyList<UserAccessEntry>)Results, dialog.FileName, CurrentSplit, CancellationToken.None, MergePermissions);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -527,7 +597,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
branding = new ReportBranding(mspLogo, clientLogo);
}
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions, branding);
await _htmlExportService.WriteAsync((IReadOnlyList<UserAccessEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, MergePermissions, branding);
OpenFile(dialog.FileName);
}
catch (Exception ex)