using System.IO; using System.Text; using SharepointToolbox.Core.Helpers; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services.Export; /// /// Exports user access audit results to a self-contained interactive HTML report. /// Produces a single HTML file with dual-view toggle (by-user / by-site), /// collapsible groups, sortable columns, filter input, and risk highlighting. /// No external CSS/JS dependencies — everything is inline. /// 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, 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(); 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 ────────────────────────────────────────────────────────── 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 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 buttons sb.AppendLine("
"); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine("
"); // Filter input sb.AppendLine("
"); sb.AppendLine(" "); sb.AppendLine("
"); // ── BY-USER VIEW ─────────────────────────────────────────────────────── sb.AppendLine("
"); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(""); sb.AppendLine(""); int userGroupIdx = 0; foreach (var ug in userGroups) { var groupId = $"ugrp{userGroupIdx++}"; var uName = HtmlEncode(ug.First().UserDisplayName); var uIsExt = ug.First().IsExternalUser; var uCount = ug.Count(); var guestBadge = uIsExt ? " Guest" : ""; sb.AppendLine($""); sb.AppendLine($" "); sb.AppendLine(""); foreach (var entry in ug) { var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : ""; var accessBadge = AccessTypeBadge(entry.AccessType); var highIcon = entry.IsHighPrivilege ? " ⚠" : ""; sb.AppendLine($""); sb.AppendLine($" {HtmlEncode(entry.SiteTitle)}"); sb.AppendLine($" "); sb.AppendLine($" "); sb.AppendLine($" {HtmlEncode(entry.PermissionLevel)}{highIcon}"); sb.AppendLine($" "); sb.AppendLine($" "); sb.AppendLine(""); } } sb.AppendLine(""); sb.AppendLine("
SiteObject TypeObjectPermission LevelAccess TypeGranted Through
{uName}{guestBadge} — {uCount} access(es)
{HtmlEncode(entry.ObjectType)}{HtmlEncode(entry.ObjectTitle)}{accessBadge}{HtmlEncode(entry.GrantedThrough)}
"); sb.AppendLine("
"); // ── BY-SITE VIEW ─────────────────────────────────────────────────────── sb.AppendLine("
"); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(" "); sb.AppendLine(""); sb.AppendLine(""); var siteGroups = entries.GroupBy(e => e.SiteUrl).OrderBy(g => g.Key).ToList(); int siteGroupIdx = 0; foreach (var sg in siteGroups) { var groupId = $"sgrp{siteGroupIdx++}"; var siteTitle = HtmlEncode(sg.First().SiteTitle); var sCount = sg.Count(); sb.AppendLine($""); sb.AppendLine($" "); sb.AppendLine(""); foreach (var entry in sg) { var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : ""; var accessBadge = AccessTypeBadge(entry.AccessType); var highIcon = entry.IsHighPrivilege ? " ⚠" : ""; var guestBadge = entry.IsExternalUser ? " Guest" : ""; sb.AppendLine($""); sb.AppendLine($" "); sb.AppendLine($" "); sb.AppendLine($" "); sb.AppendLine($" {HtmlEncode(entry.PermissionLevel)}{highIcon}"); sb.AppendLine($" "); sb.AppendLine($" "); sb.AppendLine(""); } } sb.AppendLine(""); sb.AppendLine("
UserObject TypeObjectPermission LevelAccess TypeGranted Through
{siteTitle} — {sCount} access(es)
{HtmlEncode(entry.UserDisplayName)}{guestBadge}{HtmlEncode(entry.ObjectType)}{HtmlEncode(entry.ObjectTitle)}{accessBadge}{HtmlEncode(entry.GrantedThrough)}
"); sb.AppendLine("
"); // ── INLINE JS ───────────────────────────────────────────────────────── sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); return sb.ToString(); } /// /// Writes the HTML report to the specified file path using UTF-8 without BOM. /// public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct, bool mergePermissions = false, ReportBranding? branding = null) { 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 + object title var loc0 = entry.Locations[0]; var locLabel = string.IsNullOrEmpty(loc0.ObjectTitle) ? HtmlEncode(loc0.SiteTitle) : $"{HtmlEncode(loc0.SiteTitle)} › {HtmlEncode(loc0.ObjectTitle)}"; 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) { var subLabel = string.IsNullOrEmpty(loc.ObjectTitle) ? $"{HtmlEncode(loc.SiteTitle)}" : $"{HtmlEncode(loc.SiteTitle)} › {HtmlEncode(loc.ObjectTitle)}"; sb.AppendLine($""); sb.AppendLine($" "); sb.AppendLine(""); } } } } sb.AppendLine(""); sb.AppendLine("
UserPermission LevelAccess TypeGranted ThroughSites
{cuName}{guestBadge} — {cuCount} permission(s)
{accessBadge}{HtmlEncode(entry.GrantedThrough)}{locLabel}
{entry.LocationCount} sites
"); sb.AppendLine($" {subLabel}"); 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 { AccessType.Direct => "Direct", AccessType.Group => "Group", AccessType.Inherited => "Inherited", _ => $"{HtmlEncode(accessType.ToString())}" }; /// Minimal HTML encoding for text content and attribute values. private static string HtmlEncode(string value) { if (string.IsNullOrEmpty(value)) return string.Empty; return value .Replace("&", "&") .Replace("<", "<") .Replace(">", ">") .Replace("\"", """) .Replace("'", "'"); } }