From 3de737ac3ff5fab8eb36689df4e524bc12aa7e02 Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 7 Apr 2026 12:44:02 +0200 Subject: [PATCH] feat(07-04): implement UserAccessAuditViewModel - Extends FeatureViewModelBase with RunOperationAsync calling IUserAccessAuditService.AuditUsersAsync - People picker with 300ms debounced Graph search via IGraphUserSearchService.SearchUsersAsync - SelectedUsers ObservableCollection with AddUserCommand/RemoveUserCommand - Results ObservableCollection 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) --- .../Tabs/UserAccessAuditViewModel.cs | 503 ++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs diff --git a/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs b/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs new file mode 100644 index 0000000..4faf6a8 --- /dev/null +++ b/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs @@ -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; + +/// +/// ViewModel for the User Access Audit tab. +/// Orchestrates people-picker search, site selection, audit execution, +/// result grouping/filtering, summary banner, and export commands. +/// +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 _logger; + + // ── People picker debounce ────────────────────────────────────────────── + + private CancellationTokenSource? _searchCts; + + // ── Observable properties ─────────────────────────────────────────────── + + /// Text typed in the people-picker search box. Changes trigger debounced Graph search. + [ObservableProperty] + private string _searchQuery = string.Empty; + + /// Autocomplete dropdown items from Graph search results. + [ObservableProperty] + private ObservableCollection _searchResults = new(); + + /// Users added for audit. + [ObservableProperty] + private ObservableCollection _selectedUsers = new(); + + /// Raw audit output entries. + [ObservableProperty] + private ObservableCollection _results = new(); + + /// Real-time text filter applied to ResultsView. + [ObservableProperty] + private string _filterText = string.Empty; + + /// When true, results are grouped by user; when false, grouped by site. + [ObservableProperty] + private bool _isGroupByUser = true; + + /// Include inherited permissions in scan. + [ObservableProperty] + private bool _includeInherited; + + /// Include folders in scan. + [ObservableProperty] + private bool _scanFolders = true; + + /// Include subsites in scan. + [ObservableProperty] + private bool _includeSubsites; + + /// True while a Graph user search is in progress. + [ObservableProperty] + private bool _isSearching; + + // ── Computed summary properties ───────────────────────────────────────── + + /// Total number of access entries in current results. + public int TotalAccessCount => Results.Count; + + /// Number of distinct sites referenced in current results. + public int SitesCount => Results.Select(r => r.SiteUrl).Distinct().Count(); + + /// Number of high-privilege access entries in current results. + public int HighPrivilegeCount => Results.Count(r => r.IsHighPrivilege); + + /// Human-readable label for the people-picker selection, e.g. "2 user(s) selected". + public string SelectedUsersLabel => + SelectedUsers.Count > 0 + ? $"{SelectedUsers.Count} user(s) selected" + : string.Empty; + + // ── CollectionViewSource (grouping + filtering) ───────────────────────── + + /// ICollectionView over Results supporting grouping and text filtering. + public ICollectionView ResultsView { get; } + + // ── Commands ──────────────────────────────────────────────────────────── + + public IAsyncRelayCommand ExportCsvCommand { get; } + public IAsyncRelayCommand ExportHtmlCommand { get; } + public RelayCommand OpenSitePickerCommand { get; } + public RelayCommand AddUserCommand { get; } + public RelayCommand RemoveUserCommand { get; } + + // ── Multi-site ────────────────────────────────────────────────────────── + + public ObservableCollection SelectedSites { get; } = new(); + + /// + /// True when the user has manually selected sites via the site picker. + /// Prevents global site changes from overwriting the local selection. + /// + private bool _hasLocalSiteOverride; + + // ── Dialog factory ────────────────────────────────────────────────────── + + /// Factory set by the View layer to open the SitePickerDialog without importing Window into ViewModel. + public Func? OpenSitePickerDialog { get; set; } + + // ── Current tenant profile ────────────────────────────────────────────── + + internal TenantProfile? _currentProfile; + + /// Public accessor for the current tenant profile — used by View layer dialog factory. + public TenantProfile? CurrentProfile => _currentProfile; + + /// Label shown in the UI: "3 site(s) selected" or empty when none are selected. + public string SitesSelectedLabel => + SelectedSites.Count > 0 + ? $"{SelectedSites.Count} site(s) selected" + : string.Empty; + + // ── Constructors ──────────────────────────────────────────────────────── + + /// Full constructor — used by DI and production code. + public UserAccessAuditViewModel( + IUserAccessAuditService auditService, + IGraphUserSearchService graphUserSearchService, + ISessionManager sessionManager, + UserAccessCsvExportService csvExportService, + UserAccessHtmlExportService htmlExportService, + ILogger 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(ExecuteAddUser); + RemoveUserCommand = new RelayCommand(ExecuteRemoveUser); + + SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel)); + SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel)); + + var cvs = new CollectionViewSource { Source = Results }; + ResultsView = cvs.View; + ApplyGrouping(); + } + + /// Test constructor — omits export services (not needed for unit tests). + internal UserAccessAuditViewModel( + IUserAccessAuditService auditService, + IGraphUserSearchService graphUserSearchService, + ISessionManager sessionManager, + ILogger 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(ExecuteAddUser); + RemoveUserCommand = new RelayCommand(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 sites) + { + if (_hasLocalSiteOverride) return; + + SelectedSites.Clear(); + foreach (var site in sites) + SelectedSites.Add(site); + } + + protected override async Task RunOperationAsync(CancellationToken ct, IProgress 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(entries); + NotifySummaryProperties(); + }); + } + else + { + Results = new ObservableCollection(entries); + NotifySummaryProperties(); + } + + ExportCsvCommand.NotifyCanExecuteChanged(); + ExportHtmlCommand.NotifyCanExecuteChanged(); + } + + // ── Tenant switching ───────────────────────────────────────────────────── + + protected override void OnTenantSwitched(TenantProfile profile) + { + _currentProfile = profile; + _hasLocalSiteOverride = false; + Results = new ObservableCollection(); + 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 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 ───────────────────────────────────────────────────── + + /// Sets the current tenant profile (for test injection). + internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile; + + /// Exposes RunOperationAsync for unit tests (internal + InternalsVisibleTo). + internal Task TestRunOperationAsync(CancellationToken ct, IProgress 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 + } + } +}