From 1312dcdb1e4d141a5dbbe9d48f645618de794b28 Mon Sep 17 00:00:00 2001 From: Dev Date: Tue, 12 May 2026 15:20:51 +0200 Subject: [PATCH] Added new feature : display the file/folder and link of a SharingLink object in the permissions reports. --- .../Services/Export/CsvExportServiceTests.cs | 48 ++++ .../Services/Export/HtmlExportServiceTests.cs | 140 ++++++++++++ .../Export/UserAccessCsvExportServiceTests.cs | 15 +- .../PermissionEntryClassificationTests.cs | 97 ++++++++- SharepointToolbox/App.xaml.cs | 1 + .../Core/Helpers/PermissionConsolidator.cs | 5 +- .../Core/Helpers/PermissionEntryHelper.cs | 91 +++++++- .../Core/Helpers/SharingLinkLabels.cs | 58 +++++ .../Models/ConsolidatedPermissionEntry.cs | 5 +- .../Core/Models/PermissionEntry.cs | 7 +- .../Core/Models/SimplifiedPermissionEntry.cs | 3 + .../Core/Models/SystemGroupTarget.cs | 15 ++ .../Core/Models/UserAccessEntry.cs | 5 +- .../Localization/Strings.fr.resx | 1 + SharepointToolbox/Localization/Strings.resx | 1 + .../Services/Export/CsvExportService.cs | 20 +- .../Services/Export/HtmlExportService.cs | 48 ++-- .../Export/PermissionHtmlFragments.cs | 120 +++++++++- .../Export/UserAccessCsvExportService.cs | 31 ++- .../Export/UserAccessHtmlExportService.cs | 6 +- .../Services/ISystemGroupTargetResolver.cs | 23 ++ .../Services/PermissionsService.cs | 52 ++++- .../Services/SystemGroupTargetResolver.cs | 205 ++++++++++++++++++ .../Services/UserAccessAuditService.cs | 5 +- .../ViewModels/Tabs/PermissionsViewModel.cs | 13 +- .../Views/Tabs/PermissionsView.xaml | 4 +- 26 files changed, 937 insertions(+), 82 deletions(-) create mode 100644 SharepointToolbox/Core/Helpers/SharingLinkLabels.cs create mode 100644 SharepointToolbox/Core/Models/SystemGroupTarget.cs create mode 100644 SharepointToolbox/Services/ISystemGroupTargetResolver.cs create mode 100644 SharepointToolbox/Services/SystemGroupTargetResolver.cs diff --git a/SharepointToolbox.Tests/Services/Export/CsvExportServiceTests.cs b/SharepointToolbox.Tests/Services/Export/CsvExportServiceTests.cs index 3a0cbf2..686b680 100644 --- a/SharepointToolbox.Tests/Services/Export/CsvExportServiceTests.cs +++ b/SharepointToolbox.Tests/Services/Export/CsvExportServiceTests.cs @@ -48,6 +48,54 @@ public class CsvExportServiceTests Assert.Contains("Object", csv); } + [Fact] + public void BuildCsv_WithResolvedTarget_IncludesTargetColumns() + { + var entry = new PermissionEntry( + ObjectType: "Site", + Title: "HR", + Url: "https://contoso.sharepoint.com/sites/HR", + HasUniquePermissions: true, + Users: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc", + UserLogins: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc", + PermissionLevels: "Limited Access: Edit", + GrantedThrough: "SharePoint Group: Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc", + PrincipalType: "SharePointGroup", + TargetUrl: "https://contoso.sharepoint.com/sites/HR/Lists/Payroll", + TargetLabel: "Payroll"); + + var svc = new CsvExportService(); + var csv = svc.BuildCsv(new[] { entry }); + + Assert.Contains("TargetLabel", csv); + Assert.Contains("TargetUrl", csv); + Assert.Contains("Payroll", csv); + Assert.Contains("https://contoso.sharepoint.com/sites/HR/Lists/Payroll", csv); + } + + [Fact] + public void BuildCsv_WithSharingLink_IncludesLinkType() + { + var entry = new PermissionEntry( + ObjectType: "Folder", Title: "Reports", Url: "https://contoso.sharepoint.com/sites/HR/Docs", + HasUniquePermissions: true, + Users: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + UserLogins: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + PermissionLevels: "Contribute", + GrantedThrough: "SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + PrincipalType: "SharePointGroup", + TargetUrl: "https://contoso.sharepoint.com/sites/HR/Docs/Q4.xlsx", + TargetLabel: "Q4.xlsx", + SharingLinkType: "OrganizationEdit"); + + var svc = new CsvExportService(); + var csv = svc.BuildCsv(new[] { entry }); + + Assert.Contains("SharingLinkType", csv); + Assert.Contains("OrganizationEdit", csv); + Assert.Contains("Q4.xlsx", csv); + } + [Fact] public void BuildCsv_WithDuplicateUserPermissionGrantedThrough_MergesLocations() { diff --git a/SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs b/SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs index 5ec3055..ae50a97 100644 --- a/SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs +++ b/SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs @@ -156,6 +156,146 @@ public class HtmlExportServiceTests Assert.Contains("getAttribute('data-group-target')", html); } + // ── System-group target rendering (Limited Access / SharingLinks) ──────── + + [Fact] + public void BuildHtml_WithResolvedListTarget_RendersClickableLink() + { + var entry = new PermissionEntry( + ObjectType: "Site", + Title: "Contoso HR", + Url: "https://contoso.sharepoint.com/sites/HR", + HasUniquePermissions: true, + Users: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc", + UserLogins: "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc", + PermissionLevels: "Limited Access: Edit", + GrantedThrough: "SharePoint Group: Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc", + PrincipalType: "SharePointGroup", + TargetUrl: "https://contoso.sharepoint.com/sites/HR/Lists/Payroll", + TargetLabel: "Payroll"); + + var svc = new HtmlExportService(); + var html = svc.BuildHtml(new[] { entry }); + + Assert.Contains("https://contoso.sharepoint.com/sites/HR/Lists/Payroll", html); + Assert.Contains(">Payroll", html); + } + + [Fact] + public void BuildHtml_WithSharingLinkTarget_RendersLinkTypeBadgeAndTarget() + { + var entry = new PermissionEntry( + ObjectType: "Folder", + Title: "Reports", + Url: "https://contoso.sharepoint.com/sites/HR/Shared%20Documents/Reports", + HasUniquePermissions: true, + Users: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + UserLogins: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + PermissionLevels: "Contribute", + GrantedThrough: "SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + PrincipalType: "SharePointGroup", + TargetUrl: "https://contoso.sharepoint.com/sites/HR/Shared%20Documents/Reports/Q4.xlsx", + TargetLabel: "Q4.xlsx", + SharingLinkType: "OrganizationEdit"); + + var svc = new HtmlExportService(); + var html = svc.BuildHtml(new[] { entry }); + + // Friendly label, raw code preserved as tooltip + Assert.Contains("Org link", html); + Assert.Contains("Edit", html); + Assert.Contains("title=\"OrganizationEdit\"", html); + Assert.Contains("Q4.xlsx", html); + Assert.Contains("https://contoso.sharepoint.com/sites/HR/Shared%20Documents/Reports/Q4.xlsx", html); + } + + [Theory] + [InlineData("OrganizationView", "Org link", "View")] + [InlineData("AnonymousEdit", "Anyone", "Edit")] + [InlineData("Direct", "Specific people", null)] + [InlineData("Flexible", "Custom link", null)] + public void BuildHtml_FriendlyLinkLabel_ReplacesRawType(string raw, string expectedPart, string? expectedSecond) + { + var entry = new PermissionEntry( + ObjectType: "File", Title: "Doc.docx", + Url: "https://contoso.sharepoint.com/sites/HR/Docs/Doc.docx", + HasUniquePermissions: true, + Users: $"SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.{raw}.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + UserLogins: $"SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.{raw}.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + PermissionLevels: "Contribute", + GrantedThrough: $"SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.{raw}.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + PrincipalType: "SharePointGroup", + TargetUrl: "https://contoso.sharepoint.com/sites/HR/Docs/Doc.docx", + TargetLabel: "Doc.docx", + SharingLinkType: raw); + + var svc = new HtmlExportService(); + var html = svc.BuildHtml(new[] { entry }, null, null, hideSystemGroupRaw: true); + + Assert.Contains(expectedPart, html); + if (expectedSecond is not null) + Assert.Contains(expectedSecond, html); + Assert.Contains($"title=\"{raw}\"", html); + } + + [Fact] + public void BuildHtml_WithSharingLinkAndHideRaw_OmitsRawGroupString() + { + var entry = new PermissionEntry( + ObjectType: "File", Title: "Q4.xlsx", + Url: "https://contoso.sharepoint.com/sites/HR/Docs/Q4.xlsx", + HasUniquePermissions: true, + Users: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + UserLogins: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + PermissionLevels: "Contribute", + GrantedThrough: "SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + PrincipalType: "SharePointGroup", + TargetUrl: "https://contoso.sharepoint.com/sites/HR/Docs/Q4.xlsx", + TargetLabel: "Q4.xlsx", + SharingLinkType: "OrganizationEdit"); + + var svc = new HtmlExportService(); + var html = svc.BuildHtml(new[] { entry }, null, null, hideSystemGroupRaw: true); + + // Raw "SharingLinks.{guid}..." text suppressed when target resolved + flag on + Assert.DoesNotContain("SharingLinks.e686221e", html); + // Target link still rendered + Assert.Contains("Q4.xlsx", html); + Assert.Contains("OrganizationEdit", html); + } + + [Fact] + public void BuildHtml_WithSharingLinkButNoTargetAndHideRaw_KeepsRawAsFallback() + { + // Target resolution failed (deleted item) — flag on, but no TargetUrl → keep raw text. + var entry = new PermissionEntry( + ObjectType: "File", Title: "Q4.xlsx", + Url: "https://contoso.sharepoint.com/sites/HR/Docs/Q4.xlsx", + HasUniquePermissions: true, + Users: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + UserLogins: "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + PermissionLevels: "Contribute", + GrantedThrough: "SharePoint Group: SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", + PrincipalType: "SharePointGroup", + SharingLinkType: "OrganizationEdit"); + + var svc = new HtmlExportService(); + var html = svc.BuildHtml(new[] { entry }, null, null, hideSystemGroupRaw: true); + + Assert.Contains("SharingLinks.e686221e", html); + } + + [Fact] + public void BuildHtml_WithoutTarget_RendersGrantedThroughTextOnly() + { + // Standard SP group with no resolved target — no extra link should appear. + var entry = MakeEntry("Owners", "ownersgroup", principalType: "SharePointGroup"); + var svc = new HtmlExportService(); + var html = svc.BuildHtml(new[] { entry }); + + Assert.DoesNotContain("→", html); + } + [Fact] public void BuildHtml_Simplified_WithGroupMembers_RendersExpandablePill() { diff --git a/SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs b/SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs index ee1479b..0c727d0 100644 --- a/SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs +++ b/SharepointToolbox.Tests/Services/Export/UserAccessCsvExportServiceTests.cs @@ -86,20 +86,15 @@ public class UserAccessCsvExportServiceTests var svc = new UserAccessCsvExportService(); var csv = svc.BuildCsv("Alice", "alice@contoso.com", new[] { DefaultEntry }); - // Find the header row and count its quoted comma-separated fields - // Header is: "Site","Object Type","Object","URL","Permission Level","Access Type","Granted Through" - // That is 7 fields. + // Header: Site, Object Type, Object, URL, Permission Level, Access Type, + // Granted Through, TargetLabel, TargetUrl, SharingLinkType → 10 fields. var lines = csv.Split('\n', StringSplitOptions.RemoveEmptyEntries); - // Find a data row (after the blank line separating summary from data) - // Data rows contain the entry content (not the header line itself) - // We want to count fields in the header row: var headerLine = lines.FirstOrDefault(l => l.Contains("\"Site\",\"Object Type\"")); Assert.NotNull(headerLine); - // Count comma-separated quoted fields: split by "," boundary var fields = CountCsvFields(headerLine!); - Assert.Equal(7, fields); + Assert.Equal(10, fields); } // ── Test 5: WriteSingleFileAsync includes entries for all users ─────────── @@ -190,8 +185,8 @@ public class UserAccessCsvExportServiceTests await svc.WriteSingleFileAsync(entries, tmpFile, CancellationToken.None, mergePermissions: true); var content = await File.ReadAllTextAsync(tmpFile); - // Header must contain consolidated columns - Assert.Contains("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"Locations\",\"Location Count\"", content); + // Header must contain consolidated columns (now includes Target* + SharingLinkType) + Assert.Contains("\"User\",\"User Login\",\"Permission Level\",\"Access Type\",\"Granted Through\",\"TargetLabel\",\"TargetUrl\",\"SharingLinkType\",\"Locations\",\"Location Count\"", content); // Alice's two entries merged — locations column contains both site titles Assert.Contains("Contoso", content); diff --git a/SharepointToolbox.Tests/Services/PermissionEntryClassificationTests.cs b/SharepointToolbox.Tests/Services/PermissionEntryClassificationTests.cs index 8cd3e4f..724102c 100644 --- a/SharepointToolbox.Tests/Services/PermissionEntryClassificationTests.cs +++ b/SharepointToolbox.Tests/Services/PermissionEntryClassificationTests.cs @@ -3,7 +3,8 @@ using SharepointToolbox.Core.Helpers; namespace SharepointToolbox.Tests.Services; /// -/// Tests for PERM-03: external user detection and permission-level filtering. +/// Tests for PERM-03: external user detection, permission-level filtering, +/// and SharePoint system-group classification (Limited Access / SharingLinks). /// Pure static logic — runs immediately without stubs. /// public class PermissionEntryClassificationTests @@ -13,7 +14,6 @@ public class PermissionEntryClassificationTests [Fact] public void IsExternalUser_WithExtHashInLoginName_ReturnsTrue() { - // B2B guest login names contain the literal "#EXT#" fragment Assert.True(PermissionEntryHelper.IsExternalUser("ext_user_domain.com#EXT#@contoso.onmicrosoft.com")); } @@ -28,8 +28,6 @@ public class PermissionEntryClassificationTests [Fact] public void PermissionEntry_FiltersOutLimitedAccess_WhenOnlyPermissionIsLimitedAccess() { - // A principal whose sole permission level is "Limited Access" should produce - // an empty list after filtering — used to decide whether to include the entry. var result = PermissionEntryHelper.FilterPermissionLevels(new[] { "Limited Access" }); Assert.Empty(result); } @@ -41,23 +39,100 @@ public class PermissionEntryClassificationTests Assert.Equal(new[] { "Contribute" }, result); } - // ── IsSharingLinksGroup ──────────────────────────────────────────────────── + // ── IsBareLimitedAccessSystemGroup ───────────────────────────────────────── [Fact] - public void IsSharingLinksGroup_WithSharingLinksPrefix_ReturnsTrue() + public void IsBareLimitedAccessSystemGroup_WithExactMatch_ReturnsTrue() { - Assert.True(PermissionEntryHelper.IsSharingLinksGroup("SharingLinks.abc123.Edit")); + Assert.True(PermissionEntryHelper.IsBareLimitedAccessSystemGroup("Limited Access System Group")); } [Fact] - public void IsSharingLinksGroup_WithLimitedAccessSystemGroup_ReturnsTrue() + public void IsBareLimitedAccessSystemGroup_WithForWebVariant_ReturnsFalse() { - Assert.True(PermissionEntryHelper.IsSharingLinksGroup("Limited Access System Group")); + // The "For Web {guid}" variant is enriched, not filtered. + Assert.False(PermissionEntryHelper.IsBareLimitedAccessSystemGroup( + "Limited Access System Group For Web e55a4f7a-b132-4baa-bd96-c63d8dc1fc80")); } [Fact] - public void IsSharingLinksGroup_WithNormalGroup_ReturnsFalse() + public void IsBareLimitedAccessSystemGroup_WithNormalGroup_ReturnsFalse() { - Assert.False(PermissionEntryHelper.IsSharingLinksGroup("Owners")); + Assert.False(PermissionEntryHelper.IsBareLimitedAccessSystemGroup("Owners")); + } + + // ── Classify ─────────────────────────────────────────────────────────────── + + [Fact] + public void Classify_WithNormalGroupTitle_ReturnsNone() + { + var c = PermissionEntryHelper.Classify("Site Owners"); + Assert.Equal(SystemGroupKind.None, c.Kind); + } + + [Fact] + public void Classify_WithBareLimitedAccess_ReturnsLimitedAccessBare() + { + var c = PermissionEntryHelper.Classify("Limited Access System Group"); + Assert.Equal(SystemGroupKind.LimitedAccessBare, c.Kind); + } + + [Fact] + public void Classify_WithLimitedAccessForWeb_ExtractsWebId() + { + var c = PermissionEntryHelper.Classify( + "Limited Access System Group For Web e55a4f7a-b132-4baa-bd96-c63d8dc1fc80"); + Assert.Equal(SystemGroupKind.LimitedAccessWeb, c.Kind); + Assert.Equal(Guid.Parse("e55a4f7a-b132-4baa-bd96-c63d8dc1fc80"), c.WebId); + Assert.Null(c.ListId); + Assert.Null(c.ItemUniqueId); + } + + [Fact] + public void Classify_WithLimitedAccessForList_ExtractsListId() + { + var c = PermissionEntryHelper.Classify( + "Limited Access System Group For List b20e3b22-2b09-4c99-9ba4-37b42f3a12dc"); + Assert.Equal(SystemGroupKind.LimitedAccessList, c.Kind); + Assert.Equal(Guid.Parse("b20e3b22-2b09-4c99-9ba4-37b42f3a12dc"), c.ListId); + Assert.Null(c.WebId); + Assert.Null(c.ItemUniqueId); + } + + [Fact] + public void Classify_WithSharingLink_ExtractsItemAndShareId() + { + var c = PermissionEntryHelper.Classify( + "SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationEdit.64c27910-66ea-421d-b0f0-8f0f72dcfaf6"); + Assert.Equal(SystemGroupKind.SharingLink, c.Kind); + Assert.Equal(Guid.Parse("e686221e-d1cb-43c5-8c68-04aa7f90f329"), c.ItemUniqueId); + Assert.Equal(Guid.Parse("64c27910-66ea-421d-b0f0-8f0f72dcfaf6"), c.ShareId); + Assert.Equal("OrganizationEdit", c.LinkType); + } + + [Theory] + [InlineData("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.AnonymousView.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", "AnonymousView")] + [InlineData("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.Flexible.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", "Flexible")] + [InlineData("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.OrganizationView.64c27910-66ea-421d-b0f0-8f0f72dcfaf6", "OrganizationView")] + public void Classify_WithVariousSharingLinkTypes_PreservesLinkType(string title, string expected) + { + var c = PermissionEntryHelper.Classify(title); + Assert.Equal(SystemGroupKind.SharingLink, c.Kind); + Assert.Equal(expected, c.LinkType); + } + + [Fact] + public void Classify_WithMalformedSharingLink_ReturnsNone() + { + // Missing share GUID at the end + var c = PermissionEntryHelper.Classify("SharingLinks.e686221e-d1cb-43c5-8c68-04aa7f90f329.Edit"); + Assert.Equal(SystemGroupKind.None, c.Kind); + } + + [Fact] + public void Classify_WithEmptyString_ReturnsNone() + { + var c = PermissionEntryHelper.Classify(""); + Assert.Equal(SystemGroupKind.None, c.Kind); } } diff --git a/SharepointToolbox/App.xaml.cs b/SharepointToolbox/App.xaml.cs index edebd36..3da5b32 100644 --- a/SharepointToolbox/App.xaml.cs +++ b/SharepointToolbox/App.xaml.cs @@ -149,6 +149,7 @@ public partial class App : Application services.AddTransient(); // Phase 2: Permissions + services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/SharepointToolbox/Core/Helpers/PermissionConsolidator.cs b/SharepointToolbox/Core/Helpers/PermissionConsolidator.cs index 6322b8a..031e198 100644 --- a/SharepointToolbox/Core/Helpers/PermissionConsolidator.cs +++ b/SharepointToolbox/Core/Helpers/PermissionConsolidator.cs @@ -50,7 +50,10 @@ public static class PermissionConsolidator first.GrantedThrough, first.IsHighPrivilege, first.IsExternalUser, - locations); + locations, + first.TargetUrl, + first.TargetLabel, + first.SharingLinkType); }) .OrderBy(c => c.UserLogin) .ThenBy(c => c.PermissionLevel) diff --git a/SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs b/SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs index 597cd72..b6d8266 100644 --- a/SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs +++ b/SharepointToolbox/Core/Helpers/PermissionEntryHelper.cs @@ -1,10 +1,56 @@ +using System.Text.RegularExpressions; + namespace SharepointToolbox.Core.Helpers; +/// +/// Classifies a SharePoint group name into one of the known system-group shapes. +/// +public enum SystemGroupKind +{ + /// Not a system group — a normal SharePoint group, user, or external user. + None, + /// Bare "Limited Access System Group" pseudo-principal (no embedded target). + LimitedAccessBare, + /// "Limited Access System Group For Web {webId}". + LimitedAccessWeb, + /// "Limited Access System Group For List {listId}". + LimitedAccessList, + /// "SharingLinks.{itemUniqueId}.{linkType}.{shareId}". + SharingLink +} + +/// +/// Result of classifying a SharePoint group name. +/// Carries the embedded GUIDs / link type so a resolver can look up the target. +/// +public readonly record struct SystemGroupClassification( + SystemGroupKind Kind, + Guid? WebId, + Guid? ListId, + Guid? ItemUniqueId, + string? LinkType, + Guid? ShareId); + /// /// Pure static helpers for classifying SharePoint permission entries. /// public static class PermissionEntryHelper { + // "Limited Access System Group For Web {guid}" + private static readonly Regex LimitedAccessWebRegex = new( + @"^Limited Access System Group For Web\s+(?[0-9a-fA-F-]{36})\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // "Limited Access System Group For List {guid}" + private static readonly Regex LimitedAccessListRegex = new( + @"^Limited Access System Group For List\s+(?[0-9a-fA-F-]{36})\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // "SharingLinks.{itemUniqueId}.{linkType}.{shareId}" + private static readonly Regex SharingLinkRegex = new( + @"^SharingLinks\.(?[0-9a-fA-F-]{36})\.(?[^.]+)\.(?[0-9a-fA-F-]{36})\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + /// /// Returns true when the login name is a B2B guest (contains #EXT#). /// @@ -21,10 +67,45 @@ public static class PermissionEntryHelper .ToList(); /// - /// Returns true when the login name represents an internal sharing-link group - /// or the "Limited Access System Group" pseudo-principal. + /// Returns true when the supplied name/login is the bare "Limited Access System Group" + /// pseudo-principal — the noise entry with no embedded GUID. The + /// per-Web/List and SharingLinks variants are NOT filtered: they are enriched. /// - public static bool IsSharingLinksGroup(string loginName) => - loginName.StartsWith("SharingLinks.", StringComparison.OrdinalIgnoreCase) - || loginName.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase); + public static bool IsBareLimitedAccessSystemGroup(string name) => + name.Equals("Limited Access System Group", StringComparison.OrdinalIgnoreCase); + + /// + /// Classifies a SharePoint group title (the value used in Granted Through). When the + /// title matches a known system pattern, the embedded GUIDs / link type are parsed + /// out so a resolver can look up the actual targeted Web/List/Item. + /// + public static SystemGroupClassification Classify(string groupTitle) + { + if (string.IsNullOrWhiteSpace(groupTitle)) + return new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null); + + var trimmed = groupTitle.Trim(); + + if (IsBareLimitedAccessSystemGroup(trimmed)) + return new SystemGroupClassification(SystemGroupKind.LimitedAccessBare, null, null, null, null, null); + + var mWeb = LimitedAccessWebRegex.Match(trimmed); + if (mWeb.Success && Guid.TryParse(mWeb.Groups["id"].Value, out var webId)) + return new SystemGroupClassification(SystemGroupKind.LimitedAccessWeb, webId, null, null, null, null); + + var mList = LimitedAccessListRegex.Match(trimmed); + if (mList.Success && Guid.TryParse(mList.Groups["id"].Value, out var listId)) + return new SystemGroupClassification(SystemGroupKind.LimitedAccessList, null, listId, null, null, null); + + var mShare = SharingLinkRegex.Match(trimmed); + if (mShare.Success + && Guid.TryParse(mShare.Groups["item"].Value, out var itemId) + && Guid.TryParse(mShare.Groups["share"].Value, out var shareId)) + { + return new SystemGroupClassification( + SystemGroupKind.SharingLink, null, null, itemId, mShare.Groups["type"].Value, shareId); + } + + return new SystemGroupClassification(SystemGroupKind.None, null, null, null, null, null); + } } diff --git a/SharepointToolbox/Core/Helpers/SharingLinkLabels.cs b/SharepointToolbox/Core/Helpers/SharingLinkLabels.cs new file mode 100644 index 0000000..9853957 --- /dev/null +++ b/SharepointToolbox/Core/Helpers/SharingLinkLabels.cs @@ -0,0 +1,58 @@ +namespace SharepointToolbox.Core.Helpers; + +/// +/// Risk tier of a SharePoint sharing link, used to color-code the link-type badge +/// in HTML reports. +/// +public enum SharingLinkRisk +{ + Low, // Read-only, scoped (Org view, Direct, Existing, Review) + Medium, // Org-wide edit + High, // Anonymous (anyone with link, no auth) + Unknown // Unrecognized type — neutral styling +} + +/// +/// Maps the raw linkType segment of a SharingLinks.{itemId}.{type}.{shareId} +/// group name to a human-readable label and a risk tier. The raw codes are SharePoint +/// internals (OrganizationEdit / AnonymousView / Flexible / …); reports show the friendly +/// label and tint the badge by tier. +/// +public static class SharingLinkLabels +{ + /// + /// Returns the friendly label and risk tier for a sharing-link type code. + /// Unknown codes fall through with the raw value and . + /// + public static (string Label, SharingLinkRisk Risk) Describe(string? rawLinkType) + { + if (string.IsNullOrWhiteSpace(rawLinkType)) + return (string.Empty, SharingLinkRisk.Unknown); + + return rawLinkType.Trim() switch + { + "OrganizationView" => ("Org link · View", SharingLinkRisk.Low), + "OrganizationEdit" => ("Org link · Edit", SharingLinkRisk.Medium), + "AnonymousView" => ("Anyone · View", SharingLinkRisk.High), + "AnonymousEdit" => ("Anyone · Edit", SharingLinkRisk.High), + "Flexible" => ("Custom link", SharingLinkRisk.Medium), + "Direct" => ("Specific people", SharingLinkRisk.Low), + "Existing" => ("Existing access", SharingLinkRisk.Low), + "Review" => ("Review only", SharingLinkRisk.Low), + "Embed" => ("Embedded link", SharingLinkRisk.Medium), + _ => (rawLinkType, SharingLinkRisk.Unknown) + }; + } + + /// + /// Returns inline-CSS colors (background, foreground) for a risk tier — matches the + /// risk-card palette used elsewhere in the HTML reports. + /// + public static (string Background, string Foreground) Colors(SharingLinkRisk risk) => risk switch + { + SharingLinkRisk.Low => ("#D1FAE5", "#065F46"), + SharingLinkRisk.Medium => ("#FEF3C7", "#92400E"), + SharingLinkRisk.High => ("#FEE2E2", "#991B1B"), + _ => ("#F3F4F6", "#374151"), + }; +} diff --git a/SharepointToolbox/Core/Models/ConsolidatedPermissionEntry.cs b/SharepointToolbox/Core/Models/ConsolidatedPermissionEntry.cs index 02e274d..ea74f1b 100644 --- a/SharepointToolbox/Core/Models/ConsolidatedPermissionEntry.cs +++ b/SharepointToolbox/Core/Models/ConsolidatedPermissionEntry.cs @@ -13,7 +13,10 @@ public record ConsolidatedPermissionEntry( string GrantedThrough, bool IsHighPrivilege, bool IsExternalUser, - IReadOnlyList Locations + IReadOnlyList Locations, + string? TargetUrl = null, + string? TargetLabel = null, + string? SharingLinkType = null ) { /// Convenience count — equals Locations.Count. diff --git a/SharepointToolbox/Core/Models/PermissionEntry.cs b/SharepointToolbox/Core/Models/PermissionEntry.cs index 353d6de..c21df63 100644 --- a/SharepointToolbox/Core/Models/PermissionEntry.cs +++ b/SharepointToolbox/Core/Models/PermissionEntry.cs @@ -14,5 +14,10 @@ public record PermissionEntry( string PermissionLevels, // Semicolon-joined role names (Limited Access already removed) string GrantedThrough, // "Direct Permissions" | "SharePoint Group: " string PrincipalType, // "SharePointGroup" | "User" | "External User" - bool WasAutoElevated = false // Set to true when site admin was auto-granted to access this entry + bool WasAutoElevated = false, // Set to true when site admin was auto-granted to access this entry + // Resolved target for Limited Access System Group For Web/List and SharingLinks + // groups — populated when GrantedThrough is one of those system groups, null otherwise. + string? TargetUrl = null, + string? TargetLabel = null, + string? SharingLinkType = null // OrganizationEdit | OrganizationView | AnonymousEdit | AnonymousView | etc. ); diff --git a/SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs b/SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs index d10ee63..0509b32 100644 --- a/SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs +++ b/SharepointToolbox/Core/Models/SimplifiedPermissionEntry.cs @@ -41,6 +41,9 @@ public class SimplifiedPermissionEntry public string PermissionLevels => Inner.PermissionLevels; public string GrantedThrough => Inner.GrantedThrough; public string PrincipalType => Inner.PrincipalType; + public string? TargetUrl => Inner.TargetUrl; + public string? TargetLabel => Inner.TargetLabel; + public string? SharingLinkType => Inner.SharingLinkType; public SimplifiedPermissionEntry(PermissionEntry entry) { diff --git a/SharepointToolbox/Core/Models/SystemGroupTarget.cs b/SharepointToolbox/Core/Models/SystemGroupTarget.cs new file mode 100644 index 0000000..13bdce5 --- /dev/null +++ b/SharepointToolbox/Core/Models/SystemGroupTarget.cs @@ -0,0 +1,15 @@ +using SharepointToolbox.Core.Helpers; + +namespace SharepointToolbox.Core.Models; + +/// +/// Resolved target for a SharePoint system group (Limited Access For Web/List or SharingLinks). +/// Carries the human-readable label, the navigable URL, and (for sharing links) the link type +/// so the export layer can render a clickable link in the Granted Through cell. +/// +public record SystemGroupTarget( + SystemGroupKind Kind, + string Label, // e.g. "MyList" | "Shared Documents/Folder/File.docx" | "Site - HR" + string Url, // Navigable URL of the targeted resource + string? LinkType = null // For SharingLinks: "OrganizationEdit", "AnonymousView", etc. +); diff --git a/SharepointToolbox/Core/Models/UserAccessEntry.cs b/SharepointToolbox/Core/Models/UserAccessEntry.cs index 7db095d..b3ebd34 100644 --- a/SharepointToolbox/Core/Models/UserAccessEntry.cs +++ b/SharepointToolbox/Core/Models/UserAccessEntry.cs @@ -29,5 +29,8 @@ public record UserAccessEntry( AccessType AccessType, // Direct | Group | Inherited string GrantedThrough, // "Direct Permissions" | "SharePoint Group: Members" etc. bool IsHighPrivilege, // True for Full Control, Site Collection Administrator - bool IsExternalUser // True if login contains #EXT# + bool IsExternalUser, // True if login contains #EXT# + string? TargetUrl = null, // Resolved URL when GrantedThrough is a Limited Access / SharingLinks system group + string? TargetLabel = null, // Resolved name (list title / file name / web title) + string? SharingLinkType = null // OrganizationEdit | OrganizationView | AnonymousEdit | ... ); diff --git a/SharepointToolbox/Localization/Strings.fr.resx b/SharepointToolbox/Localization/Strings.fr.resx index ab19520..ccd1a19 100644 --- a/SharepointToolbox/Localization/Strings.fr.resx +++ b/SharepointToolbox/Localization/Strings.fr.resx @@ -582,6 +582,7 @@ Cette action est irréversible. Options d'exportation Fusionner les permissions en double + Masquer les noms bruts (SharingLinks, Limited Access) Enregistrer l'app Supprimer l'app diff --git a/SharepointToolbox/Localization/Strings.resx b/SharepointToolbox/Localization/Strings.resx index d4c2dbb..74bde2a 100644 --- a/SharepointToolbox/Localization/Strings.resx +++ b/SharepointToolbox/Localization/Strings.resx @@ -582,6 +582,7 @@ This cannot be undone. Export Options Merge duplicate permissions + Hide raw system group names (SharingLinks, Limited Access) Register App Remove App diff --git a/SharepointToolbox/Services/Export/CsvExportService.cs b/SharepointToolbox/Services/Export/CsvExportService.cs index e67bb27..966b9da 100644 --- a/SharepointToolbox/Services/Export/CsvExportService.cs +++ b/SharepointToolbox/Services/Export/CsvExportService.cs @@ -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\""; } /// @@ -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\""; } /// @@ -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(); diff --git a/SharepointToolbox/Services/Export/HtmlExportService.cs b/SharepointToolbox/Services/Export/HtmlExportService.cs index cd68a43..4aeb3ca 100644 --- a/SharepointToolbox/Services/Export/HtmlExportService.cs +++ b/SharepointToolbox/Services/Export/HtmlExportService.cs @@ -27,7 +27,8 @@ public class HtmlExportService public string BuildHtml( IReadOnlyList entries, ReportBranding? branding = null, - IReadOnlyDictionary>? groupMembers = null) + IReadOnlyDictionary>? 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(""); sb.AppendLine($" {HtmlEncode(entry.ObjectType)}"); @@ -66,7 +69,7 @@ public class HtmlExportService sb.AppendLine($" {uniqueLbl}"); sb.AppendLine($" {pills}"); sb.AppendLine($" {HtmlEncode(entry.PermissionLevels)}"); - sb.AppendLine($" {HtmlEncode(entry.GrantedThrough)}"); + sb.AppendLine($" {BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}"); sb.AppendLine(""); if (subRows.Length > 0) sb.Append(subRows); } @@ -87,7 +90,8 @@ public class HtmlExportService public string BuildHtml( IReadOnlyList entries, ReportBranding? branding = null, - IReadOnlyDictionary>? groupMembers = null) + IReadOnlyDictionary>? 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(""); sb.AppendLine($" {HtmlEncode(entry.ObjectType)}"); @@ -143,7 +149,7 @@ public class HtmlExportService sb.AppendLine($" {HtmlEncode(entry.PermissionLevels)}"); sb.AppendLine($" {HtmlEncode(entry.SimplifiedLabels)}"); sb.AppendLine($" {HtmlEncode(entry.RiskLevel.ToString())}"); - sb.AppendLine($" {HtmlEncode(entry.GrantedThrough)}"); + sb.AppendLine($" {BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType, hideSystemGroupRaw)}"); sb.AppendLine(""); if (subRows.Length > 0) sb.Append(subRows); } @@ -161,9 +167,10 @@ public class HtmlExportService string filePath, CancellationToken ct, ReportBranding? branding = null, - IReadOnlyDictionary>? groupMembers = null) + IReadOnlyDictionary>? 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>? groupMembers = null) + IReadOnlyDictionary>? 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>? groupMembers = null) + IReadOnlyDictionary>? 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); } } - /// Simplified-entry split variant of . + /// Simplified-entry split variant. public async Task WriteAsync( IReadOnlyList entries, string basePath, @@ -227,11 +236,12 @@ public class HtmlExportService HtmlSplitLayout layout, CancellationToken ct, ReportBranding? branding = null, - IReadOnlyDictionary>? groupMembers = null) + IReadOnlyDictionary>? 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); } } diff --git a/SharepointToolbox/Services/Export/PermissionHtmlFragments.cs b/SharepointToolbox/Services/Export/PermissionHtmlFragments.cs index 3da4bd6..9719354 100644 --- a/SharepointToolbox/Services/Export/PermissionHtmlFragments.cs +++ b/SharepointToolbox/Services/Export/PermissionHtmlFragments.cs @@ -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>? 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($"{HtmlEncode(name)} ▼"); + pills.Append(""); + if (isResolvedSystemGroup) + { + if (!string.IsNullOrEmpty(sharingLinkType)) + pills.Append(BuildSharingLinkBadge(sharingLinkType!)); + pills.Append(HtmlEncode(targetLabel!)); + } + else + { + pills.Append(HtmlEncode(name)); + } + pills.Append(" ▼"); string memberContent; if (resolved.Count > 0) @@ -173,6 +203,14 @@ document.addEventListener('click', function(ev) { subRows.AppendLine($"{memberContent}"); grpMemIdx++; } + else if (isResolvedSystemGroup) + { + pills.Append(""); + if (!string.IsNullOrEmpty(sharingLinkType)) + pills.Append(BuildSharingLinkBadge(sharingLinkType!)); + pills.Append(HtmlEncode(targetLabel!)); + pills.Append(""); + } else { var cls = isExt ? "user-pill external-user" : "user-pill"; @@ -183,6 +221,80 @@ document.addEventListener('click', function(ev) { return (pills.ToString(), subRows.ToString()); } + /// + /// 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 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. + /// + 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(""); + sb.Append(HtmlEncode(targetLabel!)); + sb.Append(""); + } + return sb.ToString(); + } + + sb.Append("
"); + if (hasLinkType) + sb.Append(BuildSharingLinkBadge(sharingLinkType!)); + if (hasTarget) + { + sb.Append("→ "); + sb.Append(HtmlEncode(targetLabel!)); + sb.Append(""); + } + sb.Append("
"); + return sb.ToString(); + } + + /// + /// Builds the colored badge for a SharePoint sharing-link type. Translates the + /// raw linkType code (e.g. OrganizationEdit) into a human label + /// (e.g. Org link · Edit) and tints by risk tier; raw code surfaces as a + /// title tooltip so operators can still trace it back to the source. + /// + internal static string BuildSharingLinkBadge(string rawLinkType) + { + var (label, risk) = SharingLinkLabels.Describe(rawLinkType); + var (bg, fg) = SharingLinkLabels.Colors(risk); + return $"{HtmlEncode(label)}"; + } + /// Returns the CSS class for the object-type badge. internal static string ObjectTypeCss(string t) => t switch { diff --git a/SharepointToolbox/Services/Export/UserAccessCsvExportService.cs b/SharepointToolbox/Services/Export/UserAccessCsvExportService.cs index 4726e46..ad202b1 100644 --- a/SharepointToolbox/Services/Export/UserAccessCsvExportService.cs +++ b/SharepointToolbox/Services/Export/UserAccessCsvExportService.cs @@ -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\""; } /// @@ -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) })); } diff --git a/SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs b/SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs index ed941f9..01eda33 100644 --- a/SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs +++ b/SharepointToolbox/Services/Export/UserAccessHtmlExportService.cs @@ -250,7 +250,7 @@ a:hover { text-decoration: underline; } sb.AppendLine($" {objectCell}"); sb.AppendLine($" {HtmlEncode(entry.PermissionLevel)}{highIcon}"); sb.AppendLine($" {accessBadge}"); - sb.AppendLine($" {HtmlEncode(entry.GrantedThrough)}"); + sb.AppendLine($" {PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}"); sb.AppendLine(""); } } @@ -301,7 +301,7 @@ a:hover { text-decoration: underline; } sb.AppendLine($" {objectCell}"); sb.AppendLine($" {HtmlEncode(entry.PermissionLevel)}{highIcon}"); sb.AppendLine($" {accessBadge}"); - sb.AppendLine($" {HtmlEncode(entry.GrantedThrough)}"); + sb.AppendLine($" {PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}"); sb.AppendLine(""); } } @@ -579,7 +579,7 @@ a:hover { text-decoration: underline; } sb.AppendLine($" {HtmlEncode(entry.UserDisplayName)}{guestBadge}"); sb.AppendLine($" {HtmlEncode(entry.PermissionLevel)}{highIcon}"); sb.AppendLine($" {accessBadge}"); - sb.AppendLine($" {HtmlEncode(entry.GrantedThrough)}"); + sb.AppendLine($" {PermissionHtmlFragments.BuildGrantedThroughCell(entry.GrantedThrough, entry.TargetUrl, entry.TargetLabel, entry.SharingLinkType)}"); if (entry.LocationCount == 1) { diff --git a/SharepointToolbox/Services/ISystemGroupTargetResolver.cs b/SharepointToolbox/Services/ISystemGroupTargetResolver.cs new file mode 100644 index 0000000..d89275a --- /dev/null +++ b/SharepointToolbox/Services/ISystemGroupTargetResolver.cs @@ -0,0 +1,23 @@ +using Microsoft.SharePoint.Client; +using SharepointToolbox.Core.Helpers; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services; + +/// +/// Resolves the targeted resource (List, Web, File, Folder) for a SharePoint system +/// group whose name encodes a GUID — Limited Access System Group For Web/List and +/// SharingLinks.{itemId}.{type}.{shareId}. Returns null when the target is missing +/// or unreachable (deleted item, access denied, etc.) — callers fall back to the +/// raw group name. +/// +public interface ISystemGroupTargetResolver +{ + /// + /// Resolves the target for a classified system group. Cached per (ctx site, guid). + /// + Task ResolveAsync( + ClientContext ctx, + SystemGroupClassification classification, + CancellationToken ct); +} diff --git a/SharepointToolbox/Services/PermissionsService.cs b/SharepointToolbox/Services/PermissionsService.cs index d37f5b2..5069330 100644 --- a/SharepointToolbox/Services/PermissionsService.cs +++ b/SharepointToolbox/Services/PermissionsService.cs @@ -11,6 +11,16 @@ namespace SharepointToolbox.Services; /// public class PermissionsService : IPermissionsService { + private readonly ISystemGroupTargetResolver? _systemGroupResolver; + + public PermissionsService() : this(null) { } + + public PermissionsService(ISystemGroupTargetResolver? systemGroupResolver) + { + _systemGroupResolver = systemGroupResolver; + } + + /// /// Detects the SharePoint server error raised when a RoleAssignment member /// refers to a user that no longer resolves (orphaned Azure AD account). @@ -336,9 +346,18 @@ public class PermissionsService : IPermissionsService var member = ra.Member; var loginName = member.LoginName ?? string.Empty; + var memberTitle = member.Title ?? string.Empty; - // Skip sharing links groups and limited access system groups - if (PermissionEntryHelper.IsSharingLinksGroup(loginName)) + // Classify the member name. The bare "Limited Access System Group" is + // pure noise; drop it. The For-Web/For-List and SharingLinks variants + // are kept and enriched below with a resolved target URL. + var classification = PermissionEntryHelper.Classify(memberTitle); + if (classification.Kind == SystemGroupKind.None + && PermissionEntryHelper.IsBareLimitedAccessSystemGroup(loginName)) + { + continue; + } + if (classification.Kind == SystemGroupKind.LimitedAccessBare) continue; // Collect and filter permission levels @@ -362,19 +381,42 @@ public class PermissionsService : IPermissionsService // Determine how the permission was granted string grantedThrough = principalType == "SharePointGroup" - ? $"SharePoint Group: {member.Title}" + ? $"SharePoint Group: {memberTitle}" : "Direct Permissions"; + // Resolve system-group target (Limited Access For Web/List, SharingLinks) + string? targetUrl = null; + string? targetLabel = null; + string? sharingLinkType = null; + if (_systemGroupResolver is not null && classification.Kind != SystemGroupKind.None) + { + var target = await _systemGroupResolver.ResolveAsync(ctx, classification, ct); + if (target is not null) + { + targetUrl = target.Url; + targetLabel = target.Label; + sharingLinkType = target.LinkType; + } + else if (classification.Kind == SystemGroupKind.SharingLink) + { + // Target lookup failed (deleted item / no access) — still surface link type. + sharingLinkType = classification.LinkType; + } + } + entries.Add(new PermissionEntry( ObjectType: objectType, Title: title, Url: url, HasUniquePermissions: obj.HasUniqueRoleAssignments, - Users: member.Title ?? string.Empty, + Users: memberTitle, UserLogins: loginName, PermissionLevels: permLevels, GrantedThrough: grantedThrough, - PrincipalType: principalType)); + PrincipalType: principalType, + TargetUrl: targetUrl, + TargetLabel: targetLabel, + SharingLinkType: sharingLinkType)); } return entries; diff --git a/SharepointToolbox/Services/SystemGroupTargetResolver.cs b/SharepointToolbox/Services/SystemGroupTargetResolver.cs new file mode 100644 index 0000000..f222d41 --- /dev/null +++ b/SharepointToolbox/Services/SystemGroupTargetResolver.cs @@ -0,0 +1,205 @@ +using Microsoft.SharePoint.Client; +using Microsoft.SharePoint.Client.Search.Query; +using Serilog; +using SharepointToolbox.Core.Helpers; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services; + +/// +/// CSOM-backed resolver that converts the GUID/identifier embedded in a SharePoint +/// system group name into a clickable target. Results are cached per +/// (site URL, guid) to avoid repeated round-trips when the same Limited Access +/// system group recurs on many objects. +/// +/// Never throws — on any failure (not found, access denied, claims error), returns +/// null and the caller renders the raw group name. +/// +public class SystemGroupTargetResolver : ISystemGroupTargetResolver +{ + private readonly Dictionary _cache = + new(StringComparer.OrdinalIgnoreCase); + + public async Task ResolveAsync( + ClientContext ctx, + SystemGroupClassification classification, + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + var key = BuildCacheKey(ctx.Url, classification); + if (key is not null && _cache.TryGetValue(key, out var cached)) + return cached; + + SystemGroupTarget? result = null; + try + { + result = classification.Kind switch + { + SystemGroupKind.LimitedAccessWeb => await ResolveWebAsync(ctx, classification.WebId!.Value, ct), + SystemGroupKind.LimitedAccessList => await ResolveListAsync(ctx, classification.ListId!.Value, ct), + SystemGroupKind.SharingLink => await ResolveItemAsync(ctx, classification.ItemUniqueId!.Value, classification.LinkType, ct), + _ => null + }; + } + catch (Exception ex) + { + Log.Debug("System group target resolution failed for {Kind} on {Site}: {Error}", + classification.Kind, ctx.Url, ex.Message); + } + + if (key is not null) + _cache[key] = result; + return result; + } + + private static string? BuildCacheKey(string siteUrl, SystemGroupClassification c) => c.Kind switch + { + SystemGroupKind.LimitedAccessWeb => $"{siteUrl}|web|{c.WebId}", + SystemGroupKind.LimitedAccessList => $"{siteUrl}|list|{c.ListId}", + SystemGroupKind.SharingLink => $"{siteUrl}|item|{c.ItemUniqueId}", + _ => null + }; + + private static async Task ResolveWebAsync( + ClientContext ctx, Guid webId, CancellationToken ct) + { + var web = ctx.Site.OpenWebById(webId); + ctx.Load(web, w => w.Title, w => w.Url); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct); + return new SystemGroupTarget(SystemGroupKind.LimitedAccessWeb, web.Title, web.Url); + } + + private static async Task ResolveListAsync( + ClientContext ctx, Guid listId, CancellationToken ct) + { + var list = ctx.Web.Lists.GetById(listId); + ctx.Load(list, l => l.Title, l => l.DefaultViewUrl); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct); + + var url = BuildAbsoluteUrl(ctx.Url, list.DefaultViewUrl); + return new SystemGroupTarget(SystemGroupKind.LimitedAccessList, list.Title, url); + } + + private static async Task ResolveItemAsync( + ClientContext ctx, Guid itemUniqueId, string? linkType, CancellationToken ct) + { + // 1. Try as file on current web (most sharing links target files). + try + { + var file = ctx.Web.GetFileById(itemUniqueId); + ctx.Load(file, f => f.Name, f => f.ServerRelativeUrl, f => f.Exists); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct); + + if (file.Exists) + { + var url = BuildAbsoluteUrl(ctx.Url, file.ServerRelativeUrl); + return new SystemGroupTarget(SystemGroupKind.SharingLink, file.Name, url, linkType); + } + } + catch (ServerException) { /* fall through */ } + + // 2. Try as folder on current web. + try + { + var folder = ctx.Web.GetFolderById(itemUniqueId); + ctx.Load(folder, f => f.Name, f => f.ServerRelativeUrl); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct); + + var url = BuildAbsoluteUrl(ctx.Url, folder.ServerRelativeUrl); + return new SystemGroupTarget(SystemGroupKind.SharingLink, folder.Name, url, linkType); + } + catch (ServerException) { /* fall through */ } + + // 3. Search-index fallback — covers items moved to a different subsite or + // deleted recently (the index may lag the deletion by minutes/hours). + // Search is permission-trimmed, so this only returns hits the caller + // can still see; results carry a "(via index)" hint in the label. + return await TryResolveViaSearchAsync(ctx, itemUniqueId, linkType, ct); + } + + /// + /// Queries the SharePoint search index for an item by its UniqueId. Returns + /// a target with the last-indexed path and a label prefix flagging that the + /// hit came from the index (so admins know the live lookup failed). + /// + private static async Task TryResolveViaSearchAsync( + ClientContext ctx, Guid itemUniqueId, string? linkType, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + try + { + // KQL: UniqueId managed property. Braces around the GUID are how the + // SP search engine matches the canonical form for content unique ids. + var kq = new KeywordQuery(ctx) + { + QueryText = $"UniqueId:{{{itemUniqueId}}}", + RowLimit = 1, + TrimDuplicates = false + }; + kq.SelectProperties.Add("Path"); + kq.SelectProperties.Add("Title"); + kq.SelectProperties.Add("FileExtension"); + + var executor = new SearchExecutor(ctx); + var result = executor.ExecuteQuery(kq); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, ct); + + var table = result.Value + .FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults); + if (table is null || table.RowCount == 0) + return null; + + var row = ToDict(table.ResultRows.First()); + var path = row.TryGetValue("Path", out var p) ? p?.ToString() : null; + var title = row.TryGetValue("Title", out var t) ? t?.ToString() : null; + + if (string.IsNullOrEmpty(path)) + return null; + + // Derive a leaf name if Title is empty (folders often have no Title). + var leaf = !string.IsNullOrWhiteSpace(title) + ? title! + : Uri.UnescapeDataString(path.TrimEnd('/').Split('/').Last()); + + return new SystemGroupTarget( + SystemGroupKind.SharingLink, + $"{leaf} (via index)", + path, + linkType); + } + catch (Exception ex) + { + Log.Debug("UniqueId search fallback failed for {Item} on {Site}: {Error}", + itemUniqueId, ctx.Url, ex.Message); + return null; + } + } + + /// + /// CSOM SearchExecutor returns ResultRows as either generic Dictionary or legacy + /// IDictionary depending on CSOM version — normalise to a single shape. + /// + private static IDictionary ToDict(object rawRow) + { + if (rawRow is IDictionary generic) + return generic; + var dict = new Dictionary(); + if (rawRow is System.Collections.IDictionary legacy) + { + foreach (System.Collections.DictionaryEntry e in legacy) + dict[e.Key.ToString()!] = e.Value ?? string.Empty; + } + return dict; + } + + private static string BuildAbsoluteUrl(string contextUrl, string? serverRelative) + { + if (string.IsNullOrEmpty(serverRelative)) + return contextUrl; + if (Uri.TryCreate(serverRelative, UriKind.Absolute, out _)) + return serverRelative; + var uri = new Uri(contextUrl); + return $"{uri.Scheme}://{uri.Host}{serverRelative}"; + } +} diff --git a/SharepointToolbox/Services/UserAccessAuditService.cs b/SharepointToolbox/Services/UserAccessAuditService.cs index 25779f4..d87e9e1 100644 --- a/SharepointToolbox/Services/UserAccessAuditService.cs +++ b/SharepointToolbox/Services/UserAccessAuditService.cs @@ -136,7 +136,10 @@ public class UserAccessAuditService : IUserAccessAuditService AccessType: accessType, GrantedThrough: entry.GrantedThrough, IsHighPrivilege: HighPrivilegeLevels.Contains(trimmedLevel), - IsExternalUser: PermissionEntryHelper.IsExternalUser(login)); + IsExternalUser: PermissionEntryHelper.IsExternalUser(login), + TargetUrl: entry.TargetUrl, + TargetLabel: entry.TargetLabel, + SharingLinkType: entry.SharingLinkType); } } } diff --git a/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs index bd17501..01edf24 100644 --- a/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs @@ -45,6 +45,15 @@ public partial class PermissionsViewModel : FeatureViewModelBase [ObservableProperty] private bool _mergePermissions; + /// + /// When true, the HTML report hides the raw "SharePoint Group: SharingLinks.{guid}…" + /// / "Limited Access System Group For Web|List {guid}" text for rows where the + /// system-group target was resolved — only the link-type badge and target link are shown. + /// Rows whose target could not be resolved still display the raw name as a fallback. + /// + [ObservableProperty] + private bool _hideSystemGroupRaw = true; + [ObservableProperty] private bool _includeSubsites; @@ -498,9 +507,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase } if (IsSimplifiedMode && SimplifiedResults.Count > 0) - await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers); + await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers, HideSystemGroupRaw); else - await _htmlExportService.WriteAsync((IReadOnlyList)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers); + await _htmlExportService.WriteAsync((IReadOnlyList)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers, HideSystemGroupRaw); OpenFile(dialog.FileName); } catch (Exception ex) diff --git a/SharepointToolbox/Views/Tabs/PermissionsView.xaml b/SharepointToolbox/Views/Tabs/PermissionsView.xaml index 248bc89..8879f43 100644 --- a/SharepointToolbox/Views/Tabs/PermissionsView.xaml +++ b/SharepointToolbox/Views/Tabs/PermissionsView.xaml @@ -91,7 +91,9 @@ DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8"> + IsChecked="{Binding MergePermissions}" Margin="0,0,0,4" /> +