using System.IO; using System.Text; using SharepointToolbox.Core.Helpers; using SharepointToolbox.Core.Models; using SharepointToolbox.Localization; 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); } var T = TranslationSource.Instance; // 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($"{T["report.title.user_access"]}"); sb.AppendLine(""); sb.AppendLine(""); // ── BODY ─────────────────────────────────────────────────────────────── sb.AppendLine(""); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.AppendLine($"

{T["report.title.user_access"]}

"); // Stats cards sb.AppendLine("
"); sb.AppendLine($"
{totalAccesses}
{T["report.stat.total_accesses"]}
"); sb.AppendLine($"
{usersAudited}
{T["report.stat.users_audited"]}
"); sb.AppendLine($"
{sitesScanned}
{T["report.stat.sites_scanned"]}
"); sb.AppendLine($"
{highPrivCount}
{T["report.stat.high_privilege"]}
"); sb.AppendLine($"
{externalCount}
{T["report.stat.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 ? $" {T["report.badge.guest"]}" : "")}
"); sb.AppendLine($"
{uLogin}
"); sb.AppendLine($"
{uTotal} {T["report.text.accesses"]} • {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" • {uHighPriv} {T["report.text.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 ? $" {T["report.badge.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 ? " ⚠" : ""; var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle) ? "—" : HtmlEncode(entry.ObjectTitle); 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("
{T["report.col.site"]}{T["report.col.object_type"]}{T["report.col.object"]}{T["report.col.permission_level"]}{T["report.col.access_type"]}{T["report.col.granted_through"]}
{uName}{guestBadge} — {uCount} {T["report.text.access_es"]}
{HtmlEncode(entry.ObjectType)}{objectCell}{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 ? $" {T["report.badge.guest"]}" : ""; var objectCell = IsRedundantObjectTitle(entry.SiteTitle, entry.ObjectTitle) ? "—" : HtmlEncode(entry.ObjectTitle); sb.AppendLine($""); sb.AppendLine($" "); sb.AppendLine($" "); sb.AppendLine($" "); sb.AppendLine($" {HtmlEncode(entry.PermissionLevel)}{highIcon}"); sb.AppendLine($" "); sb.AppendLine($" "); sb.AppendLine(""); } } sb.AppendLine(""); sb.AppendLine("
{T["report.col.user"]}{T["report.col.object_type"]}{T["report.col.object"]}{T["report.col.permission_level"]}{T["report.col.access_type"]}{T["report.col.granted_through"]}
{siteTitle} — {sCount} {T["report.text.access_es"]}
{HtmlEncode(entry.UserDisplayName)}{guestBadge}{HtmlEncode(entry.ObjectType)}{objectCell}{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) { var T = TranslationSource.Instance; // 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($"{T["report.title.user_access_consolidated"]}"); sb.AppendLine(""); sb.AppendLine(""); // ── BODY ─────────────────────────────────────────────────────────────── sb.AppendLine(""); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); sb.AppendLine($"

{T["report.title.user_access_consolidated"]}

"); // Stats cards sb.AppendLine("
"); sb.AppendLine($"
{totalAccesses}
{T["report.stat.total_accesses"]}
"); sb.AppendLine($"
{usersAudited}
{T["report.stat.users_audited"]}
"); sb.AppendLine($"
{sitesScanned}
{T["report.stat.sites_scanned"]}
"); sb.AppendLine($"
{highPrivCount}
{T["report.stat.high_privilege"]}
"); sb.AppendLine($"
{externalCount}
{T["report.stat.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 ? $" {T["report.badge.guest"]}" : "")}
"); sb.AppendLine($"
{uLogin}
"); sb.AppendLine($"
{uTotal} {T["report.text.accesses"]} • {uSites} {T["report.text.sites_parens"]}{(uHighPriv > 0 ? $" • {uHighPriv} {T["report.text.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 ? $" {T["report.badge.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 = IsRedundantObjectTitle(loc0.SiteTitle, 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 = IsRedundantObjectTitle(loc.SiteTitle, loc.ObjectTitle) ? $"{HtmlEncode(loc.SiteTitle)}" : $"{HtmlEncode(loc.SiteTitle)} › {HtmlEncode(loc.ObjectTitle)}"; sb.AppendLine($""); sb.AppendLine($" "); sb.AppendLine(""); } } } } sb.AppendLine(""); sb.AppendLine("
{T["report.col.user"]}{T["report.col.permission_level"]}{T["report.col.access_type"]}{T["report.col.granted_through"]}{T["report.col.sites"]}
{cuName}{guestBadge} — {cuCount} {T["report.text.permissions_parens"]}
{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) { var T = TranslationSource.Instance; return accessType switch { AccessType.Direct => $"{T["report.badge.direct"]}", AccessType.Group => $"{T["report.badge.group"]}", AccessType.Inherited => $"{T["report.badge.inherited"]}", _ => $"{HtmlEncode(accessType.ToString())}" }; } /// /// Returns true when the ObjectTitle adds no information beyond the SiteTitle: /// empty, identical (case-insensitive), or one is a whitespace-trimmed duplicate /// of the other. Used to collapse "All Company › All Company" to "All Company". /// private static bool IsRedundantObjectTitle(string siteTitle, string objectTitle) { if (string.IsNullOrWhiteSpace(objectTitle)) return true; return string.Equals( (siteTitle ?? string.Empty).Trim(), objectTitle.Trim(), StringComparison.OrdinalIgnoreCase); } /// 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("'", "'"); } }