feat(07-04): implement UserAccessAuditViewModel

- Extends FeatureViewModelBase with RunOperationAsync calling IUserAccessAuditService.AuditUsersAsync
- People picker with 300ms debounced Graph search via IGraphUserSearchService.SearchUsersAsync
- SelectedUsers ObservableCollection<GraphUserResult> with AddUserCommand/RemoveUserCommand
- Results ObservableCollection<UserAccessEntry> with CollectionViewSource grouping (by user/site) and FilterText predicate
- Summary banner properties: TotalAccessCount, SitesCount, HighPrivilegeCount (computed from Results)
- ExportCsvCommand/ExportHtmlCommand using UserAccessCsvExportService/UserAccessHtmlExportService
- Site selection with _hasLocalSiteOverride + OnGlobalSitesChanged pattern from PermissionsViewModel
- Dual constructors (DI + internal test constructor omitting export services)
- OnTenantSwitched resets all state (results, users, search, sites)
This commit is contained in:
Dev
2026-04-07 12:44:02 +02:00
parent 5c4a285473
commit 3de737ac3f

View File

@@ -0,0 +1,503 @@
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 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 OpenSitePickerCommand { get; }
public RelayCommand<GraphUserResult> AddUserCommand { get; }
public RelayCommand<GraphUserResult> RemoveUserCommand { get; }
// ── Multi-site ──────────────────────────────────────────────────────────
public ObservableCollection<SiteInfo> SelectedSites { get; } = new();
/// <summary>
/// True when the user has manually selected sites via the site picker.
/// Prevents global site changes from overwriting the local selection.
/// </summary>
private bool _hasLocalSiteOverride;
// ── Dialog factory ──────────────────────────────────────────────────────
/// <summary>Factory set by the View layer to open the SitePickerDialog without importing Window into ViewModel.</summary>
public Func<Window>? OpenSitePickerDialog { get; set; }
// ── 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;
/// <summary>Label shown in the UI: "3 site(s) selected" or empty when none are selected.</summary>
public string SitesSelectedLabel =>
SelectedSites.Count > 0
? $"{SelectedSites.Count} site(s) selected"
: string.Empty;
// ── Constructors ────────────────────────────────────────────────────────
/// <summary>Full constructor — used by DI and production code.</summary>
public UserAccessAuditViewModel(
IUserAccessAuditService auditService,
IGraphUserSearchService graphUserSearchService,
ISessionManager sessionManager,
UserAccessCsvExportService csvExportService,
UserAccessHtmlExportService htmlExportService,
ILogger<FeatureViewModelBase> logger)
: base(logger)
{
_auditService = auditService;
_graphUserSearchService = graphUserSearchService;
_sessionManager = sessionManager;
_csvExportService = csvExportService;
_htmlExportService = htmlExportService;
_logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
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)
: base(logger)
{
_auditService = auditService;
_graphUserSearchService = graphUserSearchService;
_sessionManager = sessionManager;
_csvExportService = null;
_htmlExportService = null;
_logger = logger;
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
var cvs = new CollectionViewSource { Source = Results };
ResultsView = cvs.View;
ApplyGrouping();
}
// ── 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)
{
if (SelectedUsers.Count == 0)
{
StatusMessage = "Add at least one user to audit.";
return;
}
var effectiveSites = SelectedSites.Count > 0
? SelectedSites.ToList()
: GlobalSites.ToList();
if (effectiveSites.Count == 0)
{
StatusMessage = "Select at least one site to scan.";
return;
}
var userLogins = SelectedUsers.Select(u => u.UserPrincipalName).ToList();
var scanOptions = new ScanOptions(
IncludeInherited: IncludeInherited,
ScanFolders: ScanFolders,
FolderDepth: 1,
IncludeSubsites: IncludeSubsites);
var entries = await _auditService.AuditUsersAsync(
_sessionManager,
userLogins,
effectiveSites,
scanOptions,
progress,
ct);
// Update Results on the UI thread
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher != null)
{
await dispatcher.InvokeAsync(() =>
{
Results = new ObservableCollection<UserAccessEntry>(entries);
NotifySummaryProperties();
});
}
else
{
Results = new ObservableCollection<UserAccessEntry>(entries);
NotifySummaryProperties();
}
ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged();
}
// ── Tenant switching ─────────────────────────────────────────────────────
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
_hasLocalSiteOverride = false;
Results = new ObservableCollection<UserAccessEntry>();
SelectedUsers.Clear();
SearchQuery = string.Empty;
SearchResults.Clear();
SelectedSites.Clear();
FilterText = string.Empty;
OnPropertyChanged(nameof(SitesSelectedLabel));
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)
{
// Rebind CollectionViewSource when the collection reference changes
if (ResultsView is CollectionView cv && cv.SourceCollection != value)
{
// CollectionViewSource.View is already live-bound in constructor;
// for a new collection reference we need to refresh 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
{
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 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
}
}
}