Added new feature : display the file/folder and link of a SharingLink object in the permissions reports.

This commit is contained in:
Dev
2026-05-12 15:20:51 +02:00
parent ecc7b329d4
commit 1312dcdb1e
26 changed files with 937 additions and 82 deletions
@@ -14,7 +14,7 @@ public class CsvExportService
private static string BuildHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\"";
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
}
/// <summary>
@@ -39,7 +39,10 @@ public class CsvExportService
UserLogins = g.First().UserLogins,
PrincipalType = g.First().PrincipalType,
Permissions = g.Key.PermissionLevels,
GrantedThrough = g.Key.GrantedThrough
GrantedThrough = g.Key.GrantedThrough,
TargetLabel = g.First().TargetLabel ?? string.Empty,
TargetUrl = g.First().TargetUrl ?? string.Empty,
SharingLinkType = g.First().SharingLinkType ?? string.Empty
});
foreach (var row in merged)
@@ -47,7 +50,8 @@ public class CsvExportService
{
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins),
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.GrantedThrough)
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.GrantedThrough),
Csv(row.TargetLabel), Csv(row.TargetUrl), Csv(row.SharingLinkType)
}));
return sb.ToString();
@@ -68,7 +72,7 @@ public class CsvExportService
private static string BuildSimplifiedHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\"";
return $"\"{T["report.col.object"]}\",\"{T["report.col.title"]}\",\"{T["report.col.url"]}\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
}
/// <summary>
@@ -95,7 +99,10 @@ public class CsvExportService
Permissions = g.Key.PermissionLevels,
SimplifiedLabels = g.First().SimplifiedLabels,
RiskLevel = g.First().RiskLevel.ToString(),
GrantedThrough = g.Key.GrantedThrough
GrantedThrough = g.Key.GrantedThrough,
TargetLabel = g.First().TargetLabel ?? string.Empty,
TargetUrl = g.First().TargetUrl ?? string.Empty,
SharingLinkType = g.First().SharingLinkType ?? string.Empty
});
foreach (var row in merged)
@@ -104,7 +111,8 @@ public class CsvExportService
Csv(row.ObjectType), Csv(row.Title), Csv(row.Url),
Csv(row.HasUnique.ToString()), Csv(row.Users), Csv(row.UserLogins),
Csv(row.PrincipalType), Csv(row.Permissions), Csv(row.SimplifiedLabels),
Csv(row.RiskLevel), Csv(row.GrantedThrough)
Csv(row.RiskLevel), Csv(row.GrantedThrough),
Csv(row.TargetLabel), Csv(row.TargetUrl), Csv(row.SharingLinkType)
}));
return sb.ToString();
@@ -27,7 +27,8 @@ public class HtmlExportService
public string BuildHtml(
IReadOnlyList<PermissionEntry> entries,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var T = TranslationSource.Instance;
var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats(
@@ -57,7 +58,9 @@ public class HtmlExportService
var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers,
colSpan: 7, grpMemIdx: ref grpMemIdx);
colSpan: 7, grpMemIdx: ref grpMemIdx,
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
hideSystemGroupRaw: hideSystemGroupRaw);
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
@@ -66,7 +69,7 @@ public class HtmlExportService
sb.AppendLine($" <td><span class=\"{uniqueCss}\">{uniqueLbl}</span></td>");
sb.AppendLine($" <td>{pills}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.PermissionLevels)}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine($" <td>{BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}</td>");
sb.AppendLine("</tr>");
if (subRows.Length > 0) sb.Append(subRows);
}
@@ -87,7 +90,8 @@ public class HtmlExportService
public string BuildHtml(
IReadOnlyList<SimplifiedPermissionEntry> entries,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var T = TranslationSource.Instance;
var summaries = PermissionSummaryBuilder.Build(entries);
@@ -132,7 +136,9 @@ public class HtmlExportService
var (pills, subRows) = BuildUserPillsCell(
entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers,
colSpan: 9, grpMemIdx: ref grpMemIdx);
colSpan: 9, grpMemIdx: ref grpMemIdx,
targetLabel: entry.TargetLabel, sharingLinkType: entry.SharingLinkType,
hideSystemGroupRaw: hideSystemGroupRaw);
sb.AppendLine("<tr>");
sb.AppendLine($" <td><span class=\"{typeCss}\">{HtmlEncode(entry.ObjectType)}</span></td>");
@@ -143,7 +149,7 @@ public class HtmlExportService
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($" <td>{BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}</td>");
sb.AppendLine("</tr>");
if (subRows.Length > 0) sb.Append(subRows);
}
@@ -161,9 +167,10 @@ public class HtmlExportService
string filePath,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var html = BuildHtml(entries, branding, groupMembers);
var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw);
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
}
@@ -173,9 +180,10 @@ public class HtmlExportService
string filePath,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
var html = BuildHtml(entries, branding, groupMembers);
var html = BuildHtml(entries, branding, groupMembers, hideSystemGroupRaw);
await ExportFileWriter.WriteHtmlAsync(filePath, html, ct);
}
@@ -191,11 +199,12 @@ public class HtmlExportService
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(entries, basePath, ct, branding, groupMembers);
await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
return;
}
@@ -203,7 +212,7 @@ public class HtmlExportService
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers, hideSystemGroupRaw)))
.ToList();
var title = TranslationSource.Instance["report.title.permissions"];
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
@@ -215,11 +224,11 @@ public class HtmlExportService
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteAsync(partEntries, path, ct, branding, groupMembers);
await WriteAsync(partEntries, path, ct, branding, groupMembers, hideSystemGroupRaw);
}
}
/// <summary>Simplified-entry split variant of <see cref="WriteAsync(IReadOnlyList{PermissionEntry}, string, ReportSplitMode, HtmlSplitLayout, CancellationToken, ReportBranding?, IReadOnlyDictionary{string, IReadOnlyList{ResolvedMember}}?)"/>.</summary>
/// <summary>Simplified-entry split variant.</summary>
public async Task WriteAsync(
IReadOnlyList<SimplifiedPermissionEntry> entries,
string basePath,
@@ -227,11 +236,12 @@ public class HtmlExportService
HtmlSplitLayout layout,
CancellationToken ct,
ReportBranding? branding = null,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null)
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers = null,
bool hideSystemGroupRaw = false)
{
if (splitMode != ReportSplitMode.BySite)
{
await WriteAsync(entries, basePath, ct, branding, groupMembers);
await WriteAsync(entries, basePath, ct, branding, groupMembers, hideSystemGroupRaw);
return;
}
@@ -239,7 +249,7 @@ public class HtmlExportService
if (layout == HtmlSplitLayout.SingleTabbed)
{
var parts = partitions
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers)))
.Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers, hideSystemGroupRaw)))
.ToList();
var title = TranslationSource.Instance["report.title.permissions_simplified"];
var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title);
@@ -251,7 +261,7 @@ public class HtmlExportService
{
ct.ThrowIfCancellationRequested();
var path = ReportSplitHelper.BuildPartitionPath(basePath, label);
await WriteAsync(partEntries, path, ct, branding, groupMembers);
await WriteAsync(partEntries, path, ct, branding, groupMembers, hideSystemGroupRaw);
}
}
@@ -1,4 +1,5 @@
using System.Text;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Localization;
@@ -137,7 +138,10 @@ document.addEventListener('click', function(ev) {
string? principalType,
IReadOnlyDictionary<string, IReadOnlyList<ResolvedMember>>? groupMembers,
int colSpan,
ref int grpMemIdx)
ref int grpMemIdx,
string? targetLabel = null,
string? sharingLinkType = null,
bool hideSystemGroupRaw = false)
{
var T = TranslationSource.Instance;
var logins = userLogins.Split(';', StringSplitOptions.RemoveEmptyEntries);
@@ -151,14 +155,40 @@ document.addEventListener('click', function(ev) {
var name = i < names.Length ? names[i].Trim() : login;
var isExt = login.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
bool isExpandable = principalType == "SharePointGroup"
// When the principal is a resolved system group and the user wants the raw
// name hidden, replace the pill's visible text with the link-type badge
// (sharing links) and/or the target label. Falls back to the raw name when
// resolution failed (no targetLabel).
var classification = principalType == "SharePointGroup"
? PermissionEntryHelper.Classify(name)
: new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null);
bool isResolvedSystemGroup = hideSystemGroupRaw
&& classification.Kind != SystemGroupKind.None
&& classification.Kind != SystemGroupKind.LimitedAccessBare
&& !string.IsNullOrEmpty(targetLabel);
bool hasResolvedMembers = principalType == "SharePointGroup"
&& groupMembers != null
&& groupMembers.TryGetValue(name, out _);
if (isExpandable && groupMembers != null && groupMembers.TryGetValue(name, out var resolved))
if (hasResolvedMembers && 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)} &#9660;</span>");
pills.Append("<span class=\"user-pill group-expandable\"");
if (isResolvedSystemGroup)
pills.Append(" data-system-group=\"1\"");
pills.Append($" data-group-target=\"{HtmlEncode(grpId)}\" data-email=\"{HtmlEncode(login)}\">");
if (isResolvedSystemGroup)
{
if (!string.IsNullOrEmpty(sharingLinkType))
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
pills.Append(HtmlEncode(targetLabel!));
}
else
{
pills.Append(HtmlEncode(name));
}
pills.Append(" &#9660;</span>");
string memberContent;
if (resolved.Count > 0)
@@ -173,6 +203,14 @@ document.addEventListener('click', function(ev) {
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 if (isResolvedSystemGroup)
{
pills.Append("<span class=\"user-pill\" data-system-group=\"1\">");
if (!string.IsNullOrEmpty(sharingLinkType))
pills.Append(BuildSharingLinkBadge(sharingLinkType!));
pills.Append(HtmlEncode(targetLabel!));
pills.Append("</span>");
}
else
{
var cls = isExt ? "user-pill external-user" : "user-pill";
@@ -183,6 +221,80 @@ document.addEventListener('click', function(ev) {
return (pills.ToString(), subRows.ToString());
}
/// <summary>
/// Renders the Granted Through cell. When the entry carries a resolved system-group
/// target (Limited Access For Web/List or SharingLinks), a clickable link to the
/// targeted resource is appended on a second line. For sharing links the link type
/// (OrganizationEdit / AnonymousView / …) is surfaced alongside the target.
///
/// When <paramref name="hideSystemGroupRaw"/> is true and a target was resolved, the
/// raw "SharePoint Group: SharingLinks.{guid}…" / "Limited Access System Group For
/// Web|List {guid}" prefix is suppressed and only the link-type badge + clickable
/// target are shown — keeps the report readable without losing information.
/// </summary>
internal static string BuildGrantedThroughCell(
string grantedThrough,
string? targetUrl,
string? targetLabel,
string? sharingLinkType,
bool hideSystemGroupRaw = false)
{
var hasTarget = !string.IsNullOrEmpty(targetUrl) && !string.IsNullOrEmpty(targetLabel);
var hasLinkType = !string.IsNullOrEmpty(sharingLinkType);
var suppressRaw = hideSystemGroupRaw && hasTarget;
var sb = new StringBuilder();
if (!suppressRaw)
sb.Append(HtmlEncode(grantedThrough));
if (!hasTarget && !hasLinkType)
return sb.ToString();
if (suppressRaw)
{
// Inline layout — no leading raw text to wrap under.
if (hasLinkType)
sb.Append(BuildSharingLinkBadge(sharingLinkType!));
if (hasTarget)
{
sb.Append("<a href=\"");
sb.Append(HtmlEncode(targetUrl!));
sb.Append("\" target=\"_blank\">");
sb.Append(HtmlEncode(targetLabel!));
sb.Append("</a>");
}
return sb.ToString();
}
sb.Append("<div style=\"margin-top:4px;font-size:.75rem;color:#555\">");
if (hasLinkType)
sb.Append(BuildSharingLinkBadge(sharingLinkType!));
if (hasTarget)
{
sb.Append("&rarr; <a href=\"");
sb.Append(HtmlEncode(targetUrl!));
sb.Append("\" target=\"_blank\">");
sb.Append(HtmlEncode(targetLabel!));
sb.Append("</a>");
}
sb.Append("</div>");
return sb.ToString();
}
/// <summary>
/// Builds the colored badge for a SharePoint sharing-link type. Translates the
/// raw <c>linkType</c> code (e.g. <c>OrganizationEdit</c>) into a human label
/// (e.g. <c>Org link · Edit</c>) and tints by risk tier; raw code surfaces as a
/// <c>title</c> tooltip so operators can still trace it back to the source.
/// </summary>
internal static string BuildSharingLinkBadge(string rawLinkType)
{
var (label, risk) = SharingLinkLabels.Describe(rawLinkType);
var (bg, fg) = SharingLinkLabels.Colors(risk);
return $"<span class=\"badge\" style=\"background:{bg};color:{fg};margin-right:6px\" " +
$"title=\"{HtmlEncode(rawLinkType)}\">{HtmlEncode(label)}</span>";
}
/// <summary>Returns the CSS class for the object-type badge.</summary>
internal static string ObjectTypeCss(string t) => t switch
{
@@ -15,7 +15,7 @@ public class UserAccessCsvExportService
private static string BuildDataHeader()
{
var T = TranslationSource.Instance;
return $"\"{T["report.col.site"]}\",\"{T["report.col.object_type"]}\",\"{T["report.col.object"]}\",\"{T["report.col.url"]}\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\"";
return $"\"{T["report.col.site"]}\",\"{T["report.col.object_type"]}\",\"{T["report.col.object"]}\",\"{T["report.col.url"]}\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\"";
}
/// <summary>
@@ -51,7 +51,10 @@ public class UserAccessCsvExportService
Csv(entry.ObjectUrl),
Csv(entry.PermissionLevel),
Csv(entry.AccessType.ToString()),
Csv(entry.GrantedThrough)
Csv(entry.GrantedThrough),
Csv(entry.TargetLabel ?? string.Empty),
Csv(entry.TargetUrl ?? string.Empty),
Csv(entry.SharingLinkType ?? string.Empty)
}));
}
@@ -179,7 +182,7 @@ public class UserAccessCsvExportService
sb.AppendLine();
// Header
sb.AppendLine($"\"{T["report.col.user"]}\",\"User Login\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"Locations\",\"Location Count\"");
sb.AppendLine($"\"{T["report.col.user"]}\",\"User Login\",\"{T["report.col.permission_level"]}\",\"{T["report.col.access_type"]}\",\"{T["report.col.granted_through"]}\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\",\"Locations\",\"Location Count\"");
// Data rows
foreach (var entry in consolidated)
@@ -187,13 +190,16 @@ public class UserAccessCsvExportService
var locations = string.Join("; ", entry.Locations.Select(l => l.SiteTitle));
sb.AppendLine(string.Join(",", new[]
{
$"\"{entry.UserDisplayName}\"",
$"\"{entry.UserLogin}\"",
$"\"{entry.PermissionLevel}\"",
$"\"{entry.AccessType}\"",
$"\"{entry.GrantedThrough}\"",
$"\"{locations}\"",
$"\"{entry.LocationCount}\""
Csv(entry.UserDisplayName),
Csv(entry.UserLogin),
Csv(entry.PermissionLevel),
Csv(entry.AccessType.ToString()),
Csv(entry.GrantedThrough),
Csv(entry.TargetLabel ?? string.Empty),
Csv(entry.TargetUrl ?? string.Empty),
Csv(entry.SharingLinkType ?? string.Empty),
Csv(locations),
Csv(entry.LocationCount.ToString())
}));
}
@@ -226,7 +232,10 @@ public class UserAccessCsvExportService
Csv(entry.ObjectUrl),
Csv(entry.PermissionLevel),
Csv(entry.AccessType.ToString()),
Csv(entry.GrantedThrough)
Csv(entry.GrantedThrough),
Csv(entry.TargetLabel ?? string.Empty),
Csv(entry.TargetUrl ?? string.Empty),
Csv(entry.SharingLinkType ?? string.Empty)
}));
}
@@ -250,7 +250,7 @@ a:hover { text-decoration: underline; }
sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
sb.AppendLine("</tr>");
}
}
@@ -301,7 +301,7 @@ a:hover { text-decoration: underline; }
sb.AppendLine($" <td>{objectCell}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
sb.AppendLine("</tr>");
}
}
@@ -579,7 +579,7 @@ a:hover { text-decoration: underline; }
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.UserDisplayName)}{guestBadge}</td>");
sb.AppendLine($" <td{rowClass}>{HtmlEncode(entry.PermissionLevel)}{highIcon}</td>");
sb.AppendLine($" <td>{accessBadge}</td>");
sb.AppendLine($" <td>{HtmlEncode(entry.GrantedThrough)}</td>");
sb.AppendLine($" <td>{PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}</td>");
if (entry.LocationCount == 1)
{