using System.Text; using SharepointToolbox.Core.Models; using SharepointToolbox.Localization; namespace SharepointToolbox.Services.Export; /// /// Shared HTML-rendering fragments for the permission exports (standard and /// simplified). Extracted so the two 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. /// 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'; } }); });"; /// /// Appends the shared HTML head (doctype, meta, inline CSS, title) to /// . Pass when the /// caller renders risk cards/badges (simplified report only). /// internal static void AppendHead(StringBuilder sb, string title, bool includeRiskCss) { sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine($"{title}"); sb.AppendLine(""); sb.AppendLine(""); } /// /// Appends the three stat cards (total entries, unique permission sets, /// distinct users/groups) inside a single .stats row. /// internal static void AppendStatsCards(StringBuilder sb, int totalEntries, int uniquePermSets, int distinctUsers) { var T = TranslationSource.Instance; sb.AppendLine("
"); sb.AppendLine($"
{totalEntries}
{T["report.stat.total_entries"]}
"); sb.AppendLine($"
{uniquePermSets}
{T["report.stat.unique_permission_sets"]}
"); sb.AppendLine($"
{distinctUsers}
{T["report.stat.distinct_users_groups"]}
"); sb.AppendLine("
"); } /// Appends the live-filter input bound to #permTable. internal static void AppendFilterInput(StringBuilder sb) { var T = TranslationSource.Instance; sb.AppendLine("
"); sb.AppendLine($" "); sb.AppendLine("
"); } /// Appends the inline <script> that powers filter and group toggle. internal static void AppendInlineJs(StringBuilder sb) { sb.AppendLine(""); } /// /// 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; must be mutated /// across rows so sub-row IDs stay unique. /// internal static (string Pills, string MemberSubRows) BuildUserPillsCell( string userLogins, string userNames, string? principalType, IReadOnlyDictionary>? 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($"{HtmlEncode(name)} ▼"); string memberContent; if (resolved.Count > 0) { var parts = resolved.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>"); memberContent = string.Join(" • ", parts); } else { memberContent = $"{T["report.text.members_unavailable"]}"; } subRows.AppendLine($"{memberContent}"); grpMemIdx++; } else { var cls = isExt ? "user-pill external-user" : "user-pill"; pills.Append($"{HtmlEncode(name)}"); } } return (pills.ToString(), subRows.ToString()); } /// Returns the CSS class for the object-type badge. internal static string ObjectTypeCss(string t) => t switch { "Site Collection" => "badge site-coll", "Site" => "badge site", "List" => "badge list", "Folder" => "badge folder", _ => "badge" }; /// Minimal HTML encoding for text content and attribute values. internal static string HtmlEncode(string value) { if (string.IsNullOrEmpty(value)) return string.Empty; return value .Replace("&", "&") .Replace("<", "<") .Replace(">", ">") .Replace("\"", """) .Replace("'", "'"); } }