using System.Collections.ObjectModel; using System.Diagnostics; using System.Windows; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using Microsoft.Extensions.Logging; using Microsoft.Win32; using SharepointToolbox.Core.Messages; using SharepointToolbox.Core.Models; using SharepointToolbox.Services; using SharepointToolbox.Core.Helpers; using SharepointToolbox.Services.Export; namespace SharepointToolbox.ViewModels.Tabs; /// /// ViewModel for the Permissions tab. /// Orchestrates permission scanning across one or multiple SharePoint sites /// and exports results to CSV or HTML. /// public partial class PermissionsViewModel : FeatureViewModelBase { private readonly IPermissionsService _permissionsService; private readonly ISiteListService _siteListService; private readonly ISessionManager _sessionManager; private readonly CsvExportService? _csvExportService; private readonly HtmlExportService? _htmlExportService; private readonly IBrandingService? _brandingService; private readonly ISharePointGroupResolver? _groupResolver; private readonly ILogger _logger; private readonly SettingsService? _settingsService; private readonly IOwnershipElevationService? _ownershipService; // ── Observable properties ─────────────────────────────────────────────── [ObservableProperty] private bool _includeInherited; [ObservableProperty] private bool _scanFolders = true; /// Placeholder for the Export Options toggle — no-op in PermissionsViewModel (reserved for future use). [ObservableProperty] private bool _mergePermissions; [ObservableProperty] private bool _includeSubsites; [ObservableProperty] private int _folderDepth = 1; /// /// When true, sets FolderDepth to 999 (scan all levels). /// public bool IsMaxDepth { get => FolderDepth >= 999; set { if (value) FolderDepth = 999; else if (FolderDepth >= 999) FolderDepth = 1; OnPropertyChanged(); } } [ObservableProperty] private ObservableCollection _results = new(); /// /// When true, displays simplified plain-language labels instead of raw SharePoint role names. /// Toggling does not re-run the scan. /// [ObservableProperty] private bool _isSimplifiedMode; /// /// When true, shows individual item-level rows (detailed view). /// When false, shows only summary rows grouped by risk level (simple view). /// Only meaningful when IsSimplifiedMode is true. /// [ObservableProperty] private bool _isDetailView = true; /// /// Simplified wrappers computed from Results. Rebuilt when Results changes. /// private IReadOnlyList _simplifiedResults = Array.Empty(); public IReadOnlyList SimplifiedResults { get => _simplifiedResults; private set => SetProperty(ref _simplifiedResults, value); } /// /// Summary counts grouped by risk level. Rebuilt when SimplifiedResults changes. /// private IReadOnlyList _summaries = Array.Empty(); public IReadOnlyList Summaries { get => _summaries; private set => SetProperty(ref _summaries, value); } /// /// The collection the DataGrid actually binds to. Returns: /// - Results (raw) when simplified mode is OFF /// - SimplifiedResults when simplified mode is ON and detail view is ON /// - (View handles summary display separately via Summaries property) /// public object ActiveItemsSource => IsSimplifiedMode ? (object)SimplifiedResults : Results; partial void OnFolderDepthChanged(int value) => OnPropertyChanged(nameof(IsMaxDepth)); // ── Commands ──────────────────────────────────────────────────────────── public IAsyncRelayCommand ExportCsvCommand { get; } public IAsyncRelayCommand ExportHtmlCommand { get; } // ── Current tenant profile (received via WeakReferenceMessenger) ──────── internal TenantProfile? _currentProfile; // ── Constructors ──────────────────────────────────────────────────────── /// /// Full constructor — used by DI and production code. /// public PermissionsViewModel( IPermissionsService permissionsService, ISiteListService siteListService, ISessionManager sessionManager, CsvExportService csvExportService, HtmlExportService htmlExportService, IBrandingService brandingService, ILogger logger, ISharePointGroupResolver? groupResolver = null, SettingsService? settingsService = null, IOwnershipElevationService? ownershipService = null) : base(logger) { _permissionsService = permissionsService; _siteListService = siteListService; _sessionManager = sessionManager; _csvExportService = csvExportService; _htmlExportService = htmlExportService; _brandingService = brandingService; _groupResolver = groupResolver; _logger = logger; _settingsService = settingsService; _ownershipService = ownershipService; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); } /// /// Test constructor — omits export services (not needed for unit tests). /// internal PermissionsViewModel( IPermissionsService permissionsService, ISiteListService siteListService, ISessionManager sessionManager, ILogger logger, IBrandingService? brandingService = null, SettingsService? settingsService = null, IOwnershipElevationService? ownershipService = null) : base(logger) { _permissionsService = permissionsService; _siteListService = siteListService; _sessionManager = sessionManager; _csvExportService = null; _htmlExportService = null; _brandingService = brandingService; _logger = logger; _settingsService = settingsService; _ownershipService = ownershipService; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); } // ── FeatureViewModelBase implementation ───────────────────────────────── partial void OnIsSimplifiedModeChanged(bool value) { if (value && Results.Count > 0) RebuildSimplifiedData(); OnPropertyChanged(nameof(ActiveItemsSource)); } partial void OnIsDetailViewChanged(bool value) { OnPropertyChanged(nameof(ActiveItemsSource)); } /// /// Recomputes SimplifiedResults and Summaries from the current Results collection. /// Called when Results changes or when simplified mode is toggled on. /// private void RebuildSimplifiedData() { SimplifiedResults = SimplifiedPermissionEntry.WrapAll(Results); Summaries = PermissionSummaryBuilder.Build(SimplifiedResults); } protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) { var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList(); if (urls.Count == 0) { StatusMessage = "Select at least one site from the toolbar."; return; } var nonEmpty = urls; var allEntries = new List(); var scanOptions = new ScanOptions( IncludeInherited: IncludeInherited, ScanFolders: ScanFolders, FolderDepth: FolderDepth, IncludeSubsites: IncludeSubsites); // Read toggle once before the loop (avoids async in exception filter) var autoOwnership = await IsAutoTakeOwnershipEnabled(); int i = 0; foreach (var url in nonEmpty) { ct.ThrowIfCancellationRequested(); progress.Report(new OperationProgress(i, nonEmpty.Count, $"Scanning {url}...")); var profile = new TenantProfile { TenantUrl = url, ClientId = _currentProfile?.ClientId ?? string.Empty, Name = _currentProfile?.Name ?? string.Empty }; bool wasElevated = false; IReadOnlyList siteEntries; try { var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct); siteEntries = await _permissionsService.ScanSiteAsync(ctx, scanOptions, progress, ct); } catch (Exception ex) when (IsAccessDenied(ex) && _ownershipService != null && autoOwnership) { _logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", url); var adminUrl = DeriveAdminUrl(_currentProfile?.TenantUrl ?? url); var adminProfile = new TenantProfile { TenantUrl = adminUrl, ClientId = _currentProfile?.ClientId ?? string.Empty, Name = _currentProfile?.Name ?? string.Empty }; var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct); await _ownershipService.ElevateAsync(adminCtx, url, string.Empty, ct); // Retry scan with fresh context var retryCtx = await _sessionManager.GetOrCreateContextAsync(profile, ct); siteEntries = await _permissionsService.ScanSiteAsync(retryCtx, scanOptions, progress, ct); wasElevated = true; } if (wasElevated) allEntries.AddRange(siteEntries.Select(e => e with { WasAutoElevated = true })); else allEntries.AddRange(siteEntries); i++; } // Update Results on the UI thread (no-op if no Dispatcher in tests) var dispatcher = Application.Current?.Dispatcher; if (dispatcher != null) { await dispatcher.InvokeAsync(() => { Results = new ObservableCollection(allEntries); if (IsSimplifiedMode) RebuildSimplifiedData(); OnPropertyChanged(nameof(ActiveItemsSource)); }); } else { Results = new ObservableCollection(allEntries); if (IsSimplifiedMode) RebuildSimplifiedData(); OnPropertyChanged(nameof(ActiveItemsSource)); } ExportCsvCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged(); } // ── Auto-ownership helpers ─────────────────────────────────────────────── /// /// Derives the tenant admin URL from a standard tenant URL. /// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com /// internal static string DeriveAdminUrl(string tenantUrl) { var uri = new Uri(tenantUrl.TrimEnd('/')); var host = uri.Host; if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase)) return tenantUrl; var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase); return $"{uri.Scheme}://{adminHost}"; } private static bool IsAccessDenied(Exception ex) { if (ex is Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) return true; if (ex is System.Net.WebException webEx && webEx.Response is System.Net.HttpWebResponse resp && resp.StatusCode == System.Net.HttpStatusCode.Forbidden) return true; return false; } private async Task IsAutoTakeOwnershipEnabled() { if (_settingsService == null) return false; var settings = await _settingsService.GetSettingsAsync(); return settings.AutoTakeOwnership; } // ── Tenant switching ───────────────────────────────────────────────────── protected override void OnTenantSwitched(TenantProfile profile) { _currentProfile = profile; Results = new ObservableCollection(); SimplifiedResults = Array.Empty(); Summaries = Array.Empty(); OnPropertyChanged(nameof(ActiveItemsSource)); ExportCsvCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.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); // ── 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 permissions to CSV", Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", DefaultExt = "csv", FileName = "permissions" }; if (dialog.ShowDialog() != true) return; try { if (IsSimplifiedMode && SimplifiedResults.Count > 0) await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None); else await _csvExportService.WriteAsync(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 permissions to HTML", Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*", DefaultExt = "html", FileName = "permissions" }; 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); } IReadOnlyDictionary>? groupMembers = null; if (_groupResolver != null && Results.Count > 0) { var groupNames = Results .Where(r => r.PrincipalType == "SharePointGroup") .SelectMany(r => r.Users.Split(';', StringSplitOptions.RemoveEmptyEntries)) .Select(n => n.Trim()) .Where(n => n.Length > 0) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); if (groupNames.Count > 0 && _currentProfile != null) { try { var ctx = await _sessionManager.GetOrCreateContextAsync( _currentProfile, CancellationToken.None); groupMembers = await _groupResolver.ResolveGroupsAsync( ctx, _currentProfile.ClientId, groupNames, CancellationToken.None); } catch (Exception ex) { _logger.LogWarning(ex, "Group resolution failed — exporting without member expansion."); } } } if (IsSimplifiedMode && SimplifiedResults.Count > 0) await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding, groupMembers); else await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding, groupMembers); OpenFile(dialog.FileName); } catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); } } 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 } } }