diff --git a/SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs b/SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs index 026e57b..bd157d9 100644 --- a/SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs +++ b/SharepointToolbox.Tests/Services/Export/UserAccessHtmlExportServiceTests.cs @@ -138,7 +138,7 @@ public class UserAccessHtmlExportServiceTests public void BuildHtml_WithBranding_ContainsLogoImg() { var svc = new UserAccessHtmlExportService(); - var html = svc.BuildHtml(new[] { DefaultEntry }, MakeBranding(msp: true)); + var html = svc.BuildHtml(new[] { DefaultEntry }, branding: MakeBranding(msp: true)); Assert.Contains("data:image/png;base64,bXNw", html); } diff --git a/SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs b/SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs index bcb2e89..51d69bd 100644 --- a/SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs +++ b/SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs @@ -1,5 +1,6 @@ using System.IO; using System.Text; +using SharepointToolbox.Core.Helpers; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services.Export; @@ -14,9 +15,17 @@ public class UserAccessHtmlExportService { /// /// Builds a self-contained HTML string from the supplied user access entries. + /// When is true, renders a consolidated by-user + /// report with an expandable Sites column instead of the dual by-user/by-site view. /// - public string BuildHtml(IReadOnlyList entries, ReportBranding? branding = null) + public string BuildHtml(IReadOnlyList entries, bool mergePermissions = false, ReportBranding? branding = null) { + if (mergePermissions) + { + var consolidated = PermissionConsolidator.Consolidate(entries); + return BuildConsolidatedHtml(consolidated, entries, branding); + } + // Compute stats var totalAccesses = entries.Count; var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count(); @@ -321,12 +330,259 @@ function sortTable(view, col) { /// /// Writes the HTML report to the specified file path using UTF-8 without BOM. /// - public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct, ReportBranding? branding = null) + public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct, bool mergePermissions = false, ReportBranding? branding = null) { - var html = BuildHtml(entries, branding); + var html = BuildHtml(entries, mergePermissions, branding); await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct); } + /// + /// Builds the consolidated HTML report: single by-user table with a Sites column. + /// By-site view and view-toggle are omitted. Uses the same CSS shell as BuildHtml. + /// + private string BuildConsolidatedHtml( + IReadOnlyList consolidated, + IReadOnlyList entries, + ReportBranding? branding) + { + // Stats computed from the original flat list for accurate counts + var totalAccesses = entries.Count; + var usersAudited = entries.Select(e => e.UserLogin).Distinct().Count(); + var sitesScanned = entries.Select(e => e.SiteUrl).Distinct().Count(); + var highPrivCount = entries.Count(e => e.IsHighPrivilege); + var externalCount = entries.Count(e => e.IsExternalUser); + + var sb = new StringBuilder(); + + // ── HTML HEAD (same as BuildHtml) ────────────────────────────────────── + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine("User Access Audit Report"); + sb.AppendLine(""); + sb.AppendLine(""); + + // ── BODY ─────────────────────────────────────────────────────────────── + sb.AppendLine(""); + sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); + sb.AppendLine("

User Access Audit Report

"); + + // Stats cards + sb.AppendLine("
"); + sb.AppendLine($"
{totalAccesses}
Total Accesses
"); + sb.AppendLine($"
{usersAudited}
Users Audited
"); + sb.AppendLine($"
{sitesScanned}
Sites Scanned
"); + sb.AppendLine($"
{highPrivCount}
High Privilege
"); + sb.AppendLine($"
{externalCount}
External Users
"); + sb.AppendLine("
"); + + // Per-user summary cards (from original flat entries) + sb.AppendLine("
"); + var userGroups = entries.GroupBy(e => e.UserLogin).OrderBy(g => g.Key).ToList(); + foreach (var ug in userGroups) + { + var uName = HtmlEncode(ug.First().UserDisplayName); + var uLogin = HtmlEncode(ug.Key); + var uTotal = ug.Count(); + var uSites = ug.Select(e => e.SiteUrl).Distinct().Count(); + var uHighPriv = ug.Count(e => e.IsHighPrivilege); + var uIsExt = ug.First().IsExternalUser; + var cardClass = uHighPriv > 0 ? "user-card has-high-priv" : "user-card"; + + sb.AppendLine($"
"); + sb.AppendLine($"
{uName}{(uIsExt ? " Guest" : "")}
"); + sb.AppendLine($"
{uLogin}
"); + sb.AppendLine($"
{uTotal} accesses • {uSites} site(s){(uHighPriv > 0 ? $" • {uHighPriv} high-priv" : "")}
"); + sb.AppendLine("
"); + } + sb.AppendLine("
"); + + // View toggle — only By User (By Site is suppressed for consolidated view) + sb.AppendLine("
"); + sb.AppendLine(" "); + sb.AppendLine("
"); + + // Filter input + sb.AppendLine("
"); + sb.AppendLine(" "); + sb.AppendLine("
"); + + // ── CONSOLIDATED BY-USER TABLE ──────────────────────────────────────── + sb.AppendLine("
"); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(" "); + sb.AppendLine(""); + sb.AppendLine(""); + + // Group consolidated entries by UserLogin for group headers + var consolidatedByUser = consolidated + .GroupBy(c => c.UserLogin) + .OrderBy(g => g.Key) + .ToList(); + + int grpIdx = 0; + int locIdx = 0; // SEPARATE counter for location group IDs — Pitfall 2 + + foreach (var cug in consolidatedByUser) + { + var groupId = $"ugrp{grpIdx++}"; + var cuName = HtmlEncode(cug.First().UserDisplayName); + var cuIsExt = cug.First().IsExternalUser; + var cuCount = cug.Count(); + var guestBadge = cuIsExt ? " Guest" : ""; + + sb.AppendLine($""); + sb.AppendLine($" "); + sb.AppendLine(""); + + foreach (var entry in cug) + { + var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : ""; + var accessBadge = AccessTypeBadge(entry.AccessType); + var highIcon = entry.IsHighPrivilege ? " ⚠" : ""; + + sb.AppendLine($""); + sb.AppendLine($" {HtmlEncode(entry.UserDisplayName)}{guestBadge}"); + sb.AppendLine($" {HtmlEncode(entry.PermissionLevel)}{highIcon}"); + sb.AppendLine($" "); + sb.AppendLine($" "); + + if (entry.LocationCount == 1) + { + // Single location — inline site title, no badge + sb.AppendLine($" "); + sb.AppendLine(""); + } + else + { + // Multiple locations — expandable badge + var currentLocId = $"loc{locIdx++}"; + sb.AppendLine($" "); + sb.AppendLine(""); + + // Hidden sub-rows — one per location + foreach (var loc in entry.Locations) + { + sb.AppendLine($""); + sb.AppendLine($" "); + sb.AppendLine(""); + } + } + } + } + + sb.AppendLine(""); + sb.AppendLine("
UserPermission LevelAccess TypeGranted ThroughSites
{cuName}{guestBadge} — {cuCount} permission(s)
{accessBadge}{HtmlEncode(entry.GrantedThrough)}{HtmlEncode(entry.Locations[0].SiteTitle)}
{entry.LocationCount} sites
"); + sb.AppendLine($" {HtmlEncode(loc.SiteTitle)}"); + sb.AppendLine("
"); + sb.AppendLine("
"); + + // ── INLINE JS ───────────────────────────────────────────────────────── + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + + return sb.ToString(); + } + /// Returns a colored badge span for the given access type. private static string AccessTypeBadge(AccessType accessType) => accessType switch { diff --git a/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs b/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs index ecac2a1..ae909c8 100644 --- a/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs @@ -527,7 +527,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase branding = new ReportBranding(mspLogo, clientLogo); } - await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding); + await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions, branding); OpenFile(dialog.FileName); } catch (Exception ex)