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
This commit is contained in:
Dev
2026-04-09 13:09:38 +02:00
parent c35ee76987
commit 07ed6e2515

View File

@@ -14,8 +14,10 @@ public class HtmlExportService
/// <summary> /// <summary>
/// Builds a self-contained HTML string from the supplied permission entries. /// 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. /// 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> /// </summary>
public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null) public string BuildHtml(IReadOnlyList<PermissionEntry> entries, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{ {
// Compute stats // Compute stats
var totalEntries = entries.Count; var totalEntries = entries.Count;
@@ -65,6 +67,8 @@ tr:hover td { background: #fafafa; }
/* User pills */ /* 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 { 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; } .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 { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
"); ");
@@ -96,6 +100,7 @@ a:hover { text-decoration: underline; }
sb.AppendLine("</tr></thead>"); sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>"); sb.AppendLine("<tbody>");
int grpMemIdx = 0;
foreach (var entry in entries) foreach (var entry in entries)
{ {
var typeCss = ObjectTypeCss(entry.ObjectType); var typeCss = ObjectTypeCss(entry.ObjectType);
@@ -106,14 +111,42 @@ a:hover { text-decoration: underline; }
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries); var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries); var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
var pillsBuilder = new StringBuilder(); var pillsBuilder = new StringBuilder();
var memberSubRows = new StringBuilder();
for (int i = 0; i < logins.Length; i++) for (int i = 0; i < logins.Length; i++)
{ {
var login = logins[i].Trim(); var login = logins[i].Trim();
var name = i < names.Length ? names[i].Trim() : login; var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase); 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"; var pillCss = isExt ? "user-pill external-user" : "user-pill";
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>"); pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
} }
}
sb.AppendLine("<tr>"); sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>"); sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
@@ -124,6 +157,8 @@ a:hover { text-decoration: underline; }
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>"); sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>"); sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine("</tr>"); sb.AppendLine("</tr>");
if (memberSubRows.Length > 0)
sb.Append(memberSubRows);
} }
sb.AppendLine("</tbody>"); sb.AppendLine("</tbody>");
@@ -136,8 +171,13 @@ a:hover { text-decoration: underline; }
var input = document.getElementById('filter').value.toLowerCase(); var input = document.getElementById('filter').value.toLowerCase();
var rows = document.querySelectorAll('#permTable tbody tr'); var rows = document.querySelectorAll('#permTable tbody tr');
rows.forEach(function(row) { rows.forEach(function(row) {
if (row.hasAttribute('data-group')) return;
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none'; 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("</script>");
sb.AppendLine("</body>"); sb.AppendLine("</body>");
@@ -149,9 +189,11 @@ a:hover { text-decoration: underline; }
/// <summary> /// <summary>
/// Writes the HTML report to the specified file path using UTF-8 without BOM. /// Writes the HTML report to the specified file path using UTF-8 without BOM.
/// </summary> /// </summary>
public async Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct, ReportBranding? branding = null) 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); var html = BuildHtml(entries, branding, groupMembers);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct); await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
} }
@@ -168,8 +210,10 @@ a:hover { text-decoration: underline; }
/// <summary> /// <summary>
/// Builds a self-contained HTML string from simplified permission entries. /// Builds a self-contained HTML string from simplified permission entries.
/// Includes risk-level summary cards, color-coded rows, and simplified labels column. /// Includes risk-level summary cards, color-coded rows, and simplified labels column.
/// When <paramref name="groupMembers"/> is provided, SharePoint group pills become expandable.
/// </summary> /// </summary>
public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null) public string BuildHtml(IReadOnlyList<SimplifiedPermissionEntry> entries, ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
{ {
var summaries = PermissionSummaryBuilder.Build(entries); var summaries = PermissionSummaryBuilder.Build(entries);
@@ -222,6 +266,8 @@ a:hover { text-decoration: underline; }
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; } .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 { 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; } .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 { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
"); ");
@@ -265,6 +311,7 @@ a:hover { text-decoration: underline; }
sb.AppendLine("</tr></thead>"); sb.AppendLine("</tr></thead>");
sb.AppendLine("<tbody>"); sb.AppendLine("<tbody>");
int grpMemIdx = 0;
foreach (var entry in entries) foreach (var entry in entries)
{ {
var typeCss = ObjectTypeCss(entry.ObjectType); var typeCss = ObjectTypeCss(entry.ObjectType);
@@ -275,14 +322,42 @@ a:hover { text-decoration: underline; }
var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries); var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
var names = entry.Inner.Users.Split(';', StringSplitOptions.RemoveEmptyEntries); var names = entry.Inner.Users.Split(';', StringSplitOptions.RemoveEmptyEntries);
var pillsBuilder = new StringBuilder(); var pillsBuilder = new StringBuilder();
var memberSubRows = new StringBuilder();
for (int i = 0; i < logins.Length; i++) for (int i = 0; i < logins.Length; i++)
{ {
var login = logins[i].Trim(); var login = logins[i].Trim();
var name = i < names.Length ? names[i].Trim() : login; var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase); 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"; var pillCss = isExt ? "user-pill external-user" : "user-pill";
pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>"); pillsBuilder.Append($"<span class=\"{pillCss}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
} }
}
sb.AppendLine("<tr>"); sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>"); sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
@@ -295,6 +370,8 @@ a:hover { text-decoration: underline; }
sb.AppendLine($" <td><span class=\"risk-badge\" style=\"background:{riskBg};color:{riskText};border-color:{riskBorder}\">{HtmlEncode(entry.RiskLevel.ToString())}</span></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($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine("</tr>"); sb.AppendLine("</tr>");
if (memberSubRows.Length > 0)
sb.Append(memberSubRows);
} }
sb.AppendLine("</tbody>"); sb.AppendLine("</tbody>");
@@ -306,8 +383,13 @@ a:hover { text-decoration: underline; }
var input = document.getElementById('filter').value.toLowerCase(); var input = document.getElementById('filter').value.toLowerCase();
var rows = document.querySelectorAll('#permTable tbody tr'); var rows = document.querySelectorAll('#permTable tbody tr');
rows.forEach(function(row) { rows.forEach(function(row) {
if (row.hasAttribute('data-group')) return;
row.style.display = row.textContent.toLowerCase().indexOf(input) > -1 ? '' : 'none'; 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("</script>");
sb.AppendLine("</body>"); sb.AppendLine("</body>");
@@ -319,9 +401,11 @@ a:hover { text-decoration: underline; }
/// <summary> /// <summary>
/// Writes the simplified HTML report to the specified file path. /// Writes the simplified HTML report to the specified file path.
/// </summary> /// </summary>
public async Task WriteAsync(IReadOnlyList<SimplifiedPermissionEntry> entries, string filePath, CancellationToken ct, ReportBranding? branding = null) 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); var html = BuildHtml(entries, branding, groupMembers);
await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct); await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct);
} }