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 IBrandingService? _brandingService; private readonly IGraphUserDirectoryService? _graphUserDirectoryService; 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; // ── Directory browse mode properties ─────────────────────────────────── /// When true, the UI shows the directory browse panel instead of the people-picker search. [ObservableProperty] private bool _isBrowseMode; /// All directory users loaded from Graph. [ObservableProperty] private ObservableCollection _directoryUsers = new(); /// True while a directory load is in progress. [ObservableProperty] private bool _isLoadingDirectory; /// Status text for directory load progress, e.g. "Loading... 500 users". [ObservableProperty] private string _directoryLoadStatus = string.Empty; /// When true, guest users are shown in the directory view; when false, only members. [ObservableProperty] private bool _includeGuests; /// Text filter applied to DirectoryUsersView (DisplayName, UPN, Department, JobTitle). [ObservableProperty] private string _directoryFilterText = string.Empty; private CancellationTokenSource? _directoryCts = null; // ── 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; /// Number of users currently visible in the filtered directory view. public int DirectoryUserCount => DirectoryUsersView?.Cast().Count() ?? 0; // ── CollectionViewSource (grouping + filtering) ───────────────────────── /// ICollectionView over Results supporting grouping and text filtering. public ICollectionView ResultsView { get; } /// ICollectionView over DirectoryUsers with member/guest and text filtering, sorted by DisplayName. public ICollectionView DirectoryUsersView { get; } // ── Commands ──────────────────────────────────────────────────────────── public IAsyncRelayCommand ExportCsvCommand { get; } public IAsyncRelayCommand ExportHtmlCommand { get; } public RelayCommand AddUserCommand { get; } public RelayCommand RemoveUserCommand { get; } public RelayCommand SelectDirectoryUserCommand { get; } public IAsyncRelayCommand LoadDirectoryCommand { get; } public RelayCommand CancelDirectoryLoadCommand { get; } // ── Current tenant profile ────────────────────────────────────────────── internal TenantProfile? _currentProfile; /// Public accessor for the current tenant profile — used by View layer dialog factory. public TenantProfile? CurrentProfile => _currentProfile; // ── Constructors ──────────────────────────────────────────────────────── /// Full constructor — used by DI and production code. public UserAccessAuditViewModel( IUserAccessAuditService auditService, IGraphUserSearchService graphUserSearchService, ISessionManager sessionManager, UserAccessCsvExportService csvExportService, UserAccessHtmlExportService htmlExportService, IBrandingService brandingService, IGraphUserDirectoryService graphUserDirectoryService, ILogger logger) : base(logger) { _auditService = auditService; _graphUserSearchService = graphUserSearchService; _sessionManager = sessionManager; _csvExportService = csvExportService; _htmlExportService = htmlExportService; _brandingService = brandingService; _graphUserDirectoryService = graphUserDirectoryService; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); AddUserCommand = new RelayCommand(ExecuteAddUser); RemoveUserCommand = new RelayCommand(ExecuteRemoveUser); SelectDirectoryUserCommand = new RelayCommand(ExecuteSelectDirectoryUser); SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel)); var cvs = new CollectionViewSource { Source = Results }; ResultsView = cvs.View; ApplyGrouping(); var dirCvs = new CollectionViewSource { Source = DirectoryUsers }; DirectoryUsersView = dirCvs.View; DirectoryUsersView.SortDescriptions.Add( new SortDescription(nameof(GraphDirectoryUser.DisplayName), ListSortDirection.Ascending)); DirectoryUsersView.Filter = DirectoryFilterPredicate; LoadDirectoryCommand = new AsyncRelayCommand(LoadDirectoryAsync, () => !IsLoadingDirectory); CancelDirectoryLoadCommand = new RelayCommand( () => _directoryCts?.Cancel(), () => IsLoadingDirectory); } /// Test constructor — omits export services (not needed for unit tests). internal UserAccessAuditViewModel( IUserAccessAuditService auditService, IGraphUserSearchService graphUserSearchService, ISessionManager sessionManager, ILogger logger, IBrandingService? brandingService = null, IGraphUserDirectoryService? graphUserDirectoryService = null) : base(logger) { _auditService = auditService; _graphUserSearchService = graphUserSearchService; _sessionManager = sessionManager; _csvExportService = null; _htmlExportService = null; _brandingService = brandingService; _graphUserDirectoryService = graphUserDirectoryService; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); AddUserCommand = new RelayCommand(ExecuteAddUser); RemoveUserCommand = new RelayCommand(ExecuteRemoveUser); SelectDirectoryUserCommand = new RelayCommand(ExecuteSelectDirectoryUser); SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel)); var cvs = new CollectionViewSource { Source = Results }; ResultsView = cvs.View; ApplyGrouping(); var dirCvs = new CollectionViewSource { Source = DirectoryUsers }; DirectoryUsersView = dirCvs.View; DirectoryUsersView.SortDescriptions.Add( new SortDescription(nameof(GraphDirectoryUser.DisplayName), ListSortDirection.Ascending)); DirectoryUsersView.Filter = DirectoryFilterPredicate; LoadDirectoryCommand = new AsyncRelayCommand(LoadDirectoryAsync, () => !IsLoadingDirectory); CancelDirectoryLoadCommand = new RelayCommand( () => _directoryCts?.Cancel(), () => IsLoadingDirectory); } // ── FeatureViewModelBase implementation ───────────────────────────────── protected override async Task RunOperationAsync(CancellationToken ct, IProgress 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(); // Directory browse mode reset _directoryCts?.Cancel(); _directoryCts?.Dispose(); _directoryCts = null; DirectoryUsers.Clear(); DirectoryFilterText = string.Empty; DirectoryLoadStatus = string.Empty; IsBrowseMode = false; IsLoadingDirectory = false; IncludeGuests = false; OnPropertyChanged(nameof(DirectoryUserCount)); } // ── 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) { // Safety net: if the collection reference ever changes, rebind grouping/filter ApplyGrouping(); ResultsView.Filter = FilterPredicate; ResultsView.Refresh(); NotifySummaryProperties(); } partial void OnIncludeGuestsChanged(bool value) { DirectoryUsersView.Refresh(); OnPropertyChanged(nameof(DirectoryUserCount)); } partial void OnDirectoryFilterTextChanged(string value) { DirectoryUsersView.Refresh(); OnPropertyChanged(nameof(DirectoryUserCount)); } partial void OnIsLoadingDirectoryChanged(bool value) { LoadDirectoryCommand.NotifyCanExecuteChanged(); CancelDirectoryLoadCommand.NotifyCanExecuteChanged(); } // ── 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); // ── Directory browse mode ────────────────────────────────────────────── private async Task LoadDirectoryAsync() { if (_graphUserDirectoryService is null) return; var clientId = _currentProfile?.ClientId; if (string.IsNullOrEmpty(clientId)) { StatusMessage = "No tenant profile selected. Please connect first."; return; } _directoryCts?.Cancel(); _directoryCts?.Dispose(); _directoryCts = new CancellationTokenSource(); var ct = _directoryCts.Token; IsLoadingDirectory = true; DirectoryLoadStatus = "Loading..."; try { var progress = new Progress(count => DirectoryLoadStatus = $"Loading... {count} users"); var users = await _graphUserDirectoryService.GetUsersAsync( clientId, includeGuests: true, progress, ct); ct.ThrowIfCancellationRequested(); var dispatcher = Application.Current?.Dispatcher; if (dispatcher != null) { await dispatcher.InvokeAsync(() => PopulateDirectory(users)); } else { PopulateDirectory(users); } DirectoryLoadStatus = $"{users.Count} users loaded"; } catch (OperationCanceledException) { DirectoryLoadStatus = "Load cancelled."; } catch (Exception ex) { DirectoryLoadStatus = $"Failed: {ex.Message}"; _logger.LogError(ex, "Directory load failed."); } finally { IsLoadingDirectory = false; } } private void PopulateDirectory(IReadOnlyList users) { DirectoryUsers.Clear(); foreach (var u in users) DirectoryUsers.Add(u); DirectoryUsersView.Refresh(); OnPropertyChanged(nameof(DirectoryUserCount)); } private bool DirectoryFilterPredicate(object obj) { if (obj is not GraphDirectoryUser user) return false; // Member/guest filter if (!IncludeGuests && !string.Equals(user.UserType, "Member", StringComparison.OrdinalIgnoreCase)) return false; // Text filter if (string.IsNullOrWhiteSpace(DirectoryFilterText)) return true; var filter = DirectoryFilterText.Trim(); return user.DisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase) || user.UserPrincipalName.Contains(filter, StringComparison.OrdinalIgnoreCase) || (user.Department?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false) || (user.JobTitle?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false); } /// Exposes LoadDirectoryAsync for unit tests (internal + InternalsVisibleTo). internal Task TestLoadDirectoryAsync() => LoadDirectoryAsync(); // ── 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 void ExecuteSelectDirectoryUser(GraphDirectoryUser? dirUser) { if (dirUser == null) return; var userResult = new GraphUserResult(dirUser.DisplayName, dirUser.UserPrincipalName, dirUser.Mail); if (!SelectedUsers.Any(u => u.UserPrincipalName == userResult.UserPrincipalName)) { SelectedUsers.Add(userResult); } } 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 } } }