Files
Dev 07ed6e2515 feat(17-02): extend HtmlExportService with expandable group pills and toggleGroup JS
- Add optional groupMembers parameter to both BuildHtml overloads and WriteAsync methods
- SharePoint group pills render as expandable with onclick toggleGroup when groupMembers provided
- Hidden member sub-rows injected after parent row with resolved member names
- Empty member list renders 'members unavailable' fallback label
- toggleGroup JS function added to inline script block in both overloads
- filterTable updated to skip data-group sub-rows
- CSS for .group-expandable added to both overloads
- Backward compatibility: null groupMembers produces identical output to pre-Phase 17
2026-04-09 13:09:38 +02:00

434 lines
23 KiB
C#

using System.IO;
using System.Text;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services.Export;
/// <summary>
/// Exports permission entries to a self-contained interactive HTML report.
/// Ports PowerShell Export-PermissionsToHTML functionality.
/// No external CSS/JS dependencies — everything is inline.
/// </summary>
public class HtmlExportService
{
/// <summary>
/// Builds a self-contained HTML string from the supplied permission entries.
/// Includes inline CSS, inline JS filter, stats cards, type badges, unique/inherited badges, and user pills.
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable.
/// </summary>
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
// Compute stats
var totalEntries = entries.Count;
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
var distinctUsers = entries
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct()
.Count();
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>SharePoint Permissions 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; }
.filter-wrap { padding: 0 24px 12px; }
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
.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; }
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; }
/* Type badges */
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
.badge.site-coll { background: #dbeafe; color: #1e40af; }
.badge.site { background: #dcfce7; color: #166534; }
.badge.list { background: #fef9c3; color: #854d0e; }
.badge.folder { background: #f3f4f6; color: #374151; }
/* Unique/Inherited badges */
.badge.unique { background: #dcfce7; color: #166534; }
.badge.inherited { background: #f3f4f6; color: #374151; }
/* 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; }
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
.group-expandable { cursor: pointer; }
.group-expandable:hover { opacity: 0.8; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
// ── BODY ───────────────────────────────────────────────────────────────
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>SharePoint Permissions Report</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">Total Entries</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">Unique Permission Sets</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">Distinct Users/Groups</div></div>");
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter permissions...\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>Object</th><th>Title</th><th>URL</th><th>Unique</th><th>Users/Groups</th><th>Permission Level</th><th>Granted Through</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");
int grpMemIdx = 0;
foreach (var entry in entries)
{
var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited";
// Build user pills: zip UserLogins and Users (both semicolon-delimited)
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
var pillsBuilder = new StringBuilder();
var memberSubRows = new StringBuilder();
for (int i = 0; i < logins.Length; i++)
{
var login = logins[i].Trim();
var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
bool isExpandableGroup = entry.PrincipalType == "SharePointGroup"
&& groupMembers != null
&& groupMembers.TryGetValue(name, out var members);
if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers))
{
var grpId = $"grpmem{grpMemIdx}";
pillsBuilder.Append($"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{grpId}')\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} &#9660;</span>");
string memberContent;
if (resolvedMembers.Count > 0)
{
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} &lt;{HtmlEncode(m.Login)}&gt;");
memberContent = string.Join(" &bull; ", memberParts);
}
else
{
memberContent = "<em style=\"color:#888\">members unavailable</em>";
}
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"7\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
}
else
{
var pillCss = isExt ? "user-pill external-user" : "user-pill";
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
}
}
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">Link</a></td>");
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
sb.AppendLine($" <td>{pillsBuilder}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine("</tr>");
if (memberSubRows.Length > 0)
sb.Append(memberSubRows);
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
// Inline JS
sb.AppendLine("<script>");
sb.AppendLine(@"function filterTable() {
var input = document.getElementById('filter').value.toLowerCase();
var rows = document.querySelectorAll('#permTable tbody tr');
rows.forEach(function(row) {
if (row.hasAttribute('data-group')) return;
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
});
}
function toggleGroup(id) {
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
}");
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<PermissionEntry> entries, string filePath, CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var html = BuildHtml(entries, branding, groupMembers);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
}
/// <summary>Returns inline CSS background and text color for a risk level.</summary>
private static (string bg, string text, string border) RiskLevelColors(RiskLevel level) => level switch
{
RiskLevel.High => ("#FEE2E2", "#991B1B", "#FECACA"),
RiskLevel.Medium => ("#FEF3C7", "#92400E", "#FDE68A"),
RiskLevel.Low => ("#D1FAE5", "#065F46", "#A7F3D0"),
RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"),
_ => ("#F3F4F6", "#374151", "#E5E7EB")
};
/// <summary>
/// Builds a self-contained HTML string from simplified permission entries.
/// Includes risk-level summary cards, color-coded rows, and simplified labels column.
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable.
/// </summary>
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var summaries = PermissionSummaryBuilder.Build(entries);
var totalEntries = entries.Count;
var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count();
var distinctUsers = entries
.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))
.Select(u => u.Trim())
.Where(u => u.Length > 0)
.Distinct()
.Count();
var sb = new StringBuilder();
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>SharePoint Permissions Report (Simplified)</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; }
.risk-cards { display: flex; gap: 12px; padding: 0 24px 16px; flex-wrap: wrap; }
.risk-card { border-radius: 8px; padding: 12px 18px; min-width: 140px; border: 1px solid; }
.risk-card .count { font-size: 1.5rem; font-weight: 700; }
.risk-card .rlabel { font-size: .8rem; margin-top: 2px; }
.risk-card .users { font-size: .7rem; margin-top: 2px; opacity: 0.8; }
.filter-wrap { padding: 0 24px 12px; }
#filter { width: 320px; padding: 8px 12px; border: 1px solid #ccc; border-radius: 6px; font-size: .95rem; }
.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; }
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: rgba(0,0,0,.03); }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; white-space: nowrap; }
.badge.site-coll { background: #dbeafe; color: #1e40af; }
.badge.site { background: #dcfce7; color: #166534; }
.badge.list { background: #fef9c3; color: #854d0e; }
.badge.folder { background: #f3f4f6; color: #374151; }
.badge.unique { background: #dcfce7; color: #166534; }
.badge.inherited { background: #f3f4f6; color: #374151; }
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
.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; }
.user-pill.external-user { background: #fff7ed; color: #9a3412; border: 1px solid #fed7aa; }
.group-expandable { cursor: pointer; }
.group-expandable:hover { opacity: 0.8; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
");
sb.AppendLine("</style>");
sb.AppendLine("</head>");
sb.AppendLine("<body>");
sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding));
sb.AppendLine("<h1>SharePoint Permissions Report (Simplified)</h1>");
// Stats cards
sb.AppendLine("<div class=\"stats\">");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">Total Entries</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">Unique Permission Sets</div></div>");
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">Distinct Users/Groups</div></div>");
sb.AppendLine("</div>");
// Risk-level summary cards
sb.AppendLine("<div class=\"risk-cards\">");
foreach (var summary in summaries)
{
var (bg, text, border) = RiskLevelColors(summary.RiskLevel);
sb.AppendLine($" <div class=\"risk-card\" style=\"background:{bg};color:{text};border-color:{border}\">");
sb.AppendLine($" <div class=\"count\">{summary.Count}</div>");
sb.AppendLine($" <div class=\"rlabel\">{HtmlEncode(summary.Label)}</div>");
sb.AppendLine($" <div class=\"users\">{summary.DistinctUsers} user(s)</div>");
sb.AppendLine(" </div>");
}
sb.AppendLine("</div>");
// Filter input
sb.AppendLine("<div class=\"filter-wrap\">");
sb.AppendLine(" <input type=\"text\" id=\"filter\" placeholder=\"Filter permissions...\" onkeyup=\"filterTable()\" />");
sb.AppendLine("</div>");
// Table with simplified columns
sb.AppendLine("<div class=\"table-wrap\">");
sb.AppendLine("<table id=\"permTable\">");
sb.AppendLine("<thead><tr>");
sb.AppendLine(" <th>Object</th><th>Title</th><th>URL</th><th>Unique</th><th>Users/Groups</th><th>Permission Level</th><th>Simplified</th><th>Risk</th><th>Granted Through</th>");
sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>");
int grpMemIdx = 0;
foreach (var entry in entries)
{
var typeCss = ObjectTypeCss(entry.ObjectType);
var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited";
var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited";
var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel);
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Inner.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
var pillsBuilder = new StringBuilder();
var memberSubRows = new StringBuilder();
for (int i = 0; i < logins.Length; i++)
{
var login = logins[i].Trim();
var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
bool isExpandableGroup = entry.Inner.PrincipalType == "SharePointGroup"
&& groupMembers != null
&& groupMembers.TryGetValue(name, out _);
if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers))
{
var grpId = $"grpmem{grpMemIdx}";
pillsBuilder.Append($"<span class=\"user-pill group-expandable\" onclick=\"toggleGroup('{grpId}')\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} &#9660;</span>");
string memberContent;
if (resolvedMembers.Count > 0)
{
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} &lt;{HtmlEncode(m.Login)}&gt;");
memberContent = string.Join(" &bull; ", memberParts);
}
else
{
memberContent = "<em style=\"color:#888\">members unavailable</em>";
}
memberSubRows.AppendLine($"<tr data-group=\"{grpId}\" style=\"display:none\"><td colspan=\"9\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
grpMemIdx++;
}
else
{
var pillCss = isExt ? "user-pill external-user" : "user-pill";
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
}
}
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.Title)}</td>");
sb.AppendLine($" <td><a href=\"{HtmlEncode(entry.Url)}\" target=\"_blank\">Link</a></td>");
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
sb.AppendLine($" <td>{pillsBuilder}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.SimplifiedLabels)}</td>");
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine("</tr>");
if (memberSubRows.Length > 0)
sb.Append(memberSubRows);
}
sb.AppendLine("</tbody>");
sb.AppendLine("</table>");
sb.AppendLine("</div>");
sb.AppendLine("<script>");
sb.AppendLine(@"function filterTable() {
var input = document.getElementById('filter').value.toLowerCase();
var rows = document.querySelectorAll('#permTable tbody tr');
rows.forEach(function(row) {
if (row.hasAttribute('data-group')) return;
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none';
});
}
function toggleGroup(id) {
var rows = document.querySelectorAll('tr[data-group=""' + id + '""]');
rows.forEach(function(r) { r.style.display = r.style.display === 'none' ? '' : 'none'; });
}");
sb.AppendLine("</script>");
sb.AppendLine("</body>");
sb.AppendLine("</html>");
return sb.ToString();
}
/// <summary>
/// Writes the simplified HTML report to the specified file path.
/// </summary>
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{
var html = BuildHtml(entries, branding, groupMembers);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
}
/// <summary>Returns the CSS class for the object-type badge.</summary>
private static string ObjectTypeCss(string t) => t switch
{
"Site Collection" => "badge site-coll",
"Site" => "badge site",
"List" => "badge list",
"Folder" => "badge folder",
_ => "badge"
};
/// <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("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
}