Files
Sharepoint-Toolbox/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
Dev 0ebe707aca feat(16-02): implement consolidated HTML rendering path
- Add mergePermissions parameter to BuildHtml and WriteAsync
- Early-return branch calls PermissionConsolidator.Consolidate and delegates to BuildConsolidatedHtml
- BuildConsolidatedHtml: by-user table with Sites column, expandable [N sites] badge with toggleGroup, hidden sub-rows (data-group=locN), inline title for single-location entries
- By-site view and btn-site omitted when mergePermissions=true
- Wire UserAccessAuditViewModel.ExportHtmlAsync to pass MergePermissions
- Fix existing branding test call site to use named parameter
2026-04-09 12:38:19 +02:00

666 lines
26 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 IGraphUserDirectoryService? _graphUserDirectoryService;
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;
// ── Directory browse mode properties ───────────────────────────────────
/// <summary>When true, the UI shows the directory browse panel instead of the people-picker search.</summary>
[ObservableProperty]
private bool _isBrowseMode;
/// <summary>All directory users loaded from Graph.</summary>
[ObservableProperty]
private ObservableCollection<GraphDirectoryUser> _directoryUsers = new();
/// <summary>True while a directory load is in progress.</summary>
[ObservableProperty]
private bool _isLoadingDirectory;
/// <summary>Status text for directory load progress, e.g. "Loading... 500 users".</summary>
[ObservableProperty]
private string _directoryLoadStatus = string.Empty;
/// <summary>When true, guest users are shown in the directory view; when false, only members.</summary>
[ObservableProperty]
private bool _includeGuests;
/// <summary>Text filter applied to DirectoryUsersView (DisplayName, UPN, Department, JobTitle).</summary>
[ObservableProperty]
private string _directoryFilterText = string.Empty;
/// <summary>When true, the CSV export merges duplicate permission rows into consolidated entries.</summary>
[ObservableProperty]
private bool _mergePermissions;
private CancellationTokenSource? _directoryCts = null;
// ── 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;
/// <summary>Number of users currently visible in the filtered directory view.</summary>
public int DirectoryUserCount => DirectoryUsersView?.Cast<object>().Count() ?? 0;
// ── CollectionViewSource (grouping + filtering) ─────────────────────────
/// <summary>ICollectionView over Results supporting grouping and text filtering.</summary>
public ICollectionView ResultsView { get; }
/// <summary>ICollectionView over DirectoryUsers with member/guest and text filtering, sorted by DisplayName.</summary>
public ICollectionView DirectoryUsersView { get; }
// ── Commands ────────────────────────────────────────────────────────────
public IAsyncRelayCommand ExportCsvCommand { get; }
public IAsyncRelayCommand ExportHtmlCommand { get; }
public RelayCommand<GraphUserResult> AddUserCommand { get; }
public RelayCommand<GraphUserResult> RemoveUserCommand { get; }
public RelayCommand<GraphDirectoryUser> SelectDirectoryUserCommand { get; }
public IAsyncRelayCommand LoadDirectoryCommand { get; }
public RelayCommand CancelDirectoryLoadCommand { 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,
IGraphUserDirectoryService graphUserDirectoryService,
ILogger<FeatureViewModelBase> 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<GraphUserResult>(ExecuteAddUser);
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
SelectDirectoryUserCommand = new RelayCommand<GraphDirectoryUser>(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);
}
/// <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,
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<GraphUserResult>(ExecuteAddUser);
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
SelectDirectoryUserCommand = new RelayCommand<GraphDirectoryUser>(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<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();
// 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<UserAccessEntry> 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 ─────────────────────────────────────────────────────
/// <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);
// ── 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<int>(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<GraphDirectoryUser> 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);
}
/// <summary>Exposes LoadDirectoryAsync for unit tests (internal + InternalsVisibleTo).</summary>
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, MergePermissions);
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, MergePermissions, 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
}
}
}