- Add IBrandingService field and DI constructor parameter to all 5 ViewModels - Add optional IBrandingService? parameter to test constructors (PermissionsViewModel, StorageViewModel, UserAccessAuditViewModel) - Assemble ReportBranding from GetMspLogoAsync + _currentProfile.ClientLogo before each WriteAsync call - Pass branding as last parameter to WriteAsync in all ExportHtmlAsync methods - Guard clause: branding assembly skipped (branding = null) when _brandingService is null (test constructors) - Build: 0 warnings, 0 errors; tests: 254 passed / 0 failed / 26 skipped
471 lines
18 KiB
C#
471 lines
18 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.ComponentModel;
|
|
using System.Diagnostics;
|
|
using System.Windows;
|
|
using System.Windows.Data;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Win32;
|
|
using SharepointToolbox.Core.Models;
|
|
using SharepointToolbox.Services;
|
|
using SharepointToolbox.Services.Export;
|
|
|
|
namespace SharepointToolbox.ViewModels.Tabs;
|
|
|
|
/// <summary>
|
|
/// ViewModel for the User Access Audit tab.
|
|
/// Orchestrates people-picker search, site selection, audit execution,
|
|
/// result grouping/filtering, summary banner, and export commands.
|
|
/// </summary>
|
|
public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
|
{
|
|
private readonly IUserAccessAuditService _auditService;
|
|
private readonly IGraphUserSearchService _graphUserSearchService;
|
|
private readonly ISessionManager _sessionManager;
|
|
private readonly UserAccessCsvExportService? _csvExportService;
|
|
private readonly UserAccessHtmlExportService? _htmlExportService;
|
|
private readonly IBrandingService? _brandingService;
|
|
private readonly ILogger<FeatureViewModelBase> _logger;
|
|
|
|
// ── People picker debounce ──────────────────────────────────────────────
|
|
|
|
private CancellationTokenSource? _searchCts;
|
|
|
|
// ── Observable properties ───────────────────────────────────────────────
|
|
|
|
/// <summary>Text typed in the people-picker search box. Changes trigger debounced Graph search.</summary>
|
|
[ObservableProperty]
|
|
private string _searchQuery = string.Empty;
|
|
|
|
/// <summary>Autocomplete dropdown items from Graph search results.</summary>
|
|
[ObservableProperty]
|
|
private ObservableCollection<GraphUserResult> _searchResults = new();
|
|
|
|
/// <summary>Users added for audit.</summary>
|
|
[ObservableProperty]
|
|
private ObservableCollection<GraphUserResult> _selectedUsers = new();
|
|
|
|
/// <summary>Raw audit output entries.</summary>
|
|
[ObservableProperty]
|
|
private ObservableCollection<UserAccessEntry> _results = new();
|
|
|
|
/// <summary>Real-time text filter applied to ResultsView.</summary>
|
|
[ObservableProperty]
|
|
private string _filterText = string.Empty;
|
|
|
|
/// <summary>When true, results are grouped by user; when false, grouped by site.</summary>
|
|
[ObservableProperty]
|
|
private bool _isGroupByUser = true;
|
|
|
|
/// <summary>Include inherited permissions in scan.</summary>
|
|
[ObservableProperty]
|
|
private bool _includeInherited;
|
|
|
|
/// <summary>Include folders in scan.</summary>
|
|
[ObservableProperty]
|
|
private bool _scanFolders = true;
|
|
|
|
/// <summary>Include subsites in scan.</summary>
|
|
[ObservableProperty]
|
|
private bool _includeSubsites;
|
|
|
|
/// <summary>True while a Graph user search is in progress.</summary>
|
|
[ObservableProperty]
|
|
private bool _isSearching;
|
|
|
|
// ── Computed summary properties ─────────────────────────────────────────
|
|
|
|
/// <summary>Total number of access entries in current results.</summary>
|
|
public int TotalAccessCount => Results.Count;
|
|
|
|
/// <summary>Number of distinct sites referenced in current results.</summary>
|
|
public int SitesCount => Results.Select(r => r.SiteUrl).Distinct().Count();
|
|
|
|
/// <summary>Number of high-privilege access entries in current results.</summary>
|
|
public int HighPrivilegeCount => Results.Count(r => r.IsHighPrivilege);
|
|
|
|
/// <summary>Human-readable label for the people-picker selection, e.g. "2 user(s) selected".</summary>
|
|
public string SelectedUsersLabel =>
|
|
SelectedUsers.Count > 0
|
|
? $"{SelectedUsers.Count} user(s) selected"
|
|
: string.Empty;
|
|
|
|
// ── CollectionViewSource (grouping + filtering) ─────────────────────────
|
|
|
|
/// <summary>ICollectionView over Results supporting grouping and text filtering.</summary>
|
|
public ICollectionView ResultsView { get; }
|
|
|
|
// ── Commands ────────────────────────────────────────────────────────────
|
|
|
|
public IAsyncRelayCommand ExportCsvCommand { get; }
|
|
public IAsyncRelayCommand ExportHtmlCommand { get; }
|
|
public RelayCommand<GraphUserResult> AddUserCommand { get; }
|
|
public RelayCommand<GraphUserResult> RemoveUserCommand { get; }
|
|
|
|
// ── Current tenant profile ──────────────────────────────────────────────
|
|
|
|
internal TenantProfile? _currentProfile;
|
|
|
|
/// <summary>Public accessor for the current tenant profile — used by View layer dialog factory.</summary>
|
|
public TenantProfile? CurrentProfile => _currentProfile;
|
|
|
|
// ── Constructors ────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Full constructor — used by DI and production code.</summary>
|
|
public UserAccessAuditViewModel(
|
|
IUserAccessAuditService auditService,
|
|
IGraphUserSearchService graphUserSearchService,
|
|
ISessionManager sessionManager,
|
|
UserAccessCsvExportService csvExportService,
|
|
UserAccessHtmlExportService htmlExportService,
|
|
IBrandingService brandingService,
|
|
ILogger<FeatureViewModelBase> logger)
|
|
: base(logger)
|
|
{
|
|
_auditService = auditService;
|
|
_graphUserSearchService = graphUserSearchService;
|
|
_sessionManager = sessionManager;
|
|
_csvExportService = csvExportService;
|
|
_htmlExportService = htmlExportService;
|
|
_brandingService = brandingService;
|
|
_logger = logger;
|
|
|
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
|
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
|
|
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
|
|
|
|
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
|
|
|
|
var cvs = new CollectionViewSource { Source = Results };
|
|
ResultsView = cvs.View;
|
|
ApplyGrouping();
|
|
}
|
|
|
|
/// <summary>Test constructor — omits export services (not needed for unit tests).</summary>
|
|
internal UserAccessAuditViewModel(
|
|
IUserAccessAuditService auditService,
|
|
IGraphUserSearchService graphUserSearchService,
|
|
ISessionManager sessionManager,
|
|
ILogger<FeatureViewModelBase> logger,
|
|
IBrandingService? brandingService = null)
|
|
: base(logger)
|
|
{
|
|
_auditService = auditService;
|
|
_graphUserSearchService = graphUserSearchService;
|
|
_sessionManager = sessionManager;
|
|
_csvExportService = null;
|
|
_htmlExportService = null;
|
|
_brandingService = brandingService;
|
|
_logger = logger;
|
|
|
|
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
|
|
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
|
|
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
|
|
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
|
|
|
|
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
|
|
|
|
var cvs = new CollectionViewSource { Source = Results };
|
|
ResultsView = cvs.View;
|
|
ApplyGrouping();
|
|
}
|
|
|
|
// ── FeatureViewModelBase implementation ─────────────────────────────────
|
|
|
|
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
|
{
|
|
if (SelectedUsers.Count == 0)
|
|
{
|
|
StatusMessage = "Add at least one user to audit.";
|
|
return;
|
|
}
|
|
|
|
if (GlobalSites.Count == 0)
|
|
{
|
|
StatusMessage = "Select at least one site from the toolbar.";
|
|
return;
|
|
}
|
|
|
|
var effectiveSites = GlobalSites.ToList();
|
|
|
|
var userLogins = SelectedUsers.Select(u => u.UserPrincipalName).ToList();
|
|
var scanOptions = new ScanOptions(
|
|
IncludeInherited: IncludeInherited,
|
|
ScanFolders: ScanFolders,
|
|
FolderDepth: 1,
|
|
IncludeSubsites: IncludeSubsites);
|
|
|
|
if (_currentProfile == null)
|
|
{
|
|
StatusMessage = "No tenant profile selected. Please connect first.";
|
|
return;
|
|
}
|
|
|
|
var entries = await _auditService.AuditUsersAsync(
|
|
_sessionManager,
|
|
_currentProfile,
|
|
userLogins,
|
|
effectiveSites,
|
|
scanOptions,
|
|
progress,
|
|
ct);
|
|
|
|
// Update Results on the UI thread — clear + repopulate (not replace)
|
|
// so the CollectionViewSource bound to ResultsView stays connected.
|
|
var dispatcher = Application.Current?.Dispatcher;
|
|
if (dispatcher != null)
|
|
{
|
|
await dispatcher.InvokeAsync(() =>
|
|
{
|
|
Results.Clear();
|
|
foreach (var entry in entries)
|
|
Results.Add(entry);
|
|
NotifySummaryProperties();
|
|
});
|
|
}
|
|
else
|
|
{
|
|
Results.Clear();
|
|
foreach (var entry in entries)
|
|
Results.Add(entry);
|
|
NotifySummaryProperties();
|
|
}
|
|
|
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
// ── Tenant switching ─────────────────────────────────────────────────────
|
|
|
|
protected override void OnTenantSwitched(TenantProfile profile)
|
|
{
|
|
_currentProfile = profile;
|
|
Results.Clear();
|
|
SelectedUsers.Clear();
|
|
SearchQuery = string.Empty;
|
|
SearchResults.Clear();
|
|
FilterText = string.Empty;
|
|
OnPropertyChanged(nameof(CurrentProfile));
|
|
NotifySummaryProperties();
|
|
ExportCsvCommand.NotifyCanExecuteChanged();
|
|
ExportHtmlCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
// ── Observable property change handlers ─────────────────────────────────
|
|
|
|
partial void OnSearchQueryChanged(string value)
|
|
{
|
|
// Cancel any in-flight search
|
|
_searchCts?.Cancel();
|
|
_searchCts?.Dispose();
|
|
_searchCts = new CancellationTokenSource();
|
|
|
|
var ct = _searchCts.Token;
|
|
_ = DebounceSearchAsync(value, ct);
|
|
}
|
|
|
|
partial void OnFilterTextChanged(string value)
|
|
{
|
|
ResultsView.Refresh();
|
|
}
|
|
|
|
partial void OnIsGroupByUserChanged(bool value)
|
|
{
|
|
ApplyGrouping();
|
|
}
|
|
|
|
partial void OnResultsChanged(ObservableCollection<UserAccessEntry> value)
|
|
{
|
|
// Safety net: if the collection reference ever changes, rebind grouping/filter
|
|
ApplyGrouping();
|
|
ResultsView.Filter = FilterPredicate;
|
|
ResultsView.Refresh();
|
|
NotifySummaryProperties();
|
|
}
|
|
|
|
// ── 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 user access audit to CSV",
|
|
Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*",
|
|
DefaultExt = "csv",
|
|
FileName = "user-access-audit"
|
|
};
|
|
if (dialog.ShowDialog() != true) return;
|
|
try
|
|
{
|
|
await _csvExportService.WriteSingleFileAsync(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 user access audit to HTML",
|
|
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
|
|
DefaultExt = "html",
|
|
FileName = "user-access-audit"
|
|
};
|
|
if (dialog.ShowDialog() != true) return;
|
|
try
|
|
{
|
|
ReportBranding? branding = null;
|
|
if (_brandingService is not null)
|
|
{
|
|
var mspLogo = await _brandingService.GetMspLogoAsync();
|
|
var clientLogo = _currentProfile?.ClientLogo;
|
|
branding = new ReportBranding(mspLogo, clientLogo);
|
|
}
|
|
|
|
await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding);
|
|
OpenFile(dialog.FileName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusMessage = $"Export failed: {ex.Message}";
|
|
_logger.LogError(ex, "HTML export failed.");
|
|
}
|
|
}
|
|
|
|
private void ExecuteAddUser(GraphUserResult? user)
|
|
{
|
|
if (user == null) return;
|
|
if (!SelectedUsers.Any(u => u.UserPrincipalName == user.UserPrincipalName))
|
|
{
|
|
SelectedUsers.Add(user);
|
|
}
|
|
// Clear search after adding
|
|
SearchQuery = string.Empty;
|
|
SearchResults.Clear();
|
|
}
|
|
|
|
private void ExecuteRemoveUser(GraphUserResult? user)
|
|
{
|
|
if (user == null) return;
|
|
SelectedUsers.Remove(user);
|
|
}
|
|
|
|
private async Task DebounceSearchAsync(string query, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(300, ct);
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
if (string.IsNullOrWhiteSpace(query) || query.Length < 2)
|
|
{
|
|
SearchResults.Clear();
|
|
return;
|
|
}
|
|
|
|
IsSearching = true;
|
|
try
|
|
{
|
|
var clientId = _currentProfile?.ClientId ?? string.Empty;
|
|
var results = await _graphUserSearchService.SearchUsersAsync(clientId, query, 10, ct);
|
|
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var dispatcher = Application.Current?.Dispatcher;
|
|
if (dispatcher != null)
|
|
{
|
|
await dispatcher.InvokeAsync(() =>
|
|
{
|
|
SearchResults.Clear();
|
|
foreach (var r in results)
|
|
SearchResults.Add(r);
|
|
});
|
|
}
|
|
else
|
|
{
|
|
SearchResults.Clear();
|
|
foreach (var r in results)
|
|
SearchResults.Add(r);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
IsSearching = false;
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Keystroke cancelled this search — expected, ignore
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "People picker search failed for query '{Query}'.", query);
|
|
IsSearching = false;
|
|
}
|
|
}
|
|
|
|
private void ApplyGrouping()
|
|
{
|
|
ResultsView.GroupDescriptions.Clear();
|
|
if (IsGroupByUser)
|
|
ResultsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(UserAccessEntry.UserLogin)));
|
|
else
|
|
ResultsView.GroupDescriptions.Add(new PropertyGroupDescription(nameof(UserAccessEntry.SiteUrl)));
|
|
|
|
ResultsView.Filter = FilterPredicate;
|
|
}
|
|
|
|
private bool FilterPredicate(object obj)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(FilterText)) return true;
|
|
if (obj is not UserAccessEntry entry) return false;
|
|
|
|
var filter = FilterText.Trim();
|
|
return entry.UserDisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase)
|
|
|| entry.UserLogin.Contains(filter, StringComparison.OrdinalIgnoreCase)
|
|
|| entry.SiteTitle.Contains(filter, StringComparison.OrdinalIgnoreCase)
|
|
|| entry.SiteUrl.Contains(filter, StringComparison.OrdinalIgnoreCase)
|
|
|| entry.ObjectTitle.Contains(filter, StringComparison.OrdinalIgnoreCase)
|
|
|| entry.PermissionLevel.Contains(filter, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private void NotifySummaryProperties()
|
|
{
|
|
OnPropertyChanged(nameof(TotalAccessCount));
|
|
OnPropertyChanged(nameof(SitesCount));
|
|
OnPropertyChanged(nameof(HighPrivilegeCount));
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|