- Add _hasLocalSiteOverride field to track local user selection - Override OnGlobalSitesChanged to pre-populate SelectedSites from global sites - Set _hasLocalSiteOverride=true when user picks sites via site picker dialog - Reset _hasLocalSiteOverride=false on tenant switch (OnTenantSwitched)
326 lines
12 KiB
C#
326 lines
12 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Diagnostics;
|
|
using System.Windows;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using CommunityToolkit.Mvvm.Messaging;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Win32;
|
|
using SharepointToolbox.Core.Messages;
|
|
using SharepointToolbox.Core.Models;
|
|
using SharepointToolbox.Services;
|
|
using SharepointToolbox.Services.Export;
|
|
|
|
namespace SharepointToolbox.ViewModels.Tabs;
|
|
|
|
/// <summary>
|
|
/// ViewModel for the Permissions tab.
|
|
/// Orchestrates permission scanning across one or multiple SharePoint sites
|
|
/// and exports results to CSV or HTML.
|
|
/// </summary>
|
|
public partial class PermissionsViewModel : FeatureViewModelBase
|
|
{
|
|
private readonly IPermissionsService _permissionsService;
|
|
private readonly ISiteListService _siteListService;
|
|
private readonly ISessionManager _sessionManager;
|
|
private readonly CsvExportService? _csvExportService;
|
|
private readonly HtmlExportService? _htmlExportService;
|
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
|
|
|
// ── Observable properties ───────────────────────────────────────────────
|
|
|
|
[ObservableProperty]
|
|
private string _siteUrl = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private bool _includeInherited;
|
|
|
|
[ObservableProperty]
|
|
private bool _scanFolders = true;
|
|
|
|
[ObservableProperty]
|
|
private bool _includeSubsites;
|
|
|
|
[ObservableProperty]
|
|
private int _folderDepth = 1;
|
|
|
|
/// <summary>
|
|
/// When true, sets FolderDepth to 999 (scan all levels).
|
|
/// </summary>
|
|
public bool IsMaxDepth
|
|
{
|
|
get => FolderDepth >= 999;
|
|
set
|
|
{
|
|
if (value)
|
|
FolderDepth = 999;
|
|
else if (FolderDepth >= 999)
|
|
FolderDepth = 1;
|
|
OnPropertyChanged();
|
|
}
|
|
}
|
|
|
|
[ObservableProperty]
|
|
private ObservableCollection<PermissionEntry> _results = new();
|
|
|
|
partial void OnFolderDepthChanged(int value) => OnPropertyChanged(nameof(IsMaxDepth));
|
|
|
|
// ── Commands ────────────────────────────────────────────────────────────
|
|
|
|
public IAsyncRelayCommand ExportCsvCommand { get; }
|
|
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
|
public RelayCommand OpenSitePickerCommand { get; }
|
|
|
|
// ── Multi-site ──────────────────────────────────────────────────────────
|
|
|
|
public ObservableCollection<SiteInfo> SelectedSites { get; } = new();
|
|
|
|
/// <summary>
|
|
/// True when the user has manually selected sites via the site picker on this tab.
|
|
/// Prevents global site changes from overwriting the user's local selection.
|
|
/// </summary>
|
|
private bool _hasLocalSiteOverride;
|
|
|
|
// ── Dialog factory (set by View layer — keeps Window out of ViewModel) ──
|
|
|
|
/// <summary>
|
|
/// Factory function set by the View layer to open the SitePickerDialog.
|
|
/// Returns the opened Window; ViewModel calls ShowDialog() on it.
|
|
/// </summary>
|
|
public Func<Window>? OpenSitePickerDialog { get; set; }
|
|
|
|
// ── Current tenant profile (received via WeakReferenceMessenger) ────────
|
|
|
|
internal TenantProfile? _currentProfile;
|
|
|
|
/// <summary>
|
|
/// Public accessor for the current tenant profile — used by View layer dialog factory.
|
|
/// </summary>
|
|
public TenantProfile? CurrentProfile => _currentProfile;
|
|
|
|
/// <summary>
|
|
/// Label shown in the UI: "3 site(s) selected" or empty when none are selected.
|
|
/// </summary>
|
|
public string SitesSelectedLabel =>
|
|
SelectedSites.Count > 0
|
|
? string.Format(Localization.TranslationSource.Instance["perm.sites.selected"], SelectedSites.Count)
|
|
: string.Empty;
|
|
|
|
// ── Constructors ────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Full constructor — used by DI and production code.
|
|
/// </summary>
|
|
public PermissionsViewModel(
|
|
IPermissionsService permissionsService,
|
|
ISiteListService siteListService,
|
|
ISessionManager sessionManager,
|
|
CsvExportService csvExportService,
|
|
HtmlExportService htmlExportService,
|
|
ILogger<FeatureViewModelBase> logger)
|
|
: base(logger)
|
|
{
|
|
_permissionsService = permissionsService;
|
|
_siteListService = siteListService;
|
|
_sessionManager = sessionManager;
|
|
_csvExportService = csvExportService;
|
|
_htmlExportService = htmlExportService;
|
|
_logger = logger;
|
|
|
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
|
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
|
|
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test constructor — omits export services (not needed for unit tests).
|
|
/// </summary>
|
|
internal PermissionsViewModel(
|
|
IPermissionsService permissionsService,
|
|
ISiteListService siteListService,
|
|
ISessionManager sessionManager,
|
|
ILogger<FeatureViewModelBase> logger)
|
|
: base(logger)
|
|
{
|
|
_permissionsService = permissionsService;
|
|
_siteListService = siteListService;
|
|
_sessionManager = sessionManager;
|
|
_csvExportService = null;
|
|
_htmlExportService = null;
|
|
_logger = logger;
|
|
|
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
|
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
|
|
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
|
|
}
|
|
|
|
// ── FeatureViewModelBase implementation ─────────────────────────────────
|
|
|
|
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
|
|
{
|
|
if (_hasLocalSiteOverride) return;
|
|
|
|
SelectedSites.Clear();
|
|
foreach (var site in sites)
|
|
SelectedSites.Add(site);
|
|
}
|
|
|
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
|
{
|
|
var urls = SelectedSites.Count > 0
|
|
? SelectedSites.Select(s => s.Url).ToList()
|
|
: new List<string> { SiteUrl };
|
|
|
|
var nonEmpty = urls.Where(u => !string.IsNullOrWhiteSpace(u)).ToList();
|
|
if (nonEmpty.Count == 0)
|
|
{
|
|
StatusMessage = "Enter a site URL or select sites.";
|
|
return;
|
|
}
|
|
|
|
var allEntries = new List<PermissionEntry>();
|
|
var scanOptions = new ScanOptions(
|
|
IncludeInherited: IncludeInherited,
|
|
ScanFolders: ScanFolders,
|
|
FolderDepth: FolderDepth,
|
|
IncludeSubsites: IncludeSubsites);
|
|
|
|
int i = 0;
|
|
foreach (var url in nonEmpty)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
progress.Report(new OperationProgress(i, nonEmpty.Count, $"Scanning {url}..."));
|
|
|
|
var profile = new TenantProfile
|
|
{
|
|
TenantUrl = url,
|
|
ClientId = _currentProfile?.ClientId ?? string.Empty,
|
|
Name = _currentProfile?.Name ?? string.Empty
|
|
};
|
|
|
|
var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct);
|
|
var siteEntries = await _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct);
|
|
allEntries.AddRange(siteEntries);
|
|
i++;
|
|
}
|
|
|
|
// Update Results on the UI thread (no-op if no Dispatcher in tests)
|
|
var dispatcher = Application.Current?.Dispatcher;
|
|
if (dispatcher != null)
|
|
{
|
|
await dispatcher.InvokeAsync(() =>
|
|
{
|
|
Results = new ObservableCollection<PermissionEntry>(allEntries);
|
|
});
|
|
}
|
|
else
|
|
{
|
|
Results = new ObservableCollection<PermissionEntry>(allEntries);
|
|
}
|
|
|
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
// ── Tenant switching ─────────────────────────────────────────────────────
|
|
|
|
protected override void OnTenantSwitched(TenantProfile profile)
|
|
{
|
|
_currentProfile = profile;
|
|
_hasLocalSiteOverride = false;
|
|
Results = new ObservableCollection<PermissionEntry>();
|
|
SiteUrl = string.Empty;
|
|
SelectedSites.Clear();
|
|
OnPropertyChanged(nameof(SitesSelectedLabel));
|
|
OnPropertyChanged(nameof(CurrentProfile));
|
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
// ── Internal helpers ─────────────────────────────────────────────────────
|
|
|
|
/// <summary>Sets the current tenant profile (for test injection).</summary>
|
|
internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile;
|
|
|
|
/// <summary>Exposes RunOperationAsync for unit tests (internal + InternalsVisibleTo).</summary>
|
|
internal Task TestRunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
|
=> RunOperationAsync(ct, progress);
|
|
|
|
// ── Command implementations ───────────────────────────────────────────────
|
|
|
|
private bool CanExport() => Results.Count > 0;
|
|
|
|
private async Task ExportCsvAsync()
|
|
{
|
|
if (_csvExportService == null || Results.Count == 0) return;
|
|
var dialog = new SaveFileDialog
|
|
{
|
|
Title = "Export permissions to CSV",
|
|
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
|
|
DefaultExt = "csv",
|
|
FileName = "permissions"
|
|
};
|
|
if (dialog.ShowDialog() != true) return;
|
|
try
|
|
{
|
|
await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
|
OpenFile(dialog.FileName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Export failed: {ex.Message}";
|
|
_logger.LogError(ex, "CSV export failed.");
|
|
}
|
|
}
|
|
|
|
private async Task ExportHtmlAsync()
|
|
{
|
|
if (_htmlExportService == null || Results.Count == 0) return;
|
|
var dialog = new SaveFileDialog
|
|
{
|
|
Title = "Export permissions to HTML",
|
|
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
|
|
DefaultExt = "html",
|
|
FileName = "permissions"
|
|
};
|
|
if (dialog.ShowDialog() != true) return;
|
|
try
|
|
{
|
|
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None);
|
|
OpenFile(dialog.FileName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Export failed: {ex.Message}";
|
|
_logger.LogError(ex, "HTML export failed.");
|
|
}
|
|
}
|
|
|
|
private void ExecuteOpenSitePicker()
|
|
{
|
|
if (OpenSitePickerDialog == null) return;
|
|
var dialog = OpenSitePickerDialog.Invoke();
|
|
if (dialog?.ShowDialog() == true && dialog is Views.Dialogs.SitePickerDialog picker)
|
|
{
|
|
_hasLocalSiteOverride = true;
|
|
SelectedSites.Clear();
|
|
foreach (var site in picker.SelectedUrls)
|
|
SelectedSites.Add(site);
|
|
}
|
|
}
|
|
|
|
private static void OpenFile(string filePath)
|
|
{
|
|
try
|
|
{
|
|
Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true });
|
|
}
|
|
catch
|
|
{
|
|
// Non-critical: file was written successfully, just can't auto-open
|
|
}
|
|
}
|
|
}
|