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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user