- Add mergePermissions parameter to BuildHtml and WriteAsync - Early-return branch calls PermissionConsolidator.Consolidate and delegates to BuildConsolidatedHtml - BuildConsolidatedHtml: by-user table with Sites column, expandable [N sites] badge with toggleGroup, hidden sub-rows (data-group=locN), inline title for single-location entries - By-site view and btn-site omitted when mergePermissions=true - Wire UserAccessAuditViewModel.ExportHtmlAsync to pass MergePermissions - Fix existing branding test call site to use named parameter
607 lines
31 KiB
C#
607 lines
31 KiB
C#
using System.IO;
|
|
using System.Text;
|
|
using SharepointToolbox.Core.Helpers;
|
|
using SharepointToolbox.Core.Models;
|
|
|
|
namespace SharepointToolbox.Services.Export;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public class UserAccessHtmlExportService
|
|
{
|
|
/// <summary>
|
|
/// Builds a self-contained HTML string from the supplied user access entries.
|
|
/// When <paramref name="mergePermissions"/> is true, renders a consolidated by-user
|
|
/// report with an expandable Sites column instead of the dual by-user/by-site view.
|
|
/// </summary>
|
|
public string BuildHtml(IReadOnlyList<UserAccessEntry> 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("<!DOCTYPE html>");
|
|
sb.AppendLine("<html lang=\"en\">");
|
|
sb.AppendLine("<head>");
|
|
sb.AppendLine("<meta charset=\"UTF-8\">");
|
|
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
|
sb.AppendLine("<title>User Access Audit Report</title>");
|
|
sb.AppendLine("<style>");
|
|
sb.AppendLine(@"
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
|
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
|
|
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
|
|
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
|
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
|
|
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
|
|
/* View toggle */
|
|
.view-toggle { display: flex; gap: 8px; padding: 0 24px 12px; }
|
|
.view-toggle button { padding: 7px 18px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer; font-size: .9rem; }
|
|
.view-toggle button.active { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
|
|
/* Filter */
|
|
.filter-wrap { padding: 0 24px 12px; }
|
|
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
|
|
/* Per-user summary cards */
|
|
.user-summary { padding: 0 24px 16px; display: flex; gap: 12px; flex-wrap: wrap; }
|
|
.user-card { background: #fff; border-radius: 8px; padding: 12px 16px; min-width: 200px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
|
.user-card .user-name { font-weight: 600; font-size: .95rem; color: #1a1a2e; margin-bottom: 4px; }
|
|
.user-card .user-stats { font-size: .8rem; color: #555; }
|
|
.user-card.has-high-priv { border-left: 4px solid #dc2626; }
|
|
/* Table wrap */
|
|
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
|
|
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
|
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; cursor: pointer; user-select: none; }
|
|
th:hover { background: #2d2d4e; }
|
|
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
|
|
tr:last-child td { border-bottom: none; }
|
|
tr:hover td { background: #fafafa; }
|
|
/* Group headers */
|
|
.group-header { cursor: pointer; background: #f0f0f0; padding: 10px 14px; font-weight: 600; font-size: .875rem; }
|
|
.group-header:hover { background: #e8e8e8; }
|
|
.group-header td { background: inherit !important; font-weight: 600; }
|
|
/* Badges */
|
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
|
|
/* Access type badges */
|
|
.access-direct { background: #dbeafe; color: #1e40af; }
|
|
.access-group { background: #dcfce7; color: #166534; }
|
|
.access-inherited { background: #f3f4f6; color: #374151; }
|
|
/* High privilege */
|
|
.high-priv { font-weight: 700; }
|
|
/* Guest badge */
|
|
.guest-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: .75rem; font-weight: 600; background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; margin-left: 4px; }
|
|
/* User pills */
|
|
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
|
|
a { color: #2563eb; text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
.hidden { display: none; }
|
|
");
|
|
sb.AppendLine("</style>");
|
|
sb.AppendLine("</head>");
|
|
|
|
// ── BODY ───────────────────────────────────────────────────────────────
|
|
sb.AppendLine("<body>");
|
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
|
sb.AppendLine("<h1>User Access Audit Report</h1>");
|
|
|
|
// Stats cards
|
|
sb.AppendLine("<div class=\"stats\">");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">Total Accesses</div></div>");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">Users Audited</div></div>");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">Sites Scanned</div></div>");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">High Privilege</div></div>");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">External Users</div></div>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// Per-user summary cards
|
|
sb.AppendLine("<div class=\"user-summary\">");
|
|
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($" <div class=\"{cardClass}\">");
|
|
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "")}</div>");
|
|
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
|
|
sb.AppendLine($" <div class=\"user-stats\">{uTotal} accesses • {uSites} site(s){(uHighPriv > 0 ? $" • <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>");
|
|
sb.AppendLine(" </div>");
|
|
}
|
|
sb.AppendLine("</div>");
|
|
|
|
// View toggle buttons
|
|
sb.AppendLine("<div class=\"view-toggle\">");
|
|
sb.AppendLine(" <button id=\"btn-user\" class=\"active\" onclick=\"toggleView('user')\">By User</button>");
|
|
sb.AppendLine(" <button id=\"btn-site\" onclick=\"toggleView('site')\">By Site</button>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// Filter input
|
|
sb.AppendLine("<div class=\"filter-wrap\">");
|
|
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter results...\" oninput=\"filterTable()\" />");
|
|
sb.AppendLine("</div>");
|
|
|
|
// ── BY-USER VIEW ───────────────────────────────────────────────────────
|
|
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
|
|
sb.AppendLine("<table id=\"tbl-user\">");
|
|
sb.AppendLine("<thead><tr>");
|
|
sb.AppendLine(" <th onclick=\"sortTable('user',0)\">Site</th>");
|
|
sb.AppendLine(" <th onclick=\"sortTable('user',1)\">Object Type</th>");
|
|
sb.AppendLine(" <th onclick=\"sortTable('user',2)\">Object</th>");
|
|
sb.AppendLine(" <th onclick=\"sortTable('user',3)\">Permission Level</th>");
|
|
sb.AppendLine(" <th onclick=\"sortTable('user',4)\">Access Type</th>");
|
|
sb.AppendLine(" <th onclick=\"sortTable('user',5)\">Granted Through</th>");
|
|
sb.AppendLine("</tr></thead>");
|
|
sb.AppendLine("<tbody id=\"tbody-user\">");
|
|
|
|
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 ? " <span class=\"guest-badge\">Guest</span>" : "";
|
|
|
|
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
|
|
sb.AppendLine($" <td colspan=\"6\">{uName}{guestBadge} — {uCount} access(es)</td>");
|
|
sb.AppendLine("</tr>");
|
|
|
|
foreach (var entry in ug)
|
|
{
|
|
var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : "";
|
|
var accessBadge = AccessTypeBadge(entry.AccessType);
|
|
var highIcon = entry.IsHighPrivilege ? " ⚠" : "";
|
|
|
|
sb.AppendLine($"<tr data-group=\"{groupId}\">");
|
|
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.SiteTitle)}</td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectTitle)}</td>");
|
|
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
|
|
sb.AppendLine($" <td>{accessBadge}</td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
|
sb.AppendLine("</tr>");
|
|
}
|
|
}
|
|
|
|
sb.AppendLine("</tbody>");
|
|
sb.AppendLine("</table>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// ── BY-SITE VIEW ───────────────────────────────────────────────────────
|
|
sb.AppendLine("<div id=\"view-site\" class=\"table-wrap hidden\">");
|
|
sb.AppendLine("<table id=\"tbl-site\">");
|
|
sb.AppendLine("<thead><tr>");
|
|
sb.AppendLine(" <th onclick=\"sortTable('site',0)\">User</th>");
|
|
sb.AppendLine(" <th onclick=\"sortTable('site',1)\">Object Type</th>");
|
|
sb.AppendLine(" <th onclick=\"sortTable('site',2)\">Object</th>");
|
|
sb.AppendLine(" <th onclick=\"sortTable('site',3)\">Permission Level</th>");
|
|
sb.AppendLine(" <th onclick=\"sortTable('site',4)\">Access Type</th>");
|
|
sb.AppendLine(" <th onclick=\"sortTable('site',5)\">Granted Through</th>");
|
|
sb.AppendLine("</tr></thead>");
|
|
sb.AppendLine("<tbody id=\"tbody-site\">");
|
|
|
|
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($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
|
|
sb.AppendLine($" <td colspan=\"6\">{siteTitle} — {sCount} access(es)</td>");
|
|
sb.AppendLine("</tr>");
|
|
|
|
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 ? " <span class=\"guest-badge\">Guest</span>" : "";
|
|
|
|
sb.AppendLine($"<tr data-group=\"{groupId}\">");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectType)}</td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.ObjectTitle)}</td>");
|
|
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
|
|
sb.AppendLine($" <td>{accessBadge}</td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
|
sb.AppendLine("</tr>");
|
|
}
|
|
}
|
|
|
|
sb.AppendLine("</tbody>");
|
|
sb.AppendLine("</table>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// ── INLINE JS ─────────────────────────────────────────────────────────
|
|
sb.AppendLine("<script>");
|
|
sb.AppendLine(@"
|
|
var _currentView = 'user';
|
|
var _sortState = {};
|
|
|
|
function toggleView(view) {
|
|
_currentView = view;
|
|
document.getElementById('view-user').classList.toggle('hidden', view !== 'user');
|
|
document.getElementById('view-site').classList.toggle('hidden', view !== 'site');
|
|
document.getElementById('btn-user').classList.toggle('active', view === 'user');
|
|
document.getElementById('btn-site').classList.toggle('active', view === 'site');
|
|
// Re-apply filter to new view
|
|
filterTable();
|
|
}
|
|
|
|
function filterTable() {
|
|
var input = document.getElementById('filter').value.toLowerCase();
|
|
var tbodyId = _currentView === 'user' ? 'tbody-user' : 'tbody-site';
|
|
var tbody = document.getElementById(tbodyId);
|
|
var rows = tbody.querySelectorAll('tr[data-group]');
|
|
var groupsWithVisible = {};
|
|
|
|
rows.forEach(function(row) {
|
|
var matches = row.textContent.toLowerCase().indexOf(input) > -1;
|
|
row.style.display = matches ? '' : 'none';
|
|
if (matches) {
|
|
groupsWithVisible[row.getAttribute('data-group')] = true;
|
|
}
|
|
});
|
|
|
|
// Show/hide group headers based on whether they have visible children
|
|
var headers = tbody.querySelectorAll('tr.group-header');
|
|
headers.forEach(function(hdr) {
|
|
// find next sibling rows until next header
|
|
var next = hdr.nextElementSibling;
|
|
var groupId = null;
|
|
while (next && next.getAttribute('data-group')) {
|
|
groupId = next.getAttribute('data-group');
|
|
break;
|
|
}
|
|
if (groupId) {
|
|
hdr.style.display = groupsWithVisible[groupId] ? '' : 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
function toggleGroup(id) {
|
|
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
|
|
var isHidden = rows.length > 0 && rows[0].style.display === 'none';
|
|
rows.forEach(function(r) { r.style.display = isHidden ? '' : 'none'; });
|
|
}
|
|
|
|
function sortTable(view, col) {
|
|
var tbodyId = view === 'user' ? 'tbody-user' : 'tbody-site';
|
|
var tbody = document.getElementById(tbodyId);
|
|
var key = view + '_' + col;
|
|
var asc = _sortState[key] !== true;
|
|
_sortState[key] = asc;
|
|
|
|
// Sort within each group separately
|
|
var groupHeaders = Array.from(tbody.querySelectorAll('tr.group-header'));
|
|
groupHeaders.forEach(function(hdr) {
|
|
var groupId = null;
|
|
var next = hdr.nextElementSibling;
|
|
while (next && next.getAttribute('data-group')) {
|
|
groupId = next.getAttribute('data-group');
|
|
break;
|
|
}
|
|
if (!groupId) return;
|
|
var groupRows = Array.from(tbody.querySelectorAll('tr[data-group=""' + groupId + '""]'));
|
|
groupRows.sort(function(a, b) {
|
|
var aText = (a.cells[col] ? a.cells[col].textContent : '').trim().toLowerCase();
|
|
var bText = (b.cells[col] ? b.cells[col].textContent : '').trim().toLowerCase();
|
|
return asc ? aText.localeCompare(bText) : bText.localeCompare(aText);
|
|
});
|
|
// Re-insert sorted rows after the group header
|
|
var insertAfter = hdr;
|
|
groupRows.forEach(function(row) {
|
|
tbody.insertBefore(row, insertAfter.nextSibling);
|
|
insertAfter = row;
|
|
});
|
|
});
|
|
}
|
|
");
|
|
sb.AppendLine("</script>");
|
|
sb.AppendLine("</body>");
|
|
sb.AppendLine("</html>");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writes the HTML report to the specified file path using UTF-8 without BOM.
|
|
/// </summary>
|
|
public async Task WriteAsync(IReadOnlyList<UserAccessEntry> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private string BuildConsolidatedHtml(
|
|
IReadOnlyList<ConsolidatedPermissionEntry> consolidated,
|
|
IReadOnlyList<UserAccessEntry> 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("<!DOCTYPE html>");
|
|
sb.AppendLine("<html lang=\"en\">");
|
|
sb.AppendLine("<head>");
|
|
sb.AppendLine("<meta charset=\"UTF-8\">");
|
|
sb.AppendLine("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">");
|
|
sb.AppendLine("<title>User Access Audit Report</title>");
|
|
sb.AppendLine("<style>");
|
|
sb.AppendLine(@"
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
|
|
h1 { padding: 20px 24px 10px; font-size: 1.5rem; color: #1a1a2e; }
|
|
.stats { display: flex; gap: 16px; padding: 0 24px 16px; flex-wrap: wrap; }
|
|
.stat-card { background: #fff; border-radius: 8px; padding: 14px 20px; min-width: 160px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
|
.stat-card .value { font-size: 2rem; font-weight: 700; color: #1a1a2e; }
|
|
.stat-card .label { font-size: .8rem; color: #666; margin-top: 2px; }
|
|
/* View toggle */
|
|
.view-toggle { display: flex; gap: 8px; padding: 0 24px 12px; }
|
|
.view-toggle button { padding: 7px 18px; border: 1px solid #ccc; border-radius: 6px; background: #fff; cursor: pointer; font-size: .9rem; }
|
|
.view-toggle button.active { background: #1a1a2e; color: #fff; border-color: #1a1a2e; }
|
|
/* Filter */
|
|
.filter-wrap { padding: 0 24px 12px; }
|
|
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
|
|
/* Per-user summary cards */
|
|
.user-summary { padding: 0 24px 16px; display: flex; gap: 12px; flex-wrap: wrap; }
|
|
.user-card { background: #fff; border-radius: 8px; padding: 12px 16px; min-width: 200px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
|
.user-card .user-name { font-weight: 600; font-size: .95rem; color: #1a1a2e; margin-bottom: 4px; }
|
|
.user-card .user-stats { font-size: .8rem; color: #555; }
|
|
.user-card.has-high-priv { border-left: 4px solid #dc2626; }
|
|
/* Table wrap */
|
|
.table-wrap { overflow-x: auto; padding: 0 24px 32px; }
|
|
table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
|
th { background: #1a1a2e; color: #fff; padding: 10px 14px; text-align: left; font-size: .85rem; white-space: nowrap; cursor: pointer; user-select: none; }
|
|
th:hover { background: #2d2d4e; }
|
|
td { padding: 9px 14px; border-bottom: 1px solid #eee; vertical-align: top; font-size: .875rem; }
|
|
tr:last-child td { border-bottom: none; }
|
|
tr:hover td { background: #fafafa; }
|
|
/* Group headers */
|
|
.group-header { cursor: pointer; background: #f0f0f0; padding: 10px 14px; font-weight: 600; font-size: .875rem; }
|
|
.group-header:hover { background: #e8e8e8; }
|
|
.group-header td { background: inherit !important; font-weight: 600; }
|
|
/* Badges */
|
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
|
|
/* Access type badges */
|
|
.access-direct { background: #dbeafe; color: #1e40af; }
|
|
.access-group { background: #dcfce7; color: #166534; }
|
|
.access-inherited { background: #f3f4f6; color: #374151; }
|
|
/* High privilege */
|
|
.high-priv { font-weight: 700; }
|
|
/* Guest badge */
|
|
.guest-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: .75rem; font-weight: 600; background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; margin-left: 4px; }
|
|
/* User pills */
|
|
.user-pill { display: inline-block; background: #e0e7ff; color: #3730a3; border-radius: 12px; padding: 2px 10px; font-size: .75rem; margin: 2px 3px 2px 0; white-space: nowrap; }
|
|
a { color: #2563eb; text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
.hidden { display: none; }
|
|
");
|
|
sb.AppendLine("</style>");
|
|
sb.AppendLine("</head>");
|
|
|
|
// ── BODY ───────────────────────────────────────────────────────────────
|
|
sb.AppendLine("<body>");
|
|
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
|
|
sb.AppendLine("<h1>User Access Audit Report</h1>");
|
|
|
|
// Stats cards
|
|
sb.AppendLine("<div class=\"stats\">");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalAccesses}</div><div class=\"label\">Total Accesses</div></div>");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{usersAudited}</div><div class=\"label\">Users Audited</div></div>");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{sitesScanned}</div><div class=\"label\">Sites Scanned</div></div>");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{highPrivCount}</div><div class=\"label\">High Privilege</div></div>");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{externalCount}</div><div class=\"label\">External Users</div></div>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// Per-user summary cards (from original flat entries)
|
|
sb.AppendLine("<div class=\"user-summary\">");
|
|
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($" <div class=\"{cardClass}\">");
|
|
sb.AppendLine($" <div class=\"user-name\">{uName}{(uIsExt ? " <span class=\"guest-badge\">Guest</span>" : "")}</div>");
|
|
sb.AppendLine($" <div class=\"user-stats\">{uLogin}</div>");
|
|
sb.AppendLine($" <div class=\"user-stats\">{uTotal} accesses • {uSites} site(s){(uHighPriv > 0 ? $" • <span style=\"color:#dc2626\">{uHighPriv} high-priv</span>" : "")}</div>");
|
|
sb.AppendLine(" </div>");
|
|
}
|
|
sb.AppendLine("</div>");
|
|
|
|
// View toggle — only By User (By Site is suppressed for consolidated view)
|
|
sb.AppendLine("<div class=\"view-toggle\">");
|
|
sb.AppendLine(" <button id=\"btn-user\" class=\"active\">By User</button>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// Filter input
|
|
sb.AppendLine("<div class=\"filter-wrap\">");
|
|
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter results...\" oninput=\"filterTable()\" />");
|
|
sb.AppendLine("</div>");
|
|
|
|
// ── CONSOLIDATED BY-USER TABLE ────────────────────────────────────────
|
|
sb.AppendLine("<div id=\"view-user\" class=\"table-wrap\">");
|
|
sb.AppendLine("<table id=\"tbl-user\">");
|
|
sb.AppendLine("<thead><tr>");
|
|
sb.AppendLine(" <th>User</th>");
|
|
sb.AppendLine(" <th>Permission Level</th>");
|
|
sb.AppendLine(" <th>Access Type</th>");
|
|
sb.AppendLine(" <th>Granted Through</th>");
|
|
sb.AppendLine(" <th>Sites</th>");
|
|
sb.AppendLine("</tr></thead>");
|
|
sb.AppendLine("<tbody id=\"tbody-user\">");
|
|
|
|
// 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 ? " <span class=\"guest-badge\">Guest</span>" : "";
|
|
|
|
sb.AppendLine($"<tr class=\"group-header\" onclick=\"toggleGroup('{groupId}')\">");
|
|
sb.AppendLine($" <td colspan=\"5\">{cuName}{guestBadge} — {cuCount} permission(s)</td>");
|
|
sb.AppendLine("</tr>");
|
|
|
|
foreach (var entry in cug)
|
|
{
|
|
var rowClass = entry.IsHighPrivilege ? " class=\"high-priv\"" : "";
|
|
var accessBadge = AccessTypeBadge(entry.AccessType);
|
|
var highIcon = entry.IsHighPrivilege ? " ⚠" : "";
|
|
|
|
sb.AppendLine($"<tr data-group=\"{groupId}\">");
|
|
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
|
|
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
|
|
sb.AppendLine($" <td>{accessBadge}</td>");
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
|
|
|
|
if (entry.LocationCount == 1)
|
|
{
|
|
// Single location — inline site title, no badge
|
|
sb.AppendLine($" <td>{HtmlEncode(entry.Locations[0].SiteTitle)}</td>");
|
|
sb.AppendLine("</tr>");
|
|
}
|
|
else
|
|
{
|
|
// Multiple locations — expandable badge
|
|
var currentLocId = $"loc{locIdx++}";
|
|
sb.AppendLine($" <td><span class=\"badge\" onclick=\"toggleGroup('{currentLocId}')\" style=\"cursor:pointer\">{entry.LocationCount} sites</span></td>");
|
|
sb.AppendLine("</tr>");
|
|
|
|
// Hidden sub-rows — one per location
|
|
foreach (var loc in entry.Locations)
|
|
{
|
|
sb.AppendLine($"<tr data-group=\"{currentLocId}\" style=\"display:none\">");
|
|
sb.AppendLine($" <td colspan=\"5\" style=\"padding-left:2em\">");
|
|
sb.AppendLine($" <a href=\"{HtmlEncode(loc.SiteUrl)}\">{HtmlEncode(loc.SiteTitle)}</a>");
|
|
sb.AppendLine(" </td>");
|
|
sb.AppendLine("</tr>");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sb.AppendLine("</tbody>");
|
|
sb.AppendLine("</table>");
|
|
sb.AppendLine("</div>");
|
|
|
|
// ── INLINE JS ─────────────────────────────────────────────────────────
|
|
sb.AppendLine("<script>");
|
|
sb.AppendLine(@"
|
|
var _sortState = {};
|
|
|
|
function filterTable() {
|
|
var input = document.getElementById('filter').value.toLowerCase();
|
|
var tbody = document.getElementById('tbody-user');
|
|
var rows = tbody.querySelectorAll('tr[data-group]');
|
|
var groupsWithVisible = {};
|
|
|
|
rows.forEach(function(row) {
|
|
// Skip hidden sub-rows that belong to location groups (loc...)
|
|
if (row.getAttribute('data-group').indexOf('loc') === 0) return;
|
|
var matches = row.textContent.toLowerCase().indexOf(input) > -1;
|
|
row.style.display = matches ? '' : 'none';
|
|
if (matches) {
|
|
groupsWithVisible[row.getAttribute('data-group')] = true;
|
|
}
|
|
});
|
|
|
|
// Show/hide group headers based on whether they have visible children
|
|
var headers = tbody.querySelectorAll('tr.group-header');
|
|
headers.forEach(function(hdr) {
|
|
var next = hdr.nextElementSibling;
|
|
var groupId = null;
|
|
while (next && next.getAttribute('data-group')) {
|
|
groupId = next.getAttribute('data-group');
|
|
break;
|
|
}
|
|
if (groupId) {
|
|
hdr.style.display = groupsWithVisible[groupId] ? '' : 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
function toggleGroup(id) {
|
|
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
|
|
var isHidden = rows.length > 0 && rows[0].style.display === 'none';
|
|
rows.forEach(function(r) { r.style.display = isHidden ? '' : 'none'; });
|
|
}
|
|
");
|
|
sb.AppendLine("</script>");
|
|
sb.AppendLine("</body>");
|
|
sb.AppendLine("</html>");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>Returns a colored badge span for the given access type.</summary>
|
|
private static string AccessTypeBadge(AccessType accessType) => accessType switch
|
|
{
|
|
AccessType.Direct => "<span class=\"badge access-direct\">Direct</span>",
|
|
AccessType.Group => "<span class=\"badge access-group\">Group</span>",
|
|
AccessType.Inherited => "<span class=\"badge access-inherited\">Inherited</span>",
|
|
_ => $"<span class=\"badge\">{HtmlEncode(accessType.ToString())}</span>"
|
|
};
|
|
|
|
/// <summary>Minimal HTML encoding for text content and attribute values.</summary>
|
|
private static string HtmlEncode(string value)
|
|
{
|
|
if (string.IsNullOrEmpty(value)) return string.Empty;
|
|
return value
|
|
.Replace("&", "&")
|
|
.Replace("<", "<")
|
|
.Replace(">", ">")
|
|
.Replace("\"", """)
|
|
.Replace("'", "'");
|
|
}
|
|
}
|