From 3146a04ad8da9e7422ccaaf2ba81d0031894ea39 Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 7 Apr 2026 12:40:51 +0200 Subject: [PATCH] feat(07-06): implement UserAccessHtmlExportService - BuildHtml produces self-contained HTML with inline CSS and JS - Stats cards: Total Accesses, Users Audited, Sites Scanned, High Privilege, External Users - Per-user summary cards with high-privilege border highlight and guest badge - Dual-view toggle (By User / By Site) with JS toggleView() - Collapsible group headers per user and per site via toggleGroup() - Sortable columns via sortTable() within each group - Text filter via filterTable() scoping to active view - Color-coded access type badges: Direct (blue), Group (green), Inherited (gray) - High-privilege rows with bold text and warning icon - External user guest badge (orange pill) - UTF-8 without BOM encoding (matching HtmlExportService pattern) --- .../Export/UserAccessHtmlExportService.cs | 349 ++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs diff --git a/SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs b/SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs new file mode 100644 index 0000000..de2a55f --- /dev/null +++ b/SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs @@ -0,0 +1,349 @@ +using System.IO; +using System.Text; +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. + /// + public string BuildHtml(IReadOnlyList entries) + { + // 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.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) + { + var html = BuildHtml(entries); + await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct); + } + + /// 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("'", "'"); + } +}