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
+ }
+ }
+}