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:
@@ -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)} ▼</span>");
|
||||||
|
|
||||||
|
string memberContent;
|
||||||
|
if (resolvedMembers.Count > 0)
|
||||||
|
{
|
||||||
|
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>");
|
||||||
|
memberContent = string.Join(" • ", 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)} ▼</span>");
|
||||||
|
|
||||||
|
string memberContent;
|
||||||
|
if (resolvedMembers.Count > 0)
|
||||||
|
{
|
||||||
|
var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>");
|
||||||
|
memberContent = string.Join(" • ", 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user