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
@@ -105,9 +105,9 @@ public partial class BulkMembersViewModel : FeatureViewModelBase
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
if (_currentProfile == null) throw new InvalidOperationException(TranslationSource.Instance["err.no_tenant"]);
if (_validRows == null || _validRows.Count == 0)
throw new InvalidOperationException("No valid rows to process. Import a CSV first.");
throw new InvalidOperationException(TranslationSource.Instance["err.no_valid_rows"]);
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
$"{_validRows.Count} members will be added");
@@ -105,9 +105,9 @@ public partial class BulkSitesViewModel : FeatureViewModelBase
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
if (_currentProfile == null) throw new InvalidOperationException(TranslationSource.Instance["err.no_tenant"]);
if (_validRows == null || _validRows.Count == 0)
throw new InvalidOperationException("No valid rows to process. Import a CSV first.");
throw new InvalidOperationException(TranslationSource.Instance["err.no_valid_rows"]);
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
$"{_validRows.Count} sites will be created");
@@ -6,6 +6,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;
@@ -31,6 +32,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
private readonly IDuplicatesService _duplicatesService;
private readonly ISessionManager _sessionManager;
private readonly DuplicatesHtmlExportService _htmlExportService;
private readonly DuplicatesCsvExportService _csvExportService;
private readonly IBrandingService _brandingService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
@@ -46,6 +48,14 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
[ObservableProperty] private bool _includeSubsites;
[ObservableProperty] private string _library = string.Empty;
/// <summary>0 = Single file, 1 = Split by site.</summary>
[ObservableProperty] private int _splitModeIndex;
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
[ObservableProperty] private int _htmlLayoutIndex;
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
private ObservableCollection<DuplicateRow> _results = new();
public ObservableCollection<DuplicateRow> Results
{
@@ -55,16 +65,19 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
_results = value;
OnPropertyChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
ExportCsvCommand.NotifyCanExecuteChanged();
}
}
public IAsyncRelayCommand ExportHtmlCommand { get; }
public IAsyncRelayCommand ExportCsvCommand { get; }
public TenantProfile? CurrentProfile => _currentProfile;
public DuplicatesViewModel(
IDuplicatesService duplicatesService,
ISessionManager sessionManager,
DuplicatesHtmlExportService htmlExportService,
DuplicatesCsvExportService csvExportService,
IBrandingService brandingService,
ILogger<FeatureViewModelBase> logger)
: base(logger)
@@ -72,24 +85,26 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
_duplicatesService = duplicatesService;
_sessionManager = sessionManager;
_htmlExportService = htmlExportService;
_csvExportService = csvExportService;
_brandingService = brandingService;
_logger = logger;
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null)
{
StatusMessage = "No tenant selected. Please connect to a tenant first.";
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
return;
}
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Select at least one site from the toolbar.";
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return;
}
@@ -152,6 +167,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
_lastGroups = Array.Empty<DuplicateGroup>();
OnPropertyChanged(nameof(CurrentProfile));
ExportHtmlCommand.NotifyCanExecuteChanged();
ExportCsvCommand.NotifyCanExecuteChanged();
}
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
@@ -179,9 +195,28 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
branding = new ReportBranding(mspLogo, clientLogo);
}
await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None, branding);
await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding);
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
}
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); }
}
private async Task ExportCsvAsync()
{
if (_lastGroups.Count == 0) return;
var dialog = new SaveFileDialog
{
Title = "Export duplicates report to CSV",
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
DefaultExt = "csv",
FileName = "duplicates_report"
};
if (dialog.ShowDialog() != true) return;
try
{
await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None);
Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true });
}
catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); }
}
}
@@ -103,15 +103,16 @@ public partial class FolderStructureViewModel : FeatureViewModelBase
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (_currentProfile == null) throw new InvalidOperationException("No tenant connected.");
var T = TranslationSource.Instance;
if (_currentProfile == null) throw new InvalidOperationException(T["err.no_tenant"]);
if (_validRows == null || _validRows.Count == 0)
throw new InvalidOperationException("No valid rows. Import a CSV first.");
throw new InvalidOperationException(T["err.no_valid_rows"]);
if (string.IsNullOrWhiteSpace(LibraryTitle))
throw new InvalidOperationException("Library title is required.");
throw new InvalidOperationException(T["err.library_title_required"]);
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
throw new InvalidOperationException("Select at least one site from the toolbar.");
throw new InvalidOperationException(T["err.no_sites_selected"]);
var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows);
var message = string.Format(TranslationSource.Instance["bulk.confirm.message"],
@@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Messages;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Services.Export;
@@ -84,6 +85,14 @@ public partial class PermissionsViewModel : FeatureViewModelBase
[ObservableProperty]
private bool _isDetailView = true;
/// <summary>0 = Single file, 1 = Split by site.</summary>
[ObservableProperty] private int _splitModeIndex;
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
[ObservableProperty] private int _htmlLayoutIndex;
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
/// <summary>
/// Simplified wrappers computed from Results. Rebuilt when Results changes.
/// </summary>
@@ -214,7 +223,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Select at least one site from the toolbar.";
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return;
}
@@ -308,6 +317,32 @@ public partial class PermissionsViewModel : FeatureViewModelBase
/// Derives the tenant admin URL from a standard tenant URL.
/// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com
/// </summary>
/// <summary>
/// Extracts the site-collection root URL from an arbitrary SharePoint object URL.
/// E.g. https://t.sharepoint.com/sites/hr/Shared%20Documents/Reports → https://t.sharepoint.com/sites/hr
/// Falls back to scheme+host for root-collection URLs.
/// </summary>
internal static string DeriveSiteCollectionUrl(string objectUrl)
{
if (string.IsNullOrWhiteSpace(objectUrl)) return string.Empty;
if (!Uri.TryCreate(objectUrl, UriKind.Absolute, out var uri))
return objectUrl.TrimEnd('/');
var baseUrl = $"{uri.Scheme}://{uri.Host}";
var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
// Managed paths: /sites/<name> or /teams/<name>
if (segments.Length >= 2 &&
(segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) ||
segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase)))
{
return $"{baseUrl}/{segments[0]}/{segments[1]}";
}
// Root site collection
return baseUrl;
}
internal static string DeriveAdminUrl(string tenantUrl)
{
var uri = new Uri(tenantUrl.TrimEnd('/'));
@@ -374,9 +409,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase
try
{
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None);
await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CancellationToken.None);
else
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
await _csvExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -408,36 +443,64 @@ public partial class PermissionsViewModel : FeatureViewModelBase
}
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null;
if (_groupResolver != null && Results.Count > 0)
if (_groupResolver != null && Results.Count > 0 && _currentProfile != null)
{
var groupNames = Results
// SharePoint groups live per site collection. Bucket each group
// by the site it was observed on, then resolve against that
// site's context. Using the root tenant ctx for a group that
// lives on a sub-site makes CSOM fail with "Group not found".
var groupsBySite = Results
.Where(r => r.PrincipalType == "SharePointGroup")
.SelectMany(r => r.Users.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(n => n.Trim())
.Where(n => n.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.SelectMany(r => r.Users
.Split(';', StringSplitOptions.RemoveEmptyEntries)
.Select(n => (SiteUrl: DeriveSiteCollectionUrl(r.Url), GroupName: n.Trim())))
.Where(x => x.GroupName.Length > 0)
.GroupBy(x => x.SiteUrl, StringComparer.OrdinalIgnoreCase)
.ToList();
if (groupNames.Count > 0 && _currentProfile != null)
if (groupsBySite.Count > 0)
{
try
var merged = new Dictionary<string, IReadOnlyList<ResolvedMember>>(
StringComparer.OrdinalIgnoreCase);
foreach (var bucket in groupsBySite)
{
var ctx = await _sessionManager.GetOrCreateContextAsync(
_currentProfile, CancellationToken.None);
groupMembers = await _groupResolver.ResolveGroupsAsync(
ctx, _currentProfile.ClientId, groupNames, CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Group resolution failed — exporting without member expansion.");
var distinctNames = bucket
.Select(x => x.GroupName)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
try
{
var siteProfile = new TenantProfile
{
TenantUrl = bucket.Key,
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync(
siteProfile, CancellationToken.None);
var resolved = await _groupResolver.ResolveGroupsAsync(
ctx, _currentProfile.ClientId, distinctNames, CancellationToken.None);
foreach (var kv in resolved)
merged[kv.Key] = kv.Value;
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Group resolution failed for {Site} — continuing without member expansion.",
bucket.Key);
}
}
groupMembers = merged;
}
}
if (IsSimplifiedMode && SimplifiedResults.Count > 0)
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding, groupMembers);
await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers);
else
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding, groupMembers);
await _htmlExportService.WriteAsync((IReadOnlyList<PermissionEntry>)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -6,6 +6,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;
@@ -79,14 +80,14 @@ public partial class SearchViewModel : FeatureViewModelBase
{
if (_currentProfile == null)
{
StatusMessage = "No tenant selected. Please connect to a tenant first.";
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
return;
}
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Select at least one site from the toolbar.";
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return;
}
@@ -12,6 +12,7 @@ public partial class SettingsViewModel : FeatureViewModelBase
{
private readonly SettingsService _settingsService;
private readonly IBrandingService _brandingService;
private readonly ThemeManager _themeManager;
private string _selectedLanguage = "en";
public string SelectedLanguage
@@ -39,6 +40,19 @@ public partial class SettingsViewModel : FeatureViewModelBase
}
}
private string _selectedTheme = "System";
public string SelectedTheme
{
get => _selectedTheme;
set
{
if (_selectedTheme == value) return;
_selectedTheme = value;
OnPropertyChanged();
_ = ApplyThemeAsync(value);
}
}
private bool _autoTakeOwnership;
public bool AutoTakeOwnership
{
@@ -63,11 +77,12 @@ public partial class SettingsViewModel : FeatureViewModelBase
public IAsyncRelayCommand BrowseMspLogoCommand { get; }
public IAsyncRelayCommand ClearMspLogoCommand { get; }
public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ILogger<FeatureViewModelBase> logger)
public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ThemeManager themeManager, ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_settingsService = settingsService;
_brandingService = brandingService;
_themeManager = themeManager;
BrowseFolderCommand = new RelayCommand(BrowseFolder);
BrowseMspLogoCommand = new AsyncRelayCommand(BrowseMspLogoAsync);
ClearMspLogoCommand = new AsyncRelayCommand(ClearMspLogoAsync);
@@ -79,14 +94,29 @@ public partial class SettingsViewModel : FeatureViewModelBase
_selectedLanguage = settings.Lang;
_dataFolder = settings.DataFolder;
_autoTakeOwnership = settings.AutoTakeOwnership;
_selectedTheme = settings.Theme;
OnPropertyChanged(nameof(SelectedLanguage));
OnPropertyChanged(nameof(DataFolder));
OnPropertyChanged(nameof(AutoTakeOwnership));
OnPropertyChanged(nameof(SelectedTheme));
var mspLogo = await _brandingService.GetMspLogoAsync();
MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null;
}
private async Task ApplyThemeAsync(string mode)
{
try
{
_themeManager.ApplyFromString(mode);
await _settingsService.SetThemeAsync(mode);
}
catch (Exception ex)
{
StatusMessage = ex.Message;
}
}
private async Task ApplyLanguageAsync(string code)
{
try
@@ -9,6 +9,7 @@ using LiveChartsCore.SkiaSharpView.Painting;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
using SkiaSharp;
@@ -22,6 +23,9 @@ public partial class StorageViewModel : FeatureViewModelBase
private readonly StorageCsvExportService _csvExportService;
private readonly StorageHtmlExportService _htmlExportService;
private readonly IBrandingService? _brandingService;
private readonly IOwnershipElevationService? _ownershipService;
private readonly SettingsService? _settingsService;
private readonly ThemeManager? _themeManager;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
@@ -37,6 +41,14 @@ public partial class StorageViewModel : FeatureViewModelBase
[ObservableProperty]
private bool _isDonutChart = true;
/// <summary>0 = Single file, 1 = Split by site.</summary>
[ObservableProperty] private int _splitModeIndex;
/// <summary>0 = Separate HTML files, 1 = Single tabbed HTML.</summary>
[ObservableProperty] private int _htmlLayoutIndex;
private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single;
private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles;
private ObservableCollection<FileTypeMetric> _fileTypeMetrics = new();
public ObservableCollection<FileTypeMetric> FileTypeMetrics
{
@@ -79,6 +91,15 @@ public partial class StorageViewModel : FeatureViewModelBase
private set { _barYAxes = value; OnPropertyChanged(); }
}
// Stable paint instances. SKDefaultTooltip/Legend bake the Fill paint reference
// into their geometry on first Initialize() and never re-read the chart's paint
// properties. Replacing instances on theme change has no effect — we mutate
// .Color in place so the new theme color renders on the next frame.
public SolidColorPaint LegendTextPaint { get; } = new(default(SKColor));
public SolidColorPaint LegendBackgroundPaint { get; } = new(default(SKColor));
public SolidColorPaint TooltipTextPaint { get; } = new(default(SKColor));
public SolidColorPaint TooltipBackgroundPaint { get; } = new(default(SKColor));
public bool IsMaxDepth
{
get => FolderDepth >= 999;
@@ -136,7 +157,10 @@ public partial class StorageViewModel : FeatureViewModelBase
StorageCsvExportService csvExportService,
StorageHtmlExportService htmlExportService,
IBrandingService brandingService,
ILogger<FeatureViewModelBase> logger)
ILogger<FeatureViewModelBase> logger,
IOwnershipElevationService? ownershipService = null,
SettingsService? settingsService = null,
ThemeManager? themeManager = null)
: base(logger)
{
_storageService = storageService;
@@ -144,10 +168,17 @@ public partial class StorageViewModel : FeatureViewModelBase
_csvExportService = csvExportService;
_htmlExportService = htmlExportService;
_brandingService = brandingService;
_ownershipService = ownershipService;
_settingsService = settingsService;
_themeManager = themeManager;
_logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
ApplyChartThemeColors();
if (_themeManager is not null)
_themeManager.ThemeChanged += (_, _) => UpdateChartSeries();
}
/// <summary>Test constructor — omits export services.</summary>
@@ -173,14 +204,14 @@ public partial class StorageViewModel : FeatureViewModelBase
{
if (_currentProfile == null)
{
StatusMessage = "No tenant selected. Please connect to a tenant first.";
StatusMessage = TranslationSource.Instance["err.no_tenant_connected"];
return;
}
var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
if (urls.Count == 0)
{
StatusMessage = "Select at least one site from the toolbar.";
StatusMessage = TranslationSource.Instance["err.no_sites_selected"];
return;
}
@@ -194,6 +225,8 @@ public partial class StorageViewModel : FeatureViewModelBase
var allNodes = new List<StorageNode>();
var allTypeMetrics = new List<FileTypeMetric>();
var autoOwnership = await IsAutoTakeOwnershipEnabled();
int i = 0;
foreach (var url in nonEmpty)
{
@@ -207,9 +240,30 @@ public partial class StorageViewModel : FeatureViewModelBase
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
IReadOnlyList<StorageNode> nodes;
try
{
nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
}
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (_ownershipService != null && autoOwnership)
{
_logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", url);
var adminUrl = DeriveAdminUrl(_currentProfile.TenantUrl ?? url);
var adminProfile = new TenantProfile
{
TenantUrl = adminUrl,
ClientId = _currentProfile.ClientId,
Name = _currentProfile.Name
};
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
await _ownershipService.ElevateAsync(adminCtx, url, string.Empty, ct);
ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct);
nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct);
}
// Backfill any libraries where StorageMetrics returned zeros
await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct);
@@ -258,6 +312,24 @@ public partial class StorageViewModel : FeatureViewModelBase
ExportHtmlCommand.NotifyCanExecuteChanged();
}
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}";
}
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
@@ -278,7 +350,7 @@ public partial class StorageViewModel : FeatureViewModelBase
if (dialog.ShowDialog() != true) return;
try
{
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None);
await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CancellationToken.None);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -309,7 +381,7 @@ public partial class StorageViewModel : FeatureViewModelBase
branding = new ReportBranding(mspLogo, clientLogo);
}
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None, branding);
await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding);
OpenFile(dialog.FileName);
}
catch (Exception ex)
@@ -324,11 +396,25 @@ public partial class StorageViewModel : FeatureViewModelBase
UpdateChartSeries();
}
private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30);
private SKColor ChartSeparatorColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x32, 0x38, 0x49) : new SKColor(0xE3, 0xE6, 0xEC);
private SKColor ChartSurfaceColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x1E, 0x22, 0x30) : new SKColor(0xFF, 0xFF, 0xFF);
private void ApplyChartThemeColors()
{
LegendTextPaint.Color = ChartFgColor;
LegendBackgroundPaint.Color = ChartSurfaceColor;
TooltipTextPaint.Color = ChartFgColor;
TooltipBackgroundPaint.Color = ChartSurfaceColor;
}
private void UpdateChartSeries()
{
var metrics = FileTypeMetrics.ToList();
OnPropertyChanged(nameof(HasChartData));
ApplyChartThemeColors();
if (metrics.Count == 0)
{
PieChartSeries = Enumerable.Empty<ISeries>();
@@ -361,6 +447,7 @@ public partial class StorageViewModel : FeatureViewModelBase
HoverPushout = 8,
MaxRadialColumnWidth = 60,
DataLabelsFormatter = _ => m.DisplayLabel,
DataLabelsPaint = new SolidColorPaint(ChartFgColor),
ToolTipLabelFormatter = _ =>
$"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)",
IsVisibleAtLegend = true,
@@ -379,7 +466,8 @@ public partial class StorageViewModel : FeatureViewModelBase
{
int idx = (int)point.Index;
return idx < chartItems.Count ? FormatBytes(chartItems[idx].TotalSizeBytes) : "";
}
},
DataLabelsPaint = new SolidColorPaint(ChartFgColor)
}
};
@@ -388,7 +476,10 @@ public partial class StorageViewModel : FeatureViewModelBase
new Axis
{
Labels = chartItems.Select(m => m.DisplayLabel).ToArray(),
LabelsRotation = -45
LabelsRotation = -45,
LabelsPaint = new SolidColorPaint(ChartFgColor),
TicksPaint = new SolidColorPaint(ChartFgColor),
SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor)
}
};
@@ -396,7 +487,10 @@ public partial class StorageViewModel : FeatureViewModelBase
{
new Axis
{
Labeler = value => FormatBytes((long)value)
Labeler = value => FormatBytes((long)value),
LabelsPaint = new SolidColorPaint(ChartFgColor),
TicksPaint = new SolidColorPaint(ChartFgColor),
SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor)
}
};
}
@@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging;
using Serilog;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
namespace SharepointToolbox.ViewModels.Tabs;
@@ -39,6 +40,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
// Apply options
[ObservableProperty] private string _newSiteTitle = string.Empty;
[ObservableProperty] private string _newSiteAlias = string.Empty;
private bool _aliasManuallyEdited;
public IAsyncRelayCommand CaptureCommand { get; }
public IAsyncRelayCommand ApplyCommand { get; }
@@ -78,19 +80,20 @@ public partial class TemplatesViewModel : FeatureViewModelBase
private async Task CaptureAsync()
{
var T = TranslationSource.Instance;
if (_currentProfile == null)
throw new InvalidOperationException("No tenant connected.");
throw new InvalidOperationException(T["err.no_tenant"]);
if (string.IsNullOrWhiteSpace(TemplateName))
throw new InvalidOperationException("Template name is required.");
throw new InvalidOperationException(T["err.template_name_required"]);
var captureSiteUrl = GlobalSites.Select(s => s.Url).FirstOrDefault(u => !string.IsNullOrWhiteSpace(u));
if (string.IsNullOrWhiteSpace(captureSiteUrl))
throw new InvalidOperationException("Select at least one site from the toolbar.");
throw new InvalidOperationException(T["err.no_sites_selected"]);
try
{
IsRunning = true;
StatusMessage = "Capturing template...";
StatusMessage = T["templates.status.capturing"];
var profile = new TenantProfile
{
@@ -117,11 +120,11 @@ public partial class TemplatesViewModel : FeatureViewModelBase
Log.Information("Template captured: {Name} from {Url}", template.Name, captureSiteUrl);
await RefreshListAsync();
StatusMessage = $"Template captured successfully.";
StatusMessage = T["templates.status.success"];
}
catch (Exception ex)
{
StatusMessage = $"Capture failed: {ex.Message}";
StatusMessage = string.Format(T["templates.status.capture_failed"], ex.Message);
Log.Error(ex, "Template capture failed");
}
finally
@@ -133,15 +136,23 @@ public partial class TemplatesViewModel : FeatureViewModelBase
private async Task ApplyAsync()
{
if (_currentProfile == null || SelectedTemplate == null) return;
var T = TranslationSource.Instance;
if (string.IsNullOrWhiteSpace(NewSiteTitle))
throw new InvalidOperationException("New site title is required.");
throw new InvalidOperationException(T["err.site_title_required"]);
// Auto-fill alias from title if user left it blank
if (string.IsNullOrWhiteSpace(NewSiteAlias))
throw new InvalidOperationException("New site alias is required.");
{
var generated = GenerateAliasFromTitle(NewSiteTitle);
if (string.IsNullOrWhiteSpace(generated))
throw new InvalidOperationException(T["err.site_alias_required"]);
NewSiteAlias = generated;
}
try
{
IsRunning = true;
StatusMessage = $"Applying template...";
StatusMessage = T["templates.status.applying"];
var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, CancellationToken.None);
var progress = new Progress<OperationProgress>(p => StatusMessage = p.Message);
@@ -150,12 +161,12 @@ public partial class TemplatesViewModel : FeatureViewModelBase
ctx, SelectedTemplate, NewSiteTitle, NewSiteAlias,
progress, CancellationToken.None);
StatusMessage = $"Template applied. Site created at: {siteUrl}";
StatusMessage = string.Format(T["templates.status.applied"], siteUrl);
Log.Information("Template applied. New site: {Url}", siteUrl);
}
catch (Exception ex)
{
StatusMessage = $"Apply failed: {ex.Message}";
StatusMessage = string.Format(T["templates.status.apply_failed"], ex.Message);
Log.Error(ex, "Template apply failed");
}
finally
@@ -204,6 +215,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase
TemplateName = string.Empty;
NewSiteTitle = string.Empty;
NewSiteAlias = string.Empty;
_aliasManuallyEdited = false;
StatusMessage = string.Empty;
_ = RefreshListAsync();
@@ -215,4 +227,44 @@ public partial class TemplatesViewModel : FeatureViewModelBase
RenameCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
}
partial void OnNewSiteTitleChanged(string value)
{
if (_aliasManuallyEdited) return;
var generated = GenerateAliasFromTitle(value);
if (NewSiteAlias != generated)
{
// Bypass user-edit flag while we sync alias to title
_suppressAliasEditedFlag = true;
NewSiteAlias = generated;
_suppressAliasEditedFlag = false;
}
}
private bool _suppressAliasEditedFlag;
partial void OnNewSiteAliasChanged(string value)
{
if (_suppressAliasEditedFlag) return;
_aliasManuallyEdited = !string.IsNullOrWhiteSpace(value)
&& value != GenerateAliasFromTitle(NewSiteTitle);
if (string.IsNullOrWhiteSpace(value)) _aliasManuallyEdited = false;
}
internal static string GenerateAliasFromTitle(string title)
{
if (string.IsNullOrWhiteSpace(title)) return string.Empty;
var normalized = title.Normalize(System.Text.NormalizationForm.FormD);
var sb = new System.Text.StringBuilder(normalized.Length);
foreach (var c in normalized)
{
var cat = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c);
if (cat == System.Globalization.UnicodeCategory.NonSpacingMark) continue;
if (char.IsLetterOrDigit(c)) sb.Append(c);
else if (c == ' ' || c == '-' || c == '_') sb.Append('-');
}
var collapsed = System.Text.RegularExpressions.Regex.Replace(sb.ToString(), "-+", "-");
return collapsed.Trim('-');
}
}
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using Serilog;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
@@ -15,6 +16,8 @@ public partial class TransferViewModel : FeatureViewModelBase
private readonly IFileTransferService _transferService;
private readonly ISessionManager _sessionManager;
private readonly BulkResultCsvExportService _exportService;
private readonly IOwnershipElevationService? _ownershipService;
private readonly SettingsService? _settingsService;
private readonly ILogger<FeatureViewModelBase> _logger;
private TenantProfile? _currentProfile;
private bool _hasLocalSourceSiteOverride;
@@ -32,6 +35,17 @@ public partial class TransferViewModel : FeatureViewModelBase
// Transfer options
[ObservableProperty] private TransferMode _transferMode = TransferMode.Copy;
[ObservableProperty] private ConflictPolicy _conflictPolicy = ConflictPolicy.Skip;
[ObservableProperty] private bool _includeSourceFolder;
[ObservableProperty] private bool _copyFolderContents = true;
/// <summary>
/// Library-relative file paths the user checked in the source picker.
/// When non-empty, only these files are transferred — folder recursion is skipped.
/// </summary>
public List<string> SelectedFilePaths { get; } = new();
/// <summary>Count of per-file selections, for display in the view.</summary>
public int SelectedFileCount => SelectedFilePaths.Count;
// Results
[ObservableProperty] private string _resultSummary = string.Empty;
@@ -51,12 +65,16 @@ public partial class TransferViewModel : FeatureViewModelBase
IFileTransferService transferService,
ISessionManager sessionManager,
BulkResultCsvExportService exportService,
ILogger<FeatureViewModelBase> logger)
ILogger<FeatureViewModelBase> logger,
IOwnershipElevationService? ownershipService = null,
SettingsService? settingsService = null)
: base(logger)
{
_transferService = transferService;
_sessionManager = sessionManager;
_exportService = exportService;
_ownershipService = ownershipService;
_settingsService = settingsService;
_logger = logger;
ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures);
@@ -84,14 +102,15 @@ public partial class TransferViewModel : FeatureViewModelBase
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
var T = TranslationSource.Instance;
if (_currentProfile == null)
throw new InvalidOperationException("No tenant connected.");
throw new InvalidOperationException(T["err.no_tenant"]);
if (string.IsNullOrWhiteSpace(SourceSiteUrl) || string.IsNullOrWhiteSpace(SourceLibrary))
throw new InvalidOperationException("Source site and library must be selected.");
throw new InvalidOperationException(T["err.transfer_source_required"]);
if (string.IsNullOrWhiteSpace(DestSiteUrl) || string.IsNullOrWhiteSpace(DestLibrary))
throw new InvalidOperationException("Destination site and library must be selected.");
throw new InvalidOperationException(T["err.transfer_dest_required"]);
// Confirmation dialog
var message = $"{TransferMode} files from {SourceLibrary} to {DestLibrary} ({ConflictPolicy} on conflict)";
@@ -108,6 +127,9 @@ public partial class TransferViewModel : FeatureViewModelBase
DestinationFolderPath = DestFolderPath,
Mode = TransferMode,
ConflictPolicy = ConflictPolicy,
SelectedFilePaths = SelectedFilePaths.ToList(),
IncludeSourceFolder = IncludeSourceFolder,
CopyFolderContents = CopyFolderContents,
};
// Build per-site profiles so SessionManager can resolve contexts
@@ -127,7 +149,33 @@ public partial class TransferViewModel : FeatureViewModelBase
var srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct);
var dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, ct);
_lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct);
var autoOwnership = await IsAutoTakeOwnershipEnabled();
try
{
_lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct);
}
catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException ex)
when (_ownershipService != null && autoOwnership)
{
_logger.LogWarning(ex, "Transfer hit access denied — auto-elevating on source and destination.");
var adminUrl = DeriveAdminUrl(_currentProfile.TenantUrl ?? SourceSiteUrl);
var adminProfile = new TenantProfile
{
Name = _currentProfile.Name,
TenantUrl = adminUrl,
ClientId = _currentProfile.ClientId,
};
var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct);
await _ownershipService.ElevateAsync(adminCtx, SourceSiteUrl, string.Empty, ct);
await _ownershipService.ElevateAsync(adminCtx, DestSiteUrl, string.Empty, ct);
// Retry with fresh contexts so the new admin membership is honoured.
srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct);
dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, ct);
_lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct);
}
// Update UI on dispatcher
await Application.Current.Dispatcher.InvokeAsync(() =>
@@ -182,6 +230,34 @@ public partial class TransferViewModel : FeatureViewModelBase
DestFolderPath = string.Empty;
ResultSummary = string.Empty;
HasFailures = false;
SelectedFilePaths.Clear();
OnPropertyChanged(nameof(SelectedFileCount));
_lastResult = null;
}
/// <summary>Replaces the current per-file selection and notifies the view.</summary>
public void SetSelectedFiles(IEnumerable<string> libraryRelativePaths)
{
SelectedFilePaths.Clear();
SelectedFilePaths.AddRange(libraryRelativePaths);
OnPropertyChanged(nameof(SelectedFileCount));
}
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}";
}
}
@@ -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)