12dd1de9f2
- Add theme system (Dark/Light palettes, ModernTheme, ThemeManager) - Add InputDialog, Spinner common view - Add DuplicatesCsvExportService - Refresh views, dialogs, and view models across tabs - Update localization strings (en/fr) - Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
208 lines
9.9 KiB
C#
208 lines
9.9 KiB
C#
using System.Text;
|
|
using SharepointToolbox.Core.Models;
|
|
using SharepointToolbox.Localization;
|
|
|
|
namespace SharepointToolbox.Services.Export;
|
|
|
|
/// <summary>
|
|
/// Shared HTML-rendering fragments for the permission exports (standard and
|
|
/// simplified). Extracted so the two <see cref="HtmlExportService"/> variants
|
|
/// share the document shell, stats cards, filter input, user-pill logic, and
|
|
/// inline script — leaving each caller only its own table headers and row
|
|
/// cells to render.
|
|
/// </summary>
|
|
internal static class PermissionHtmlFragments
|
|
{
|
|
internal const string BaseCss = @"
|
|
* { 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; }
|
|
.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; }
|
|
.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; }
|
|
";
|
|
|
|
internal const string RiskCardsCss = @"
|
|
.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; }
|
|
.risk-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .75rem; font-weight: 600; border: 1px solid; }
|
|
";
|
|
|
|
internal const string InlineJs = @"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';
|
|
});
|
|
}
|
|
document.addEventListener('click', function(ev) {
|
|
var trigger = ev.target.closest('.group-expandable');
|
|
if (!trigger) return;
|
|
var id = trigger.getAttribute('data-group-target');
|
|
if (!id) return;
|
|
document.querySelectorAll('#permTable tbody tr').forEach(function(r) {
|
|
if (r.getAttribute('data-group') === id) {
|
|
r.style.display = r.style.display === 'none' ? '' : 'none';
|
|
}
|
|
});
|
|
});";
|
|
|
|
/// <summary>
|
|
/// Appends the shared HTML head (doctype, meta, inline CSS, title) to
|
|
/// <paramref name="sb"/>. Pass <paramref name="includeRiskCss"/> when the
|
|
/// caller renders risk cards/badges (simplified report only).
|
|
/// </summary>
|
|
internal static void AppendHead(StringBuilder sb, string title, bool includeRiskCss)
|
|
{
|
|
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>{title}</title>");
|
|
sb.AppendLine("<style>");
|
|
sb.AppendLine(BaseCss);
|
|
if (includeRiskCss)
|
|
sb.AppendLine(RiskCardsCss);
|
|
sb.AppendLine("</style>");
|
|
sb.AppendLine("</head>");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Appends the three stat cards (total entries, unique permission sets,
|
|
/// distinct users/groups) inside a single <c>.stats</c> row.
|
|
/// </summary>
|
|
internal static void AppendStatsCards(StringBuilder sb, int totalEntries, int uniquePermSets, int distinctUsers)
|
|
{
|
|
var T = TranslationSource.Instance;
|
|
sb.AppendLine("<div class=\"stats\">");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{totalEntries}</div><div class=\"label\">{T["report.stat.total_entries"]}</div></div>");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{uniquePermSets}</div><div class=\"label\">{T["report.stat.unique_permission_sets"]}</div></div>");
|
|
sb.AppendLine($" <div class=\"stat-card\"><div class=\"value\">{distinctUsers}</div><div class=\"label\">{T["report.stat.distinct_users_groups"]}</div></div>");
|
|
sb.AppendLine("</div>");
|
|
}
|
|
|
|
/// <summary>Appends the live-filter input bound to <c>#permTable</c>.</summary>
|
|
internal static void AppendFilterInput(StringBuilder sb)
|
|
{
|
|
var T = TranslationSource.Instance;
|
|
sb.AppendLine("<div class=\"filter-wrap\">");
|
|
sb.AppendLine($" <input type=\"text\" id=\"filter\" placeholder=\"{T["report.filter.placeholder_permissions"]}\" onkeyup=\"filterTable()\" />");
|
|
sb.AppendLine("</div>");
|
|
}
|
|
|
|
/// <summary>Appends the inline <script> that powers filter and group toggle.</summary>
|
|
internal static void AppendInlineJs(StringBuilder sb)
|
|
{
|
|
sb.AppendLine("<script>");
|
|
sb.AppendLine(InlineJs);
|
|
sb.AppendLine("</script>");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders the user-pill cell content plus any group-member sub-rows for a
|
|
/// single permission entry. Callers pass their row colspan so sub-rows
|
|
/// span the full table; <paramref name="grpMemIdx"/> must be mutated
|
|
/// across rows so sub-row IDs stay unique.
|
|
/// </summary>
|
|
internal static (string Pills, string MemberSubRows) BuildUserPillsCell(
|
|
string userLogins,
|
|
string userNames,
|
|
string? principalType,
|
|
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers,
|
|
int colSpan,
|
|
ref int grpMemIdx)
|
|
{
|
|
var T = TranslationSource.Instance;
|
|
var logins = userLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
|
var names = userNames.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
|
var pills = new StringBuilder();
|
|
var subRows = 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 isExpandable = principalType == "SharePointGroup"
|
|
&& groupMembers != null
|
|
&& groupMembers.TryGetValue(name, out _);
|
|
|
|
if (isExpandable && groupMembers != null && groupMembers.TryGetValue(name, out var resolved))
|
|
{
|
|
var grpId = $"grpmem{grpMemIdx}";
|
|
pills.Append($"<span class=\"user-pill group-expandable\" data-group-target=\"{HtmlEncode(grpId)}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)} ▼</span>");
|
|
|
|
string memberContent;
|
|
if (resolved.Count > 0)
|
|
{
|
|
var parts = resolved.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>");
|
|
memberContent = string.Join(" • ", parts);
|
|
}
|
|
else
|
|
{
|
|
memberContent = $"<em style=\"color:#888\">{T["report.text.members_unavailable"]}</em>";
|
|
}
|
|
subRows.AppendLine($"<tr data-group=\"{HtmlEncode(grpId)}\" style=\"display:none\"><td colspan=\"{colSpan}\" style=\"padding-left:2em;font-size:.8rem;color:#555\">{memberContent}</td></tr>");
|
|
grpMemIdx++;
|
|
}
|
|
else
|
|
{
|
|
var cls = isExt ? "user-pill external-user" : "user-pill";
|
|
pills.Append($"<span class=\"{cls}\" data-email=\"{HtmlEncode(login)}\">{HtmlEncode(name)}</span>");
|
|
}
|
|
}
|
|
|
|
return (pills.ToString(), subRows.ToString());
|
|
}
|
|
|
|
/// <summary>Returns the CSS class for the object-type badge.</summary>
|
|
internal 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>
|
|
internal static string HtmlEncode(string value)
|
|
{
|
|
if (string.IsNullOrEmpty(value)) return string.Empty;
|
|
return value
|
|
.Replace("&", "&")
|
|
.Replace("<", "<")
|
|
.Replace(">", ">")
|
|
.Replace("\"", """)
|
|
.Replace("'", "'");
|
|
}
|
|
}
|