From 12dd1de9f298c1bd6f9b6a9567c5b23b28ac85ae Mon Sep 17 00:00:00 2001 From: Dev Date: Mon, 20 Apr 2026 11:23:11 +0200 Subject: [PATCH] chore: release v2.4 - Add theme system (Dark/Light palettes, ModernTheme, ThemeManager) - Add InputDialog, Spinner common view - Add DuplicatesCsvExportService - Refresh views, dialogs, and view models across tabs - Update localization strings (en/fr) - Tweak services (transfer, permissions, search, user access, ownership elevation, bulk operations) Co-Authored-By: Claude Opus 4.7 (1M context) --- .editorconfig | 16 + .../SharePointPaginationHelperTests.cs | 21 +- .../Services/Export/HtmlExportServiceTests.cs | 7 +- SharepointToolbox.Tests/TestResults/test.trx | 2636 +++++++++++++++++ .../Dialogs/SitePickerDialogLogicTests.cs | 88 + .../ViewModels/FeatureViewModelBaseTests.cs | 18 +- ...ileManagementViewModelRegistrationTests.cs | 13 +- .../ViewModels/SettingsViewModelLogoTests.cs | 2 +- .../SettingsViewModelOwnershipTests.cs | 4 +- SharepointToolbox/App.xaml | 29 +- SharepointToolbox/App.xaml.cs | 14 + .../Helpers/SharePointPaginationHelper.cs | 73 +- SharepointToolbox/Core/Models/AppSettings.cs | 1 + .../Core/Models/DuplicateItem.cs | 4 + .../Core/Models/OperationProgress.cs | 4 +- SharepointToolbox/Core/Models/SiteInfo.cs | 41 +- SharepointToolbox/Core/Models/TransferJob.cs | 20 + .../Infrastructure/Auth/GraphClientFactory.cs | 58 +- .../Localization/Strings.fr.resx | 217 +- SharepointToolbox/Localization/Strings.resx | 217 +- SharepointToolbox/MainWindow.xaml | 5 +- .../Services/AppRegistrationService.cs | 138 +- .../Services/BulkOperationRunner.cs | 59 +- .../Services/DuplicatesService.cs | 104 +- .../Export/BulkResultCsvExportService.cs | 29 +- .../Services/Export/CsvExportService.cs | 75 +- .../Services/Export/CsvSanitizer.cs | 47 + .../Export/DuplicatesCsvExportService.cs | 112 + .../Export/DuplicatesHtmlExportService.cs | 84 +- .../Services/Export/ExportFileWriter.cs | 27 + .../Services/Export/HtmlExportService.cs | 605 ++-- .../Export/PermissionHtmlFragments.cs | 207 ++ .../Services/Export/ReportSplitHelper.cs | 199 ++ .../Services/Export/ReportSplitMode.cs | 16 + .../Services/Export/SearchCsvExportService.cs | 19 +- .../Export/SearchHtmlExportService.cs | 52 +- .../Export/StorageCsvExportService.cs | 76 +- .../Export/StorageHtmlExportService.cs | 139 +- .../Export/UserAccessCsvExportService.cs | 120 +- .../Export/UserAccessHtmlExportService.cs | 211 +- .../Services/FileTransferService.cs | 212 +- .../Services/IAppRegistrationService.cs | 18 +- .../Services/IUserAccessAuditService.cs | 3 +- .../Services/OwnershipElevationService.cs | 11 + .../Services/PermissionsService.cs | 46 +- SharepointToolbox/Services/SearchService.cs | 21 +- SharepointToolbox/Services/SettingsService.cs | 10 + .../Services/SharePointGroupResolver.cs | 27 + SharepointToolbox/Services/SiteListService.cs | 7 +- SharepointToolbox/Services/StorageService.cs | 151 +- SharepointToolbox/Services/TemplateService.cs | 78 +- SharepointToolbox/Services/ThemeManager.cs | 135 + .../Services/UserAccessAuditService.cs | 20 +- SharepointToolbox/Themes/DarkPalette.xaml | 27 + SharepointToolbox/Themes/LightPalette.xaml | 26 + SharepointToolbox/Themes/ModernTheme.xaml | 1407 +++++++++ .../Dialogs/SitePickerDialogLogic.cs | 104 + .../ViewModels/FeatureViewModelBase.cs | 25 +- .../ViewModels/ProfileManagementViewModel.cs | 97 +- .../ViewModels/Tabs/BulkMembersViewModel.cs | 4 +- .../ViewModels/Tabs/BulkSitesViewModel.cs | 4 +- .../ViewModels/Tabs/DuplicatesViewModel.cs | 41 +- .../Tabs/FolderStructureViewModel.cs | 9 +- .../ViewModels/Tabs/PermissionsViewModel.cs | 105 +- .../ViewModels/Tabs/SearchViewModel.cs | 5 +- .../ViewModels/Tabs/SettingsViewModel.cs | 32 +- .../ViewModels/Tabs/StorageViewModel.cs | 112 +- .../ViewModels/Tabs/TemplatesViewModel.cs | 74 +- .../ViewModels/Tabs/TransferViewModel.cs | 86 +- .../Tabs/UserAccessAuditViewModel.cs | 86 +- SharepointToolbox/Views/Common/Spinner.xaml | 26 + .../Views/Common/Spinner.xaml.cs | 11 + .../Dialogs/ConfirmBulkOperationDialog.xaml | 3 + .../Views/Dialogs/FolderBrowserDialog.xaml | 13 +- .../Views/Dialogs/FolderBrowserDialog.xaml.cs | 286 +- .../Views/Dialogs/InputDialog.xaml | 24 + .../Views/Dialogs/InputDialog.xaml.cs | 28 + .../Dialogs/ProfileManagementDialog.xaml | 27 +- .../Views/Dialogs/SitePickerDialog.xaml | 105 +- .../Views/Dialogs/SitePickerDialog.xaml.cs | 118 +- .../Views/Tabs/BulkMembersView.xaml | 17 +- .../Views/Tabs/BulkSitesView.xaml | 17 +- .../Views/Tabs/DuplicatesView.xaml | 47 +- .../Views/Tabs/FolderStructureView.xaml | 29 +- .../Views/Tabs/PermissionsView.xaml | 60 +- SharepointToolbox/Views/Tabs/SearchView.xaml | 2 +- .../Views/Tabs/SettingsView.xaml | 25 +- SharepointToolbox/Views/Tabs/StorageView.xaml | 58 +- .../Views/Tabs/StorageView.xaml.cs | 22 +- .../Views/Tabs/TemplatesView.xaml | 17 +- .../Views/Tabs/TransferView.xaml | 31 +- .../Views/Tabs/TransferView.xaml.cs | 11 +- .../Views/Tabs/UserAccessAuditView.xaml | 100 +- 93 files changed, 8708 insertions(+), 1159 deletions(-) create mode 100644 .editorconfig create mode 100644 SharepointToolbox.Tests/TestResults/test.trx create mode 100644 SharepointToolbox.Tests/ViewModels/Dialogs/SitePickerDialogLogicTests.cs create mode 100644 SharepointToolbox/Services/Export/CsvSanitizer.cs create mode 100644 SharepointToolbox/Services/Export/DuplicatesCsvExportService.cs create mode 100644 SharepointToolbox/Services/Export/ExportFileWriter.cs create mode 100644 SharepointToolbox/Services/Export/PermissionHtmlFragments.cs create mode 100644 SharepointToolbox/Services/Export/ReportSplitHelper.cs create mode 100644 SharepointToolbox/Services/Export/ReportSplitMode.cs create mode 100644 SharepointToolbox/Services/ThemeManager.cs create mode 100644 SharepointToolbox/Themes/DarkPalette.xaml create mode 100644 SharepointToolbox/Themes/LightPalette.xaml create mode 100644 SharepointToolbox/Themes/ModernTheme.xaml create mode 100644 SharepointToolbox/ViewModels/Dialogs/SitePickerDialogLogic.cs create mode 100644 SharepointToolbox/Views/Common/Spinner.xaml create mode 100644 SharepointToolbox/Views/Common/Spinner.xaml.cs create mode 100644 SharepointToolbox/Views/Dialogs/InputDialog.xaml create mode 100644 SharepointToolbox/Views/Dialogs/InputDialog.xaml.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..48369ab --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*.cs] +indent_style = space +indent_size = 4 +end_of_line = crlf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +# ConfigureAwait(false) is required in non-UI service/infrastructure code so +# callers that may still sync-wait cannot deadlock on the WPF dispatcher. +# Scoped to Services/ and Infrastructure/ — ViewModels legitimately resume on +# the UI thread for INotifyPropertyChanged updates. +[{SharepointToolbox/Services/**.cs,SharepointToolbox/Infrastructure/**.cs}] +dotnet_diagnostic.CA2007.severity = suggestion diff --git a/SharepointToolbox.Tests/Helpers/SharePointPaginationHelperTests.cs b/SharepointToolbox.Tests/Helpers/SharePointPaginationHelperTests.cs index 4a89ca0..297b465 100644 --- a/SharepointToolbox.Tests/Helpers/SharePointPaginationHelperTests.cs +++ b/SharepointToolbox.Tests/Helpers/SharePointPaginationHelperTests.cs @@ -9,21 +9,21 @@ public class SharePointPaginationHelperTests public void BuildPagedViewXml_NullInput_ReturnsViewWithRowLimit() { var result = SharePointPaginationHelper.BuildPagedViewXml(null, 2000); - Assert.Equal("2000", result); + Assert.Equal("2000", result); } [Fact] public void BuildPagedViewXml_EmptyString_ReturnsViewWithRowLimit() { var result = SharePointPaginationHelper.BuildPagedViewXml("", 2000); - Assert.Equal("2000", result); + Assert.Equal("2000", result); } [Fact] public void BuildPagedViewXml_WhitespaceOnly_ReturnsViewWithRowLimit() { var result = SharePointPaginationHelper.BuildPagedViewXml(" ", 2000); - Assert.Equal("2000", result); + Assert.Equal("2000", result); } [Fact] @@ -31,7 +31,15 @@ public class SharePointPaginationHelperTests { var input = "100"; var result = SharePointPaginationHelper.BuildPagedViewXml(input, 2000); - Assert.Equal("2000", result); + Assert.Equal("2000", result); + } + + [Fact] + public void BuildPagedViewXml_ExistingPagedRowLimit_ReplacesWithNewSize() + { + var input = "100"; + var result = SharePointPaginationHelper.BuildPagedViewXml(input, 5000); + Assert.Equal("5000", result); } [Fact] @@ -39,10 +47,9 @@ public class SharePointPaginationHelperTests { var input = ""; var result = SharePointPaginationHelper.BuildPagedViewXml(input, 2000); - Assert.Contains("2000", result); + Assert.Contains("2000", result); Assert.EndsWith("", result); - // Ensure RowLimit is inserted before the closing - var rowLimitIndex = result.IndexOf("2000", StringComparison.Ordinal); + var rowLimitIndex = result.IndexOf("2000", StringComparison.Ordinal); var closingViewIndex = result.LastIndexOf("", StringComparison.Ordinal); Assert.True(rowLimitIndex < closingViewIndex, "RowLimit should appear before "); } diff --git a/SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs b/SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs index 2889bf6..5ec3055 100644 --- a/SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs +++ b/SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs @@ -115,7 +115,7 @@ public class HtmlExportServiceTests var svc = new HtmlExportService(); var html = svc.BuildHtml(new[] { entry }, null, groupMembers); - Assert.Contains("onclick=\"toggleGroup('grpmem0')\"", html); + Assert.Contains("data-group-target=\"grpmem0\"", html); Assert.Contains("class=\"user-pill group-expandable\"", html); } @@ -152,7 +152,8 @@ public class HtmlExportServiceTests var svc = new HtmlExportService(); var html = svc.BuildHtml(new[] { entry }, null, groupMembers); - Assert.Contains("function toggleGroup", html); + Assert.Contains("data-group-target", html); + Assert.Contains("getAttribute('data-group-target')", html); } [Fact] @@ -165,7 +166,7 @@ public class HtmlExportServiceTests var svc = new HtmlExportService(); var html = svc.BuildHtml(new[] { simplifiedEntry }, null, groupMembers); - Assert.Contains("onclick=\"toggleGroup('grpmem0')\"", html); + Assert.Contains("data-group-target=\"grpmem0\"", html); Assert.Contains("class=\"user-pill group-expandable\"", html); } } diff --git a/SharepointToolbox.Tests/TestResults/test.trx b/SharepointToolbox.Tests/TestResults/test.trx new file mode 100644 index 0000000..4198ece --- /dev/null +++ b/SharepointToolbox.Tests/TestResults/test.trx @@ -0,0 +1,2636 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Requires integration test with real Graph client. Intended behaviour: IProgress<int>.Report is called once per user with an incrementing count (1, 2, 3, ...). + + + + + + + + + Requires live CSOM context — covered by Plan 03-04 implementation + + + + + + + + Requires live SharePoint admin context + + + + + + + + + + + + + + + + + + + + + + + + + + Requires live SharePoint admin context + + + + + + + + + + + + + + + + + + + + + + + + + Requires live CSOM context — covered by Plan 03-04 implementation + + + + + + + + + + + + Requires live SharePoint tenant + + + + + + + + + + + + + + + + + + + Requires live CSOM context — covered by Plan 03-04 implementation + + + + + + + + + + + + + + + + + + + + + + + Requires live SharePoint tenant + + + + + + + + + + + + Requires live CSOM context — covered by Plan 03-04 implementation + + + + + + + + Requires live CSOM context — covered by Plan 03-02 implementation + + + + + + + + + + + + + + + + + + + + + + + + + + + Requires live SharePoint tenant + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Requires live SharePoint tenant + + + + + + + + + + + + + + + + Requires Graph permissions - Group.ReadWrite.All + + + + + + + + + + Requires live CSOM context — covered by Plan 03-02 implementation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Requires live CSOM context — covered by Plan 02 implementation + + + + + + + + + + + + + + + + + + Requires live SharePoint admin context + + + + + + + + + + + + + + Requires integration test with real Graph client. Intended behaviour: when CancellationToken is cancelled during iteration, the callback returns false and iteration stops, returning partial results (or OperationCanceledException if cancellation fires before first page). + + + + + + + + Requires live SharePoint tenant + + + + + + + + + Requires live CSOM context — covered by Plan 02 implementation + + + + + + + + + Requires live SharePoint admin context + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Requires live SharePoint tenant + + + + + + + + + + + + + + + + + + + + + + + + + + + + Requires integration test with real Graph client. Intended behaviour: when Graph returns null response, GetUsersAsync returns an empty IReadOnlyList without throwing. + + + + + + + + + + Requires interactive MSAL — integration test only + + + + + + + + + + Requires live SharePoint tenant and Graph permissions + + + + + + + + + + + + + + + + + + Requires live SP tenant — verify case-insensitive lookup with real data + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Requires live SP tenant — run manually against a real ClientContext + + + + + + + + + + + Requires live CSOM context — covered by Plan 03-04 implementation + + + + + + + + + Requires integration test with real Graph client — PageIterator.CreatePageIterator uses internal GraphServiceClient request execution that cannot be mocked via Moq. Intended behaviour: returns all users matching filter across all pages, correctly mapping all 5 fields per user. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.4+50e68bbb8b (64-bit .NET 10.0.4) +[xUnit.net 00:00:00.06] Discovering: SharepointToolbox.Tests +[xUnit.net 00:00:00.12] Discovered: SharepointToolbox.Tests +[xUnit.net 00:00:00.15] Starting: SharepointToolbox.Tests +[xUnit.net 00:00:00.26] Requires live SharePoint tenant +Test 'SharepointToolbox.Tests.Services.FolderStructureServiceTests.CreateFoldersAsync_ValidRows_CreatesFolders' was skipped in the test run. +[xUnit.net 00:00:00.47] Requires interactive MSAL — integration test only +Test 'SharepointToolbox.Tests.Auth.SessionManagerTests.GetOrCreateContextAsync_CreatesContext' was skipped in the test run. +[xUnit.net 00:00:00.47] Requires live SharePoint tenant +[xUnit.net 00:00:00.47] Requires live SharePoint admin context +Test 'SharepointToolbox.Tests.Services.TemplateServiceTests.CaptureTemplateAsync_CapturesLibrariesAndFolders' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.TemplateServiceTests.ApplyTemplateAsync_CreatesTeamSiteWithStructure' was skipped in the test run. +[xUnit.net 00:00:00.50] Requires live CSOM context — covered by Plan 03-02 implementation +[xUnit.net 00:00:00.51] Requires live CSOM context — covered by Plan 03-02 implementation +Test 'SharepointToolbox.Tests.Services.StorageServiceTests.CollectStorageAsync_WithFolderDepth1_ReturnsSubfolderNodes' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.StorageServiceTests.CollectStorageAsync_ReturnsLibraryNodes_ForDocumentLibraries' was skipped in the test run. +[xUnit.net 00:00:00.57] Requires live CSOM context — covered by Plan 02 implementation +[xUnit.net 00:00:00.57] Requires live CSOM context — covered by Plan 02 implementation +Test 'SharepointToolbox.Tests.Services.PermissionsServiceTests.ScanSiteAsync_ReturnsPermissionEntries_ForMockedSite' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.PermissionsServiceTests.ScanSiteAsync_WithIncludeInheritedFalse_SkipsItemsWithoutUniquePermissions' was skipped in the test run. +[xUnit.net 00:00:00.58] Requires live CSOM context — covered by Plan 03-04 implementation +Test 'SharepointToolbox.Tests.Services.DuplicatesServiceTests.ScanDuplicatesAsync_Folders_UsesCamlFSObjType1' was skipped in the test run. +[xUnit.net 00:00:00.58] Requires live CSOM context — covered by Plan 03-04 implementation +[xUnit.net 00:00:00.58] Requires live CSOM context — covered by Plan 03-04 implementation +[xUnit.net 00:00:00.58] Requires live CSOM context — covered by Plan 03-04 implementation +[xUnit.net 00:00:00.58] Requires live CSOM context — covered by Plan 03-04 implementation +Test 'SharepointToolbox.Tests.Services.DuplicatesServiceTests.ScanDuplicatesAsync_Files_GroupsByCompositeKey' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.SearchServiceTests.SearchFilesAsync_PaginationStopsAt50000' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.SearchServiceTests.SearchFilesAsync_FiltersVersionHistoryPaths' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.SearchServiceTests.SearchFilesAsync_WithExtensionFilter_BuildsCorrectKql' was skipped in the test run. +[xUnit.net 00:00:00.62] Requires live SharePoint tenant +[xUnit.net 00:00:00.62] Requires live SharePoint tenant and Graph permissions +[xUnit.net 00:00:00.62] Requires Graph permissions - Group.ReadWrite.All +[xUnit.net 00:00:00.62] Requires live SharePoint admin context +Test 'SharepointToolbox.Tests.Services.BulkMemberServiceTests.AddMembersAsync_InvalidEmail_ReportsPerItemError' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.BulkMemberServiceTests.AddMembersAsync_ValidRows_AddsToGroups' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.BulkMemberServiceTests.AddMembersAsync_M365Group_UsesGraphApi' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.BulkSiteServiceTests.CreateSitesAsync_CommunicationSite_CreatesWithUrl' was skipped in the test run. +[xUnit.net 00:00:00.62] Requires live SharePoint admin context +[xUnit.net 00:00:00.62] Requires live SharePoint admin context +[xUnit.net 00:00:00.62] Requires live SharePoint tenant +Test 'SharepointToolbox.Tests.Services.BulkSiteServiceTests.CreateSitesAsync_MixedTypes_HandlesEachCorrectly' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.BulkSiteServiceTests.CreateSitesAsync_TeamSite_CreatesWithOwners' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.FileTransferServiceTests.TransferAsync_SkipConflict_DoesNotOverwrite' was skipped in the test run. +[xUnit.net 00:00:00.63] Requires live SharePoint tenant +[xUnit.net 00:00:00.63] Requires live SharePoint tenant +Test 'SharepointToolbox.Tests.Services.FileTransferServiceTests.TransferAsync_CopyMode_CopiesFiles' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.FileTransferServiceTests.TransferAsync_MoveMode_DeletesSourceAfterCopy' was skipped in the test run. +[xUnit.net 00:00:00.70] Requires integration test with real Graph client. Intended behaviour: when CancellationToken is cancelled during iteration, the callback returns false and iteration stops, returning partial results (or OperationCanceledException if cancellation fires before first page). +Test 'SharepointToolbox.Tests.Services.GraphUserDirectoryServiceTests.GetUsersAsync_CancelledToken_StopsIteration' was skipped in the test run. +[xUnit.net 00:00:00.70] Requires integration test with real Graph client — PageIterator.CreatePageIterator uses internal GraphServiceClient request execution that cannot be mocked via Moq. Intended behaviour: returns all users matching filter across all pages, correctly mapping all 5 fields per user. +[xUnit.net 00:00:00.70] Requires integration test with real Graph client. Intended behaviour: when Graph returns null response, GetUsersAsync returns an empty IReadOnlyList without throwing. +[xUnit.net 00:00:00.70] Requires integration test with real Graph client. Intended behaviour: IProgress<int>.Report is called once per user with an incrementing count (1, 2, 3, ...). +Test 'SharepointToolbox.Tests.Services.GraphUserDirectoryServiceTests.GetUsersAsync_SinglePage_ReturnsMappedUsers' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.GraphUserDirectoryServiceTests.GetUsersAsync_NullResponse_ReturnsEmptyList' was skipped in the test run. +Test 'SharepointToolbox.Tests.Services.GraphUserDirectoryServiceTests.GetUsersAsync_ReportsProgressWithIncrementingCount' was skipped in the test run. +[xUnit.net 00:00:00.71] Requires live SP tenant — verify case-insensitive lookup with real data +Test 'SharepointToolbox.Tests.Services.SharePointGroupResolverTests.ResolveGroupsAsync_LookupDifferentCasing_FindsGroup' was skipped in the test run. +[xUnit.net 00:00:00.71] Requires live SP tenant — run manually against a real ClientContext +Test 'SharepointToolbox.Tests.Services.SharePointGroupResolverTests.ResolveGroupsAsync_KnownGroup_ReturnsMembers' was skipped in the test run. +[xUnit.net 00:00:01.34] Finished: SharepointToolbox.Tests + + + + + [xUnit.net 00:00:00.26] SharepointToolbox.Tests.Services.FolderStructureServiceTests.CreateFoldersAsync_ValidRows_CreatesFolders [SKIP] + + + [xUnit.net 00:00:00.47] SharepointToolbox.Tests.Auth.SessionManagerTests.GetOrCreateContextAsync_CreatesContext [SKIP] + + + [xUnit.net 00:00:00.47] SharepointToolbox.Tests.Services.TemplateServiceTests.CaptureTemplateAsync_CapturesLibrariesAndFolders [SKIP] + + + [xUnit.net 00:00:00.47] SharepointToolbox.Tests.Services.TemplateServiceTests.ApplyTemplateAsync_CreatesTeamSiteWithStructure [SKIP] + + + [xUnit.net 00:00:00.50] SharepointToolbox.Tests.Services.StorageServiceTests.CollectStorageAsync_WithFolderDepth1_ReturnsSubfolderNodes [SKIP] + + + [xUnit.net 00:00:00.51] SharepointToolbox.Tests.Services.StorageServiceTests.CollectStorageAsync_ReturnsLibraryNodes_ForDocumentLibraries [SKIP] + + + [xUnit.net 00:00:00.57] SharepointToolbox.Tests.Services.PermissionsServiceTests.ScanSiteAsync_ReturnsPermissionEntries_ForMockedSite [SKIP] + + + [xUnit.net 00:00:00.57] SharepointToolbox.Tests.Services.PermissionsServiceTests.ScanSiteAsync_WithIncludeInheritedFalse_SkipsItemsWithoutUniquePermissions [SKIP] + + + [xUnit.net 00:00:00.58] SharepointToolbox.Tests.Services.DuplicatesServiceTests.ScanDuplicatesAsync_Folders_UsesCamlFSObjType1 [SKIP] + + + [xUnit.net 00:00:00.58] SharepointToolbox.Tests.Services.DuplicatesServiceTests.ScanDuplicatesAsync_Files_GroupsByCompositeKey [SKIP] + + + [xUnit.net 00:00:00.58] SharepointToolbox.Tests.Services.SearchServiceTests.SearchFilesAsync_PaginationStopsAt50000 [SKIP] + + + [xUnit.net 00:00:00.58] SharepointToolbox.Tests.Services.SearchServiceTests.SearchFilesAsync_FiltersVersionHistoryPaths [SKIP] + + + [xUnit.net 00:00:00.58] SharepointToolbox.Tests.Services.SearchServiceTests.SearchFilesAsync_WithExtensionFilter_BuildsCorrectKql [SKIP] + + + [xUnit.net 00:00:00.62] SharepointToolbox.Tests.Services.BulkMemberServiceTests.AddMembersAsync_InvalidEmail_ReportsPerItemError [SKIP] + + + [xUnit.net 00:00:00.62] SharepointToolbox.Tests.Services.BulkMemberServiceTests.AddMembersAsync_ValidRows_AddsToGroups [SKIP] + + + [xUnit.net 00:00:00.62] SharepointToolbox.Tests.Services.BulkMemberServiceTests.AddMembersAsync_M365Group_UsesGraphApi [SKIP] + + + [xUnit.net 00:00:00.62] SharepointToolbox.Tests.Services.BulkSiteServiceTests.CreateSitesAsync_CommunicationSite_CreatesWithUrl [SKIP] + + + [xUnit.net 00:00:00.62] SharepointToolbox.Tests.Services.BulkSiteServiceTests.CreateSitesAsync_MixedTypes_HandlesEachCorrectly [SKIP] + + + [xUnit.net 00:00:00.62] SharepointToolbox.Tests.Services.BulkSiteServiceTests.CreateSitesAsync_TeamSite_CreatesWithOwners [SKIP] + + + [xUnit.net 00:00:00.62] SharepointToolbox.Tests.Services.FileTransferServiceTests.TransferAsync_SkipConflict_DoesNotOverwrite [SKIP] + + + [xUnit.net 00:00:00.63] SharepointToolbox.Tests.Services.FileTransferServiceTests.TransferAsync_CopyMode_CopiesFiles [SKIP] + + + [xUnit.net 00:00:00.63] SharepointToolbox.Tests.Services.FileTransferServiceTests.TransferAsync_MoveMode_DeletesSourceAfterCopy [SKIP] + + + [xUnit.net 00:00:00.70] SharepointToolbox.Tests.Services.GraphUserDirectoryServiceTests.GetUsersAsync_CancelledToken_StopsIteration [SKIP] + + + [xUnit.net 00:00:00.70] SharepointToolbox.Tests.Services.GraphUserDirectoryServiceTests.GetUsersAsync_SinglePage_ReturnsMappedUsers [SKIP] + + + [xUnit.net 00:00:00.70] SharepointToolbox.Tests.Services.GraphUserDirectoryServiceTests.GetUsersAsync_NullResponse_ReturnsEmptyList [SKIP] + + + [xUnit.net 00:00:00.70] SharepointToolbox.Tests.Services.GraphUserDirectoryServiceTests.GetUsersAsync_ReportsProgressWithIncrementingCount [SKIP] + + + [xUnit.net 00:00:00.71] SharepointToolbox.Tests.Services.SharePointGroupResolverTests.ResolveGroupsAsync_LookupDifferentCasing_FindsGroup [SKIP] + + + [xUnit.net 00:00:00.71] SharepointToolbox.Tests.Services.SharePointGroupResolverTests.ResolveGroupsAsync_KnownGroup_ReturnsMembers [SKIP] + + + + \ No newline at end of file diff --git a/SharepointToolbox.Tests/ViewModels/Dialogs/SitePickerDialogLogicTests.cs b/SharepointToolbox.Tests/ViewModels/Dialogs/SitePickerDialogLogicTests.cs new file mode 100644 index 0000000..afb634c --- /dev/null +++ b/SharepointToolbox.Tests/ViewModels/Dialogs/SitePickerDialogLogicTests.cs @@ -0,0 +1,88 @@ +using System.ComponentModel; +using SharepointToolbox.ViewModels.Dialogs; +using SharepointToolbox.Views.Dialogs; +using Xunit; + +namespace SharepointToolbox.Tests.ViewModels.Dialogs; + +public class SitePickerDialogLogicTests +{ + private static SitePickerItem Item(string url, string title, long sizeMb = 0, string template = "") + => new(url, title, sizeMb, 0, template); + + [Fact] + public void ApplyFilter_TextFilter_MatchesUrlOrTitle_CaseInsensitive() + { + var items = new[] + { + Item("https://t/sites/hr", "HR Team"), + Item("https://t/sites/finance", "Finance"), + }; + + var result = SitePickerDialogLogic.ApplyFilter(items, "fINaNce", 0, long.MaxValue, "All").ToList(); + + Assert.Single(result); + Assert.Equal("Finance", result[0].Title); + } + + [Fact] + public void ApplyFilter_SizeRange_FiltersInclusively() + { + var items = new[] + { + Item("a", "A", sizeMb: 100), + Item("b", "B", sizeMb: 500), + Item("c", "C", sizeMb: 1200), + }; + + var result = SitePickerDialogLogic.ApplyFilter(items, "", 100, 600, "All").ToList(); + + Assert.Equal(2, result.Count); + Assert.Contains(result, i => i.Title == "A"); + Assert.Contains(result, i => i.Title == "B"); + } + + [Fact] + public void ApplyFilter_KindAll_SkipsKindCheck() + { + var items = new[] { Item("a", "A", template: "STS#3"), Item("b", "B", template: "GROUP#0") }; + + var result = SitePickerDialogLogic.ApplyFilter(items, "", 0, long.MaxValue, "All").ToList(); + + Assert.Equal(2, result.Count); + } + + [Fact] + public void ApplySort_UnknownColumn_ReturnsInputUnchanged() + { + var items = new[] { Item("b", "B"), Item("a", "A") }; + + var result = SitePickerDialogLogic.ApplySort(items, "Nonexistent", ListSortDirection.Ascending).ToList(); + + Assert.Equal("B", result[0].Title); + Assert.Equal("A", result[1].Title); + } + + [Fact] + public void ApplySort_Title_Ascending_And_Descending() + { + var items = new[] { Item("b", "B"), Item("a", "A"), Item("c", "C") }; + + var asc = SitePickerDialogLogic.ApplySort(items, "Title", ListSortDirection.Ascending).ToList(); + var desc = SitePickerDialogLogic.ApplySort(items, "Title", ListSortDirection.Descending).ToList(); + + Assert.Equal(new[] { "A", "B", "C" }, asc.Select(i => i.Title)); + Assert.Equal(new[] { "C", "B", "A" }, desc.Select(i => i.Title)); + } + + [Theory] + [InlineData("", 42L, 42L)] + [InlineData(" ", 42L, 42L)] + [InlineData("not-a-number", 42L, 42L)] + [InlineData("100", 42L, 100L)] + [InlineData(" 100 ", 42L, 100L)] + public void ParseLongOrDefault_HandlesEmptyAndInvalid(string input, long fallback, long expected) + { + Assert.Equal(expected, SitePickerDialogLogic.ParseLongOrDefault(input, fallback)); + } +} diff --git a/SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs b/SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs index 764afbb..6aa6459 100644 --- a/SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs +++ b/SharepointToolbox.Tests/ViewModels/FeatureViewModelBaseTests.cs @@ -44,19 +44,25 @@ public class FeatureViewModelBaseTests public async Task ProgressValue_AndStatusMessage_UpdateViaIProgress() { var vm = new TestViewModel(); + int midProgress = -1; + string? midStatus = null; + vm.OperationFunc = async (ct, progress) => { progress.Report(new OperationProgress(50, 100, "halfway")); - await Task.Yield(); + // Let the Progress callback dispatch before sampling. + await Task.Delay(20, ct); + midProgress = vm.ProgressValue; + midStatus = vm.StatusMessage; }; await vm.RunCommand.ExecuteAsync(null); - // Allow dispatcher to process - await Task.Delay(20); - - Assert.Equal(50, vm.ProgressValue); - Assert.Equal("halfway", vm.StatusMessage); + // Mid-operation snapshot confirms IProgress reaches bound properties. + // Post-completion, FeatureViewModelBase snaps to 100% / "Complete" + // so stale "Scanning X" labels don't linger after a successful run. + Assert.Equal(50, midProgress); + Assert.Equal("halfway", midStatus); } [Fact] diff --git a/SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelRegistrationTests.cs b/SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelRegistrationTests.cs index dcfb981..1c19152 100644 --- a/SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelRegistrationTests.cs +++ b/SharepointToolbox.Tests/ViewModels/ProfileManagementViewModelRegistrationTests.cs @@ -87,11 +87,11 @@ public class ProfileManagementViewModelRegistrationTests : IDisposable } [Fact] - public async Task RegisterApp_ShowsFallback_WhenNotAdmin() + public async Task RegisterApp_ShowsFallback_WhenGraphReturnsFallbackRequired() { _mockAppReg - .Setup(s => s.IsGlobalAdminAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(false); + .Setup(s => s.RegisterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(AppRegistrationResult.FallbackRequired()); var vm = CreateViewModel(); vm.SelectedProfile = MakeProfile(appId: null); @@ -105,10 +105,7 @@ public class ProfileManagementViewModelRegistrationTests : IDisposable public async Task RegisterApp_SetsAppId_OnSuccess() { _mockAppReg - .Setup(s => s.IsGlobalAdminAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(true); - _mockAppReg - .Setup(s => s.RegisterAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(s => s.RegisterAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(AppRegistrationResult.Success("new-app-id-123")); var profileService = new ProfileService(new ProfileRepository(_tempFile)); @@ -132,7 +129,7 @@ public class ProfileManagementViewModelRegistrationTests : IDisposable public async Task RemoveApp_ClearsAppId() { _mockAppReg - .Setup(s => s.RemoveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(s => s.RemoveAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); _mockAppReg .Setup(s => s.ClearMsalSessionAsync(It.IsAny(), It.IsAny())) diff --git a/SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs b/SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs index 62090ea..ef99c5a 100644 --- a/SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs +++ b/SharepointToolbox.Tests/ViewModels/SettingsViewModelLogoTests.cs @@ -32,7 +32,7 @@ public class SettingsViewModelLogoTests : IDisposable var settingsService = new SettingsService(new SettingsRepository(_tempFile)); var mockBranding = brandingService ?? new Mock().Object; var logger = NullLogger.Instance; - return new SettingsViewModel(settingsService, mockBranding, logger); + return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger.Instance), logger); } [Fact] diff --git a/SharepointToolbox.Tests/ViewModels/SettingsViewModelOwnershipTests.cs b/SharepointToolbox.Tests/ViewModels/SettingsViewModelOwnershipTests.cs index 73e271a..3804d24 100644 --- a/SharepointToolbox.Tests/ViewModels/SettingsViewModelOwnershipTests.cs +++ b/SharepointToolbox.Tests/ViewModels/SettingsViewModelOwnershipTests.cs @@ -30,7 +30,7 @@ public class SettingsViewModelOwnershipTests : IDisposable var settingsService = new SettingsService(new SettingsRepository(_tempFile)); var mockBranding = new Mock().Object; var logger = NullLogger.Instance; - return new SettingsViewModel(settingsService, mockBranding, logger); + return new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger.Instance), logger); } [Fact] @@ -50,7 +50,7 @@ public class SettingsViewModelOwnershipTests : IDisposable var settingsService = new SettingsService(new SettingsRepository(_tempFile)); var mockBranding = new Mock().Object; var logger = NullLogger.Instance; - var vm = new SettingsViewModel(settingsService, mockBranding, logger); + var vm = new SettingsViewModel(settingsService, mockBranding, new ThemeManager(NullLogger.Instance), logger); await vm.LoadAsync(); vm.AutoTakeOwnership = true; diff --git a/SharepointToolbox/App.xaml b/SharepointToolbox/App.xaml index e40f29a..5d96c6a 100644 --- a/SharepointToolbox/App.xaml +++ b/SharepointToolbox/App.xaml @@ -4,16 +4,23 @@ xmlns:local="clr-namespace:SharepointToolbox" xmlns:conv="clr-namespace:SharepointToolbox.Views.Converters"> - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/SharepointToolbox/App.xaml.cs b/SharepointToolbox/App.xaml.cs index a41a869..10ddb98 100644 --- a/SharepointToolbox/App.xaml.cs +++ b/SharepointToolbox/App.xaml.cs @@ -50,6 +50,18 @@ public partial class App : Application App app = new(); app.InitializeComponent(); + // Apply persisted theme (System/Light/Dark) before MainWindow constructs so brushes resolve correctly. + try + { + var theme = host.Services.GetRequiredService(); + var settings = host.Services.GetRequiredService().GetSettingsAsync().GetAwaiter().GetResult(); + theme.ApplyFromString(settings.Theme); + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to apply persisted theme at startup"); + } + var mainWindow = host.Services.GetRequiredService(); // Wire LogPanelSink now that we have the RichTextBox @@ -101,6 +113,7 @@ public partial class App : Application services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); // Phase 11-04: ProfileManagementViewModel and SettingsViewModel now receive IBrandingService and GraphClientFactory services.AddTransient(); @@ -125,6 +138,7 @@ public partial class App : Application // Phase 3: Duplicates services.AddTransient(); services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient(); diff --git a/SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs b/SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs index 732f213..08181c8 100644 --- a/SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs +++ b/SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs @@ -6,9 +6,12 @@ namespace SharepointToolbox.Core.Helpers; public static class SharePointPaginationHelper { + // Max page size SharePoint honors with Paged='TRUE' (threshold bypass). + private const int DefaultRowLimit = 5000; + /// /// Enumerates all items in a SharePoint list, bypassing the 5,000-item threshold. - /// Uses CamlQuery with RowLimit=2000 and ListItemCollectionPosition for pagination. + /// Uses CamlQuery with Paged='TRUE' RowLimit and ListItemCollectionPosition for pagination. /// Never call ExecuteQuery directly on a list — always use this helper. /// public static async IAsyncEnumerable GetAllItemsAsync( @@ -18,7 +21,7 @@ public static class SharePointPaginationHelper [EnumeratorCancellation] CancellationToken ct = default) { var query = baseQuery ?? CamlQuery.CreateAllItemsQuery(); - query.ViewXml = BuildPagedViewXml(query.ViewXml, rowLimit: 2000); + query.ViewXml = BuildPagedViewXml(query.ViewXml, DefaultRowLimit); query.ListItemCollectionPosition = null; do @@ -36,21 +39,75 @@ public static class SharePointPaginationHelper while (query.ListItemCollectionPosition != null); } + /// + /// Enumerates items within a specific folder (direct children by default, or + /// recursive when is true). Uses paginated CAML + /// with no WHERE clause so it works on libraries above the 5,000-item threshold. + /// Callers filter by FSObjType client-side via the returned ListItem fields. + /// + public static async IAsyncEnumerable GetItemsInFolderAsync( + ClientContext ctx, + List list, + string folderServerRelativeUrl, + bool recursive, + string[]? viewFields = null, + [EnumeratorCancellation] CancellationToken ct = default) + { + var fields = viewFields ?? new[] + { + "FSObjType", "FileRef", "FileLeafRef", "FileDirRef", "File_x0020_Size" + }; + + var viewFieldsXml = string.Join(string.Empty, + fields.Select(f => $"")); + + var scope = recursive ? " Scope='RecursiveAll'" : string.Empty; + var viewXml = + $"" + + "" + + $"{viewFieldsXml}" + + $"{DefaultRowLimit}" + + ""; + + var query = new CamlQuery + { + ViewXml = viewXml, + FolderServerRelativeUrl = folderServerRelativeUrl, + ListItemCollectionPosition = null + }; + + do + { + ct.ThrowIfCancellationRequested(); + var items = list.GetItems(query); + ctx.Load(items); + await ctx.ExecuteQueryAsync(); + + foreach (var item in items) + yield return item; + + query.ListItemCollectionPosition = items.ListItemCollectionPosition; + } + while (query.ListItemCollectionPosition != null); + } + internal static string BuildPagedViewXml(string? existingXml, int rowLimit) { - // Inject or replace RowLimit in existing CAML, or create minimal view if (string.IsNullOrWhiteSpace(existingXml)) - return $"{rowLimit}"; + return $"{rowLimit}"; - // Simple replacement approach — adequate for Phase 1 - if (existingXml.Contains("", StringComparison.OrdinalIgnoreCase)) + // Replace any existing n with paged form. + if (System.Text.RegularExpressions.Regex.IsMatch( + existingXml, @"]*>\d+", + System.Text.RegularExpressions.RegexOptions.IgnoreCase)) { return System.Text.RegularExpressions.Regex.Replace( existingXml, @"]*>\d+", - $"{rowLimit}", + $"{rowLimit}", System.Text.RegularExpressions.RegexOptions.IgnoreCase); } - return existingXml.Replace("", $"{rowLimit}", + return existingXml.Replace("", + $"{rowLimit}", StringComparison.OrdinalIgnoreCase); } } diff --git a/SharepointToolbox/Core/Models/AppSettings.cs b/SharepointToolbox/Core/Models/AppSettings.cs index 6201515..b8d4f97 100644 --- a/SharepointToolbox/Core/Models/AppSettings.cs +++ b/SharepointToolbox/Core/Models/AppSettings.cs @@ -5,4 +5,5 @@ public class AppSettings public string DataFolder { get; set; } = string.Empty; public string Lang { get; set; } = "en"; public bool AutoTakeOwnership { get; set; } = false; + public string Theme { get; set; } = "System"; // System | Light | Dark } diff --git a/SharepointToolbox/Core/Models/DuplicateItem.cs b/SharepointToolbox/Core/Models/DuplicateItem.cs index c035aeb..2be69af 100644 --- a/SharepointToolbox/Core/Models/DuplicateItem.cs +++ b/SharepointToolbox/Core/Models/DuplicateItem.cs @@ -10,4 +10,8 @@ public class DuplicateItem public DateTime? Modified { get; set; } public int? FolderCount { get; set; } public int? FileCount { get; set; } + /// URL of the site the item was collected from. + public string SiteUrl { get; set; } = string.Empty; + /// Friendly site title; falls back to a derived label when unknown. + public string SiteTitle { get; set; } = string.Empty; } diff --git a/SharepointToolbox/Core/Models/OperationProgress.cs b/SharepointToolbox/Core/Models/OperationProgress.cs index d53b761..13aecbf 100644 --- a/SharepointToolbox/Core/Models/OperationProgress.cs +++ b/SharepointToolbox/Core/Models/OperationProgress.cs @@ -1,7 +1,7 @@ namespace SharepointToolbox.Core.Models; -public record OperationProgress(int Current, int Total, string Message) +public record OperationProgress(int Current, int Total, string Message, bool IsIndeterminate = false) { public static OperationProgress Indeterminate(string message) => - new(0, 0, message); + new(0, 0, message, IsIndeterminate: true); } diff --git a/SharepointToolbox/Core/Models/SiteInfo.cs b/SharepointToolbox/Core/Models/SiteInfo.cs index 9408e09..e3beec0 100644 --- a/SharepointToolbox/Core/Models/SiteInfo.cs +++ b/SharepointToolbox/Core/Models/SiteInfo.cs @@ -1,3 +1,42 @@ namespace SharepointToolbox.Core.Models; -public record SiteInfo(string Url, string Title); +public record SiteInfo(string Url, string Title) +{ + public long StorageUsedMb { get; init; } + public long StorageQuotaMb { get; init; } + public string Template { get; init; } = string.Empty; + + public SiteKind Kind => SiteKindHelper.FromTemplate(Template); +} + +public enum SiteKind +{ + Unknown, + TeamSite, + CommunicationSite, + Classic +} + +public static class SiteKindHelper +{ + public static SiteKind FromTemplate(string template) + { + if (string.IsNullOrEmpty(template)) return SiteKind.Unknown; + if (template.StartsWith("GROUP#", StringComparison.OrdinalIgnoreCase)) return SiteKind.TeamSite; + if (template.StartsWith("SITEPAGEPUBLISHING#", StringComparison.OrdinalIgnoreCase)) return SiteKind.CommunicationSite; + if (template.StartsWith("STS#", StringComparison.OrdinalIgnoreCase)) return SiteKind.Classic; + return SiteKind.Unknown; + } + + public static string DisplayName(SiteKind kind) + { + var key = kind switch + { + SiteKind.TeamSite => "sitepicker.kind.teamsite", + SiteKind.CommunicationSite => "sitepicker.kind.communication", + SiteKind.Classic => "sitepicker.kind.classic", + _ => "sitepicker.kind.other" + }; + return Localization.TranslationSource.Instance[key]; + } +} diff --git a/SharepointToolbox/Core/Models/TransferJob.cs b/SharepointToolbox/Core/Models/TransferJob.cs index be9d5b8..c1c997e 100644 --- a/SharepointToolbox/Core/Models/TransferJob.cs +++ b/SharepointToolbox/Core/Models/TransferJob.cs @@ -10,4 +10,24 @@ public class TransferJob public string DestinationFolderPath { get; set; } = string.Empty; public TransferMode Mode { get; set; } = TransferMode.Copy; public ConflictPolicy ConflictPolicy { get; set; } = ConflictPolicy.Skip; + + /// + /// Optional library-relative file paths. When non-empty, only these files + /// are transferred; SourceFolderPath recursive enumeration is skipped. + /// + public IReadOnlyList SelectedFilePaths { get; set; } = Array.Empty(); + + /// + /// When true, recreate the source folder name under the destination folder + /// (dest/srcFolderName/... ). When false, the source folder's contents land + /// directly inside the destination folder. + /// + public bool IncludeSourceFolder { get; set; } + + /// + /// When true (default), transfer the files inside the source folder. + /// When false, only create the folder structure (useful together with + /// to clone an empty scaffold). + /// + public bool CopyFolderContents { get; set; } = true; } diff --git a/SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs b/SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs index d0070ef..7384bad 100644 --- a/SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs +++ b/SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs @@ -15,17 +15,48 @@ public class GraphClientFactory /// /// Creates a GraphServiceClient that acquires tokens via the same MSAL PCA - /// used for SharePoint auth, but with Graph scopes. + /// used for SharePoint auth, but with Graph scopes. Uses the /common authority + /// and the .default scope (whatever the client is pre-consented for). /// - public async Task CreateClientAsync(string clientId, CancellationToken ct) + public Task CreateClientAsync(string clientId, CancellationToken ct) + => CreateClientAsync(clientId, tenantId: null, scopes: null, ct); + + /// + /// Creates a GraphServiceClient pinned to a specific tenant authority. + /// Pass the tenant domain (e.g. "contoso.onmicrosoft.com") or tenant GUID. + /// Null falls back to /common. + /// + public Task CreateClientAsync(string clientId, string? tenantId, CancellationToken ct) + => CreateClientAsync(clientId, tenantId, scopes: null, ct); + + /// + /// Creates a GraphServiceClient with explicit Graph delegated scopes. + /// Use when .default is insufficient — typically for admin actions that + /// need scopes not pre-consented on the bootstrap client (e.g. app registration + /// requires Application.ReadWrite.All and + /// DelegatedPermissionGrant.ReadWrite.All). Triggers an admin-consent + /// prompt on first use if the tenant has not yet consented. + /// + public async Task CreateClientAsync( + string clientId, + string? tenantId, + string[]? scopes, + CancellationToken ct) { var pca = await _msalFactory.GetOrCreateAsync(clientId); - var accounts = await pca.GetAccountsAsync(); - var account = accounts.FirstOrDefault(); - var graphScopes = new[] { "https://graph.microsoft.com/.default" }; + // When a tenant is specified we must NOT reuse cached accounts from /common + // (or a different tenant) — they route tokens to the wrong authority. + IAccount? account = null; + if (tenantId is null) + { + var accounts = await pca.GetAccountsAsync(); + account = accounts.FirstOrDefault(); + } - var tokenProvider = new MsalTokenProvider(pca, account, graphScopes); + var graphScopes = scopes ?? new[] { "https://graph.microsoft.com/.default" }; + + var tokenProvider = new MsalTokenProvider(pca, account, graphScopes, tenantId); var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider); return new GraphServiceClient(authProvider); } @@ -39,12 +70,14 @@ internal class MsalTokenProvider : IAccessTokenProvider private readonly IPublicClientApplication _pca; private readonly IAccount? _account; private readonly string[] _scopes; + private readonly string? _tenantId; - public MsalTokenProvider(IPublicClientApplication pca, IAccount? account, string[] scopes) + public MsalTokenProvider(IPublicClientApplication pca, IAccount? account, string[] scopes, string? tenantId = null) { _pca = pca; _account = account; _scopes = scopes; + _tenantId = tenantId; } public AllowedHostsValidator AllowedHostsValidator { get; } = new(); @@ -56,15 +89,16 @@ internal class MsalTokenProvider : IAccessTokenProvider { try { - var result = await _pca.AcquireTokenSilent(_scopes, _account) - .ExecuteAsync(cancellationToken); + var silent = _pca.AcquireTokenSilent(_scopes, _account); + if (_tenantId is not null) silent = silent.WithTenantId(_tenantId); + var result = await silent.ExecuteAsync(cancellationToken); return result.AccessToken; } catch (MsalUiRequiredException) { - // If silent fails, try interactive - var result = await _pca.AcquireTokenInteractive(_scopes) - .ExecuteAsync(cancellationToken); + var interactive = _pca.AcquireTokenInteractive(_scopes); + if (_tenantId is not null) interactive = interactive.WithTenantId(_tenantId); + var result = await interactive.ExecuteAsync(cancellationToken); return result.AccessToken; } } diff --git a/SharepointToolbox/Localization/Strings.fr.resx b/SharepointToolbox/Localization/Strings.fr.resx index 5f39a1d..e9b942f 100644 --- a/SharepointToolbox/Localization/Strings.fr.resx +++ b/SharepointToolbox/Localization/Strings.fr.resx @@ -109,6 +109,18 @@ Français + + Thème + + + Utiliser le paramètre système + + + Clair + + + Sombre + Dossier de sortie des données @@ -130,8 +142,8 @@ Ajouter - - Renommer + + Enregistrer Supprimer @@ -139,6 +151,9 @@ Prêt + + Terminé + Opération annulée @@ -357,6 +372,27 @@ Exporter HTML + + Découper + + + Fichier unique + + + Par site + + + Par utilisateur + + + Mise en page HTML + + + Fichiers séparés + + + Fichier unique à onglets + Total des accès @@ -437,4 +473,181 @@ Prendre automatiquement la propriété d'administrateur de collection de sites en cas de refus d'accès Lorsqu'activé, l'application prendra automatiquement les droits d'administrateur de collection de sites lorsqu'un scan rencontre une erreur de refus d'accès. Nécessite les permissions d'administrateur de tenant. Ce site a été élevé automatiquement — la propriété a été prise pour compléter le scan + + Rapport d'audit des accès utilisateurs + Rapport d'audit des accès utilisateurs (consolidé) + Rapport des permissions SharePoint + Rapport des permissions SharePoint (simplifié) + Métriques de stockage SharePoint + Rapport de détection de doublons SharePoint + Rapport de détection de doublons + Résultats de recherche de fichiers SharePoint + Résultats de recherche de fichiers + Accès totaux + Utilisateurs audités + Sites analysés + Privilège élevé + Utilisateurs externes + Entrées totales + Ensembles de permissions uniques + Utilisateurs/Groupes distincts + Bibliothèques + Fichiers + Taille totale + Taille des versions + Invité + Direct + Groupe + Hérité + Unique + Par utilisateur + Par site + Filtrer les résultats... + Filtrer les permissions... + Filtrer les lignes… + Filtre : + Site + Sites + Type d'objet + Objet + Niveau de permission + Type d'accès + Accordé via + Utilisateur + Titre + URL + Utilisateurs/Groupes + Simplifié + Risque + Bibliothèque / Dossier + Dernière modification + Nom + Bibliothèque + Chemin + Taille + Créé + Modifié + Créé par + Modifié par + Nom de fichier + Extension + Type de fichier + Nombre de fichiers + Erreur + Horodatage + # + Groupe + Taille totale (Mo) + Taille des versions (Mo) + Taille (Mo) + Taille (octets) + accès + accès + site(s) + permission(s) + copies + groupe(s) de doublons trouvé(s). + résultat(s) + sur + affiché(s) + Généré + Généré : + membres indisponibles + Lien + (sans ext.) + (sans extension) + priv. élevé + Stockage par type de fichier + Détails des bibliothèques + + Sélectionner les sites + Filtre : + Type : + Tous + Sites d'équipe (MS Teams) + Communication + Classique + Autre + Taille (Mo) : + min + max + Titre + URL + Type + Taille + Charger les sites + Tout sélectionner + Tout désélectionner + OK + Annuler + Chargement des sites... + {0} sites chargés. + {0} / {1} sites affichés. + Erreur : {0} + Site d'équipe + Communication + Classique + Autre + + Valide + Erreurs + Fermer + + Nouveau dossier + Invité + + Saisie + + Gérer les profils + Profils + + Groupe + Copies + + Niveau 1 + Niveau 2 + Niveau 3 + Niveau 4 + + Perm. uniques + Niveaux d'autorisation + Type de principal + + Taille totale : + Taille des versions : + Fichiers : + + Source + Capturé + + fichier(s) sélectionné(s) + Inclure le dossier source dans la destination + Si activé, recrée le dossier source sous la destination. Sinon, dépose le contenu directement dans le dossier de destination. + Copier le contenu du dossier + Si activé (par défaut), transfère les fichiers du dossier. Sinon, seul le dossier est créé à la destination. + + Aucun tenant connecté. + Aucun tenant sélectionné. Connectez-vous à un tenant d'abord. + Aucun profil de tenant sélectionné. Connectez-vous d'abord. + Sélectionnez au moins un site dans la barre d'outils. + Ajoutez au moins un utilisateur à auditer. + Aucune ligne valide à traiter. Importez un CSV d'abord. + Le nom du modèle est requis. + Le titre du nouveau site est requis. + L'alias du nouveau site est requis. + Le site source et la bibliothèque doivent être sélectionnés. + Le site de destination et la bibliothèque doivent être sélectionnés. + Le titre de la bibliothèque est requis. + + Capture du modèle... + Modèle capturé avec succès. + Échec de la capture : {0} + Application du modèle... + Modèle appliqué. Site créé à : {0} + Échec de l'application : {0} + + Recherche en cours... + + utilisateur(s) + fichiers + sites diff --git a/SharepointToolbox/Localization/Strings.resx b/SharepointToolbox/Localization/Strings.resx index 14a3075..b0f2c92 100644 --- a/SharepointToolbox/Localization/Strings.resx +++ b/SharepointToolbox/Localization/Strings.resx @@ -109,6 +109,18 @@ French + + Theme + + + Use system setting + + + Light + + + Dark + Data output folder @@ -130,8 +142,8 @@ Add - - Rename + + Save Delete @@ -139,6 +151,9 @@ Ready + + Complete + Operation cancelled @@ -357,6 +372,27 @@ Export HTML + + Split + + + Single file + + + By site + + + By user + + + HTML layout + + + Separate files + + + Single tabbed file + Total Accesses @@ -437,4 +473,181 @@ Automatically take site collection admin ownership on access denied When enabled, the app will automatically elevate to site collection admin when a scan encounters an access denied error. Requires Tenant Admin permissions. This site was automatically elevated — ownership was taken to complete the scan + + User Access Audit Report + User Access Audit Report (Consolidated) + SharePoint Permissions Report + SharePoint Permissions Report (Simplified) + SharePoint Storage Metrics + SharePoint Duplicate Detection Report + Duplicate Detection Report + SharePoint File Search Results + File Search Results + Total Accesses + Users Audited + Sites Scanned + High Privilege + External Users + Total Entries + Unique Permission Sets + Distinct Users/Groups + Libraries + Files + Total Size + Version Size + Guest + Direct + Group + Inherited + Unique + By User + By Site + Filter results... + Filter permissions... + Filter rows… + Filter: + Site + Sites + Object Type + Object + Permission Level + Access Type + Granted Through + User + Title + URL + Users/Groups + Simplified + Risk + Library / Folder + Last Modified + Name + Library + Path + Size + Created + Modified + Created By + Modified By + File Name + Extension + File Type + File Count + Error + Timestamp + # + Group + Total Size (MB) + Version Size (MB) + Size (MB) + Size (bytes) + accesses + access(es) + site(s) + permission(s) + copies + duplicate group(s) found. + result(s) + of + shown + Generated + Generated: + members unavailable + Link + (no ext) + (no extension) + high-priv + Storage by File Type + Library Details + + Select Sites + Filter: + Type: + All + Team sites (MS Teams) + Communication + Classic + Other + Size (MB): + min + max + Title + URL + Type + Size + Load Sites + Select All + Deselect All + OK + Cancel + Loading sites... + {0} sites loaded. + {0} / {1} sites shown. + Error: {0} + Team site + Communication + Classic + Other + + Valid + Errors + Close + + New Folder + Guest + + Input + + Manage Profiles + Profiles + + Group + Copies + + Level 1 + Level 2 + Level 3 + Level 4 + + Unique Perms + Permission Levels + Principal Type + + Total Size: + Version Size: + Files: + + Source + Captured + + file(s) selected + Include source folder at destination + When on, recreate the source folder under the destination. When off, drop contents directly into the destination folder. + Copy folder contents + When on (default), transfer files inside the folder. When off, only the folder is created at the destination. + + No tenant connected. + No tenant selected. Please connect to a tenant first. + No tenant profile selected. Please connect first. + Select at least one site from the toolbar. + Add at least one user to audit. + No valid rows to process. Import a CSV first. + Template name is required. + New site title is required. + New site alias is required. + Source site and library must be selected. + Destination site and library must be selected. + Library title is required. + + Capturing template... + Template captured successfully. + Capture failed: {0} + Applying template... + Template applied. Site created at: {0} + Apply failed: {0} + + Searching... + + user(s) + files + sites diff --git a/SharepointToolbox/MainWindow.xaml b/SharepointToolbox/MainWindow.xaml index bce472b..8766bff 100644 --- a/SharepointToolbox/MainWindow.xaml +++ b/SharepointToolbox/MainWindow.xaml @@ -8,6 +8,9 @@ xmlns:views="clr-namespace:SharepointToolbox.Views.Tabs" mc:Ignorable="d" Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[app.title]}" + Background="{DynamicResource AppBgBrush}" + Foreground="{DynamicResource TextBrush}" + TextOptions.TextFormattingMode="Ideal" MinWidth="900" MinHeight="600" Height="700" Width="1100"> @@ -28,7 +31,7 @@ ToolTip="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[toolbar.selectSites.tooltip]}" /> + Foreground="{DynamicResource TextMutedBrush}" /> diff --git a/SharepointToolbox/Services/AppRegistrationService.cs b/SharepointToolbox/Services/AppRegistrationService.cs index b050331..f7703f0 100644 --- a/SharepointToolbox/Services/AppRegistrationService.cs +++ b/SharepointToolbox/Services/AppRegistrationService.cs @@ -11,12 +11,41 @@ namespace SharepointToolbox.Services; /// Manages Azure AD app registration and removal using the Microsoft Graph API. /// All operations use for token acquisition. /// +/// +/// GraphServiceClient lifecycle: a fresh client is created per public call +/// (, , +/// , ). This is intentional — +/// each call may use different scopes (RegistrationScopes vs. default) and target +/// a different tenant, so a cached per-service instance would bind the wrong +/// authority. The factory itself caches the underlying MSAL PCA and token cache, +/// so client construction is cheap (no network hit when tokens are valid). +/// Do not cache a GraphServiceClient at call sites — always go through +/// so tenant pinning and scope selection stay +/// correct. +/// public class AppRegistrationService : IAppRegistrationService { + // Entra built-in directory role template IDs are global constants shared across all tenants. + // GlobalAdminTemplateId: "Global Administrator" directoryRoleTemplate. + // See https://learn.microsoft.com/entra/identity/role-based-access-control/permissions-reference#global-administrator private const string GlobalAdminTemplateId = "62e90394-69f5-4237-9190-012177145e10"; + + // First-party Microsoft service appIds (constant across tenants). private const string GraphAppId = "00000003-0000-0000-c000-000000000000"; private const string SharePointAppId = "00000003-0000-0ff1-ce00-000000000000"; + // Explicit scopes for the registration flow. The bootstrap client + // (Microsoft Graph Command Line Tools) does not pre-consent these, so + // requesting `.default` returns a token without them → POST /applications + // fails with 403 even for a Global Admin. Requesting them explicitly + // triggers the admin-consent prompt on first use. + private static readonly string[] RegistrationScopes = new[] + { + "https://graph.microsoft.com/Application.ReadWrite.All", + "https://graph.microsoft.com/DelegatedPermissionGrant.ReadWrite.All", + "https://graph.microsoft.com/Directory.Read.All", + }; + private readonly AppGraphClientFactory _graphFactory; private readonly AppMsalClientFactory _msalFactory; private readonly ISessionManager _sessionManager; @@ -35,38 +64,33 @@ public class AppRegistrationService : IAppRegistrationService } /// - public async Task IsGlobalAdminAsync(string clientId, CancellationToken ct) + public async Task IsGlobalAdminAsync(string clientId, string tenantUrl, CancellationToken ct) { - try - { - var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); - var roles = await graphClient.Me.TransitiveMemberOf.GetAsync(req => - { - req.QueryParameters.Filter = "isof('microsoft.graph.directoryRole')"; - }, ct); + // No $filter: isof() on directoryObject requires advanced query params + // (ConsistencyLevel: eventual + $count=true) and fails with 400 otherwise. + // The user's membership list is small; filtering client-side is fine. + var tenantId = ResolveTenantId(tenantUrl); + var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, ct); + var memberships = await graphClient.Me.TransitiveMemberOf.GetAsync(cancellationToken: ct); - return roles?.Value? - .OfType() - .Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId, - StringComparison.OrdinalIgnoreCase)) ?? false; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "IsGlobalAdminAsync failed — treating as non-admin. ClientId={ClientId}", clientId); - return false; - } + return memberships?.Value? + .OfType() + .Any(r => string.Equals(r.RoleTemplateId, GlobalAdminTemplateId, + StringComparison.OrdinalIgnoreCase)) ?? false; } /// public async Task RegisterAsync( string clientId, + string tenantUrl, string tenantDisplayName, CancellationToken ct) { + var tenantId = ResolveTenantId(tenantUrl); Application? createdApp = null; try { - var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); + var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct); // Step 1: Create Application object var appRequest = new Application @@ -78,7 +102,10 @@ public class AppRegistrationService : IAppRegistrationService { RedirectUris = new List { - "https://login.microsoftonline.com/common/oauth2/nativeclient" + // Loopback URI for MSAL desktop default (any port accepted by Entra). + "http://localhost", + // Legacy native-client URI for embedded WebView fallback. + "https://login.microsoftonline.com/common/oauth2/nativeclient", } }, RequiredResourceAccess = BuildRequiredResourceAccess() @@ -131,34 +158,45 @@ public class AppRegistrationService : IAppRegistrationService _logger.LogInformation("App registration complete. AppId={AppId}", createdApp.AppId); return AppRegistrationResult.Success(createdApp.AppId!); } + catch (Microsoft.Graph.Models.ODataErrors.ODataError odataEx) + when (odataEx.ResponseStatusCode == 401 || odataEx.ResponseStatusCode == 403) + { + _logger.LogWarning(odataEx, + "RegisterAsync refused by Graph (status {Status}) — user lacks role/consent. Surfacing fallback.", + odataEx.ResponseStatusCode); + await RollbackAsync(createdApp, clientId, tenantId, ct); + return AppRegistrationResult.FallbackRequired(); + } catch (Exception ex) { _logger.LogError(ex, "RegisterAsync failed. Attempting rollback."); - - if (createdApp?.Id is not null) - { - try - { - var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); - await graphClient.Applications[createdApp.Id].DeleteAsync(cancellationToken: ct); - _logger.LogInformation("Rollback: deleted Application {ObjectId}", createdApp.Id); - } - catch (Exception rollbackEx) - { - _logger.LogWarning(rollbackEx, "Rollback failed for Application {ObjectId}", createdApp.Id); - } - } - + await RollbackAsync(createdApp, clientId, tenantId, ct); return AppRegistrationResult.Failure(ex.Message); } } + private async Task RollbackAsync(Application? createdApp, string clientId, string tenantId, CancellationToken ct) + { + if (createdApp?.Id is null) return; + try + { + var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct); + await graphClient.Applications[createdApp.Id].DeleteAsync(cancellationToken: ct); + _logger.LogInformation("Rollback: deleted Application {ObjectId}", createdApp.Id); + } + catch (Exception rollbackEx) + { + _logger.LogWarning(rollbackEx, "Rollback failed for Application {ObjectId}", createdApp.Id); + } + } + /// - public async Task RemoveAsync(string clientId, string appId, CancellationToken ct) + public async Task RemoveAsync(string clientId, string tenantUrl, string appId, CancellationToken ct) { try { - var graphClient = await _graphFactory.CreateClientAsync(clientId, ct); + var tenantId = ResolveTenantId(tenantUrl); + var graphClient = await _graphFactory.CreateClientAsync(clientId, tenantId, RegistrationScopes, ct); await graphClient.Applications[$"(appId='{appId}')"].DeleteAsync(cancellationToken: ct); _logger.LogInformation("Removed Application appId={AppId}", appId); } @@ -168,6 +206,32 @@ public class AppRegistrationService : IAppRegistrationService } } + /// + /// Derives a tenant identifier (domain) from a SharePoint tenant URL so MSAL + /// can pin the authority to the correct tenant. Examples: + /// https://contoso.sharepoint.com → contoso.onmicrosoft.com + /// https://contoso-admin.sharepoint.com → contoso.onmicrosoft.com + /// Throws when the URL is not a recognisable + /// SharePoint URL — falling back to /common would silently route registration + /// to the signed-in user's home tenant, which is the bug this guards against. + /// + internal static string ResolveTenantId(string tenantUrl) + { + if (!Uri.TryCreate(tenantUrl, UriKind.Absolute, out var uri)) + throw new ArgumentException($"Invalid tenant URL: '{tenantUrl}'", nameof(tenantUrl)); + + var host = uri.Host; + var firstDot = host.IndexOf('.'); + if (firstDot <= 0) + throw new ArgumentException($"Cannot derive tenant from host '{host}'", nameof(tenantUrl)); + + var prefix = host.Substring(0, firstDot); + if (prefix.EndsWith("-admin", StringComparison.OrdinalIgnoreCase)) + prefix = prefix.Substring(0, prefix.Length - "-admin".Length); + + return $"{prefix}.onmicrosoft.com"; + } + /// public async Task ClearMsalSessionAsync(string clientId, string tenantUrl) { diff --git a/SharepointToolbox/Services/BulkOperationRunner.cs b/SharepointToolbox/Services/BulkOperationRunner.cs index 3155a0e..fe5ae23 100644 --- a/SharepointToolbox/Services/BulkOperationRunner.cs +++ b/SharepointToolbox/Services/BulkOperationRunner.cs @@ -7,29 +7,72 @@ public static class BulkOperationRunner /// /// Runs a bulk operation with continue-on-error semantics, per-item result tracking, /// and cancellation support. OperationCanceledException propagates immediately. + /// + /// Progress is reported AFTER each item completes (success or failure), so the bar + /// reflects actual work done rather than work queued. A final "Complete" report + /// guarantees 100% when the total was determinate. + /// + /// Set > 1 to run items in parallel. Callers must + /// ensure processItem is safe to invoke concurrently (e.g. each invocation uses its + /// own CSOM ClientContext — a shared CSOM context is NOT thread-safe). /// public static async Task> RunAsync( IReadOnlyList items, Func processItem, IProgress progress, - CancellationToken ct) + CancellationToken ct, + int maxConcurrency = 1) { - var results = new List>(); - for (int i = 0; i < items.Count; i++) + if (items.Count == 0) + { + progress.Report(new OperationProgress(0, 0, "Nothing to do.")); + return new BulkOperationSummary(Array.Empty>()); + } + + progress.Report(new OperationProgress(0, items.Count, $"Processing 1/{items.Count}...")); + + var results = new BulkItemResult[items.Count]; + int completed = 0; + + async Task RunOne(int i, CancellationToken token) { - ct.ThrowIfCancellationRequested(); - progress.Report(new OperationProgress(i + 1, items.Count, $"Processing {i + 1}/{items.Count}...")); try { - await processItem(items[i], i, ct); - results.Add(BulkItemResult.Success(items[i])); + await processItem(items[i], i, token); + results[i] = BulkItemResult.Success(items[i]); } catch (OperationCanceledException) { throw; } catch (Exception ex) { - results.Add(BulkItemResult.Failed(items[i], ex.Message)); + results[i] = BulkItemResult.Failed(items[i], ex.Message); + } + finally + { + int done = Interlocked.Increment(ref completed); + progress.Report(new OperationProgress(done, items.Count, + $"Processed {done}/{items.Count}")); } } + + if (maxConcurrency <= 1) + { + for (int i = 0; i < items.Count; i++) + { + ct.ThrowIfCancellationRequested(); + await RunOne(i, ct); + } + } + else + { + var options = new ParallelOptions + { + MaxDegreeOfParallelism = maxConcurrency, + CancellationToken = ct + }; + await Parallel.ForEachAsync(Enumerable.Range(0, items.Count), options, + async (i, token) => await RunOne(i, token)); + } + progress.Report(new OperationProgress(items.Count, items.Count, "Complete.")); return new BulkOperationSummary(results); } diff --git a/SharepointToolbox/Services/DuplicatesService.cs b/SharepointToolbox/Services/DuplicatesService.cs index ab2a16e..9b13333 100644 --- a/SharepointToolbox/Services/DuplicatesService.cs +++ b/SharepointToolbox/Services/DuplicatesService.cs @@ -1,7 +1,9 @@ +using System.Diagnostics; using Microsoft.SharePoint.Client; using Microsoft.SharePoint.Client.Search.Query; using SharepointToolbox.Core.Helpers; using SharepointToolbox.Core.Models; +using SharepointToolbox.Services.Export; namespace SharepointToolbox.Services; @@ -13,9 +15,27 @@ namespace SharepointToolbox.Services; /// public class DuplicatesService : IDuplicatesService { + // SharePoint Search REST API caps RowLimit at 500 per request; larger values are silently clamped. private const int BatchSize = 500; + + // SharePoint Search hard ceiling — StartRow > 50,000 returns an error regardless of pagination state. + // See https://learn.microsoft.com/sharepoint/dev/general-development/customizing-search-results-in-sharepoint private const int MaxStartRow = 50_000; + /// + /// Scans a site for duplicate files or folders and groups matches by the + /// composite key configured in (name plus any + /// of size / created / modified / subfolder-count / file-count). + /// File mode uses the SharePoint Search API — it is fast but capped at + /// 50,000 rows (see ). Folder mode uses paginated + /// CSOM CAML over every document library on the site. Groups with fewer + /// than two items are dropped before return. + /// + /// Authenticated for the target site. + /// Scope (Files/Folders), optional library filter, and match-key toggles. + /// Receives row-count progress during collection. + /// Cancellation token — honoured between paged requests. + /// Duplicate groups ordered by descending size, then name. public async Task> ScanDuplicatesAsync( ClientContext ctx, DuplicateScanOptions options, @@ -70,6 +90,8 @@ public class DuplicatesService : IDuplicatesService IProgress progress, CancellationToken ct) { + var (siteUrl, siteTitle) = await LoadSiteIdentityAsync(ctx, progress, ct); + // KQL: all documents, optionally scoped to a library var kqlParts = new List { "ContentType:Document" }; if (!string.IsNullOrEmpty(options.Library)) @@ -102,10 +124,25 @@ public class DuplicatesService : IDuplicatesService .FirstOrDefault(t => t.TableType == KnownTableTypes.RelevantResults); if (table == null || table.RowCount == 0) break; - foreach (System.Collections.Hashtable row in table.ResultRows) + foreach (var rawRow in table.ResultRows) { - var dict = row.Cast() - .ToDictionary(e => e.Key.ToString()!, e => e.Value ?? (object)string.Empty); + // CSOM has returned ResultRows as either Hashtable or + // Dictionary across versions — accept both. + IDictionary dict; + if (rawRow is IDictionary generic) + { + dict = generic; + } + else if (rawRow is System.Collections.IDictionary legacy) + { + dict = new Dictionary(); + foreach (System.Collections.DictionaryEntry e in legacy) + dict[e.Key.ToString()!] = e.Value ?? string.Empty; + } + else + { + continue; + } string path = GetStr(dict, "Path"); if (path.Contains("/_vti_history/", StringComparison.OrdinalIgnoreCase)) @@ -132,7 +169,9 @@ public class DuplicatesService : IDuplicatesService Library = library, SizeBytes = size, Created = created, - Modified = modified + Modified = modified, + SiteUrl = siteUrl, + SiteTitle = siteTitle }); } @@ -156,10 +195,16 @@ public class DuplicatesService : IDuplicatesService { // Load all document libraries on the site ctx.Load(ctx.Web, + w => w.Title, w => w.Lists.Include( l => l.Title, l => l.Hidden, l => l.BaseType)); await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + var siteUrl = ctx.Url; + var siteTitle = string.IsNullOrWhiteSpace(ctx.Web.Title) + ? ReportSplitHelper.DeriveSiteLabel(siteUrl) + : ctx.Web.Title; + var libs = ctx.Web.Lists .Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary) .ToList(); @@ -172,19 +217,15 @@ public class DuplicatesService : IDuplicatesService .ToList(); } + // No WHERE clause — a WHERE on non-indexed fields (FSObjType) throws the + // list-view threshold on libraries > 5,000 items even with pagination. + // Filter for folders client-side via FileSystemObjectType below. var camlQuery = new CamlQuery { ViewXml = """ - - - - - 1 - - - - 2000 + + 5000 """ }; @@ -200,6 +241,8 @@ public class DuplicatesService : IDuplicatesService { ct.ThrowIfCancellationRequested(); + if (item.FileSystemObjectType != FileSystemObjectType.Folder) continue; + var fv = item.FieldValues; string name = fv["FileLeafRef"]?.ToString() ?? string.Empty; string fileRef = fv["FileRef"]?.ToString() ?? string.Empty; @@ -217,7 +260,9 @@ public class DuplicatesService : IDuplicatesService FolderCount = subCount, FileCount = fileCount, Created = created, - Modified = modified + Modified = modified, + SiteUrl = siteUrl, + SiteTitle = siteTitle }); } } @@ -246,6 +291,37 @@ public class DuplicatesService : IDuplicatesService private static DateTime? ParseDate(string s) => DateTime.TryParse(s, out var dt) ? dt : (DateTime?)null; + private static async Task<(string Url, string Title)> LoadSiteIdentityAsync( + ClientContext ctx, IProgress progress, CancellationToken ct) + { + try + { + ctx.Load(ctx.Web, w => w.Title); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // Best-effort — fall back to URL-derived label + Debug.WriteLine($"[DuplicatesService] LoadSiteIdentityAsync: failed to load Web.Title: {ex.GetType().Name}: {ex.Message}"); + } + + var url = ctx.Url ?? string.Empty; + string title; + try { title = ctx.Web.Title; } + catch (Exception ex) + { + Debug.WriteLine($"[DuplicatesService] LoadSiteIdentityAsync: Web.Title getter threw: {ex.GetType().Name}: {ex.Message}"); + title = string.Empty; + } + if (string.IsNullOrWhiteSpace(title)) + title = ReportSplitHelper.DeriveSiteLabel(url); + return (url, title); + } + private static string ExtractLibraryFromPath(string path, string siteUrl) { // Extract first path segment after the site URL as library name diff --git a/SharepointToolbox/Services/Export/BulkResultCsvExportService.cs b/SharepointToolbox/Services/Export/BulkResultCsvExportService.cs index 58498f7..c1b7efe 100644 --- a/SharepointToolbox/Services/Export/BulkResultCsvExportService.cs +++ b/SharepointToolbox/Services/Export/BulkResultCsvExportService.cs @@ -2,20 +2,40 @@ using System.Globalization; using System.IO; using System.Text; using CsvHelper; +using CsvHelper.Configuration; using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; namespace SharepointToolbox.Services.Export; +/// +/// Exports the failed subset of a run +/// to CSV. CsvHelper is used so the payload's +/// properties become columns automatically, plus one error-message and one +/// timestamp column appended at the end. +/// public class BulkResultCsvExportService { + private static readonly CsvConfiguration CsvConfig = new(CultureInfo.InvariantCulture) + { + // Prevent CSV formula injection: prefix =, +, -, @, tab, CR with single quote + InjectionOptions = InjectionOptions.Escape, + }; + + /// + /// Builds a CSV containing only items whose + /// is false. Columns: every public property of + /// followed by Error and Timestamp (ISO-8601). + /// public string BuildFailedItemsCsv(IReadOnlyList> failedItems) { + var TL = TranslationSource.Instance; using var writer = new StringWriter(); - using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); + using var csv = new CsvWriter(writer, CsvConfig); csv.WriteHeader(); - csv.WriteField("Error"); - csv.WriteField("Timestamp"); + csv.WriteField(TL["report.col.error"]); + csv.WriteField(TL["report.col.timestamp"]); csv.NextRecord(); foreach (var item in failedItems.Where(r => !r.IsSuccess)) @@ -29,12 +49,13 @@ public class BulkResultCsvExportService return writer.ToString(); } + /// Writes the failed-items CSV to with UTF-8 BOM. public async Task WriteFailedItemsCsvAsync( IReadOnlyList> failedItems, string filePath, CancellationToken ct) { var content = BuildFailedItemsCsv(failedItems); - await System.IO.File.WriteAllTextAsync(filePath, content, new UTF8Encoding(true), ct); + await ExportFileWriter.WriteCsvAsync(filePath, content, ct); } } diff --git a/SharepointToolbox/Services/Export/CsvExportService.cs b/SharepointToolbox/Services/Export/CsvExportService.cs index 4f96c44..e67bb27 100644 --- a/SharepointToolbox/Services/Export/CsvExportService.cs +++ b/SharepointToolbox/Services/Export/CsvExportService.cs @@ -1,6 +1,7 @@ using System.IO; using System.Text; using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; namespace SharepointToolbox.Services.Export; @@ -10,8 +11,11 @@ namespace SharepointToolbox.Services.Export; /// public class CsvExportService { - private const string Header = - "\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"GrantedThrough\""; + 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\""; + } /// /// Builds a CSV string from the supplied permission entries. @@ -20,7 +24,7 @@ public class CsvExportService public string BuildCsv(IReadOnlyList entries) { var sb = new StringBuilder(); - sb.AppendLine(Header); + sb.AppendLine(BuildHeader()); // Merge: group by (Users, PermissionLevels, GrantedThrough) — port of PS Merge-PermissionRows var merged = entries @@ -55,14 +59,17 @@ public class CsvExportService public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct) { var csv = BuildCsv(entries); - await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct); + await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); } /// /// Header for simplified CSV export — includes "SimplifiedLabels" and "RiskLevel" columns. /// - private const string SimplifiedHeader = - "\"Object\",\"Title\",\"URL\",\"HasUniquePermissions\",\"Users\",\"UserLogins\",\"Type\",\"Permissions\",\"SimplifiedLabels\",\"RiskLevel\",\"GrantedThrough\""; + 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\""; + } /// /// Builds a CSV string from simplified permission entries. @@ -72,7 +79,7 @@ public class CsvExportService public string BuildCsv(IReadOnlyList entries) { var sb = new StringBuilder(); - sb.AppendLine(SimplifiedHeader); + sb.AppendLine(BuildSimplifiedHeader()); var merged = entries .GroupBy(e => (e.Users, e.PermissionLevels, e.GrantedThrough)) @@ -109,13 +116,57 @@ public class CsvExportService public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct) { var csv = BuildCsv(entries); - await File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct); + await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); } - /// RFC 4180 CSV field escaping: wrap in double quotes, double internal quotes. - private static string Csv(string value) + /// + /// Writes permission entries with optional per-site partitioning. + /// Single → writes one file at . + /// BySite → one file per site-collection URL, suffixed on the base path. + /// + public Task WriteAsync( + IReadOnlyList entries, + string basePath, + ReportSplitMode splitMode, + CancellationToken ct) + => ReportSplitHelper.WritePartitionedAsync( + entries, basePath, splitMode, + PartitionBySite, + (part, path, c) => WriteAsync(part, path, c), + ct); + + /// Simplified-entry split variant. + public Task WriteAsync( + IReadOnlyList entries, + string basePath, + ReportSplitMode splitMode, + CancellationToken ct) + => ReportSplitHelper.WritePartitionedAsync( + entries, basePath, splitMode, + PartitionBySite, + (part, path, c) => WriteAsync(part, path, c), + ct); + + internal static IEnumerable<(string Label, IReadOnlyList Partition)> PartitionBySite( + IReadOnlyList entries) { - if (string.IsNullOrEmpty(value)) return "\"\""; - return $"\"{value.Replace("\"", "\"\"")}\""; + return entries + .GroupBy(e => ReportSplitHelper.DeriveSiteCollectionUrl(e.Url), StringComparer.OrdinalIgnoreCase) + .Select(g => ( + Label: ReportSplitHelper.DeriveSiteLabel(g.Key), + Partition: (IReadOnlyList)g.ToList())); } + + internal static IEnumerable<(string Label, IReadOnlyList Partition)> PartitionBySite( + IReadOnlyList entries) + { + return entries + .GroupBy(e => ReportSplitHelper.DeriveSiteCollectionUrl(e.Url), StringComparer.OrdinalIgnoreCase) + .Select(g => ( + Label: ReportSplitHelper.DeriveSiteLabel(g.Key), + Partition: (IReadOnlyList)g.ToList())); + } + + /// RFC 4180 CSV field escaping with formula-injection guard. + private static string Csv(string value) => CsvSanitizer.Escape(value); } diff --git a/SharepointToolbox/Services/Export/CsvSanitizer.cs b/SharepointToolbox/Services/Export/CsvSanitizer.cs new file mode 100644 index 0000000..74ea9f0 --- /dev/null +++ b/SharepointToolbox/Services/Export/CsvSanitizer.cs @@ -0,0 +1,47 @@ +namespace SharepointToolbox.Services.Export; + +/// +/// CSV field sanitization. Adds RFC 4180 quoting plus formula-injection +/// protection: Excel and other spreadsheet apps treat cells starting with +/// '=', '+', '-', '@', tab, or CR as formulas. Prefixing with a single +/// quote neutralizes the formula while remaining readable. +/// +internal static class CsvSanitizer +{ + /// + /// Escapes a value for inclusion in a CSV row. Always wraps in double + /// quotes. Doubles internal quotes per RFC 4180. Prepends an apostrophe + /// when the value begins with a character a spreadsheet would evaluate. + /// + public static string Escape(string? value) + { + if (string.IsNullOrEmpty(value)) return "\"\""; + var safe = NeutralizeFormulaPrefix(value).Replace("\"", "\"\""); + return $"\"{safe}\""; + } + + /// + /// Minimal quoting variant: only wraps in quotes when the value contains + /// a delimiter, quote, or newline. Still guards against formula injection. + /// + public static string EscapeMinimal(string? value) + { + if (string.IsNullOrEmpty(value)) return string.Empty; + var safe = NeutralizeFormulaPrefix(value); + if (safe.Contains(',') || safe.Contains('"') || safe.Contains('\n') || safe.Contains('\r')) + return $"\"{safe.Replace("\"", "\"\"")}\""; + return safe; + } + + private static string NeutralizeFormulaPrefix(string value) + { + if (value.Length == 0) return value; + char first = value[0]; + if (first == '=' || first == '+' || first == '-' || first == '@' + || first == '\t' || first == '\r') + { + return "'" + value; + } + return value; + } +} diff --git a/SharepointToolbox/Services/Export/DuplicatesCsvExportService.cs b/SharepointToolbox/Services/Export/DuplicatesCsvExportService.cs new file mode 100644 index 0000000..f4320aa --- /dev/null +++ b/SharepointToolbox/Services/Export/DuplicatesCsvExportService.cs @@ -0,0 +1,112 @@ +using System.IO; +using System.Text; +using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; + +namespace SharepointToolbox.Services.Export; + +/// +/// Exports DuplicateGroup list to CSV. Each duplicate item becomes one row; +/// the Group column ties copies together and a Copies column gives the group size. +/// Header row is built at write-time so culture switches are honoured. +/// +public class DuplicatesCsvExportService +{ + /// Writes the CSV to with UTF-8 BOM (Excel-compatible). + public async Task WriteAsync( + IReadOnlyList groups, + string filePath, + CancellationToken ct) + { + var csv = BuildCsv(groups); + await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); + } + + /// + /// Writes one or more CSVs depending on . + /// Single → as-is. BySite → one file per site, + /// filenames derived from with a site suffix. + /// + public Task WriteAsync( + IReadOnlyList groups, + string basePath, + ReportSplitMode splitMode, + CancellationToken ct) + => ReportSplitHelper.WritePartitionedAsync( + groups, basePath, splitMode, + PartitionBySite, + (part, path, c) => WriteAsync(part, path, c), + ct); + + internal static IEnumerable<(string Label, IReadOnlyList Partition)> PartitionBySite( + IReadOnlyList groups) + { + return groups + .GroupBy(g => + { + var first = g.Items.FirstOrDefault(); + return (Url: first?.SiteUrl ?? string.Empty, Title: first?.SiteTitle ?? string.Empty); + }) + .Select(g => ( + Label: ReportSplitHelper.DeriveSiteLabel(g.Key.Url, g.Key.Title), + Partition: (IReadOnlyList)g.ToList())); + } + + /// + /// Builds the CSV payload. Emits a header summary (group count, generated + /// timestamp), then one row per duplicate item with its group index and + /// group size. CSV fields are escaped via . + /// + public string BuildCsv(IReadOnlyList groups) + { + var T = TranslationSource.Instance; + var sb = new StringBuilder(); + + // Summary + sb.AppendLine($"\"{T["report.title.duplicates_short"]}\""); + sb.AppendLine($"\"{T["report.text.duplicate_groups_found"]}\",\"{groups.Count}\""); + sb.AppendLine($"\"{T["report.text.generated"]}\",\"{DateTime.Now:yyyy-MM-dd HH:mm:ss}\""); + sb.AppendLine(); + + // Header + sb.AppendLine(string.Join(",", new[] + { + Csv(T["report.col.number"]), + Csv(T["report.col.group"]), + Csv(T["report.text.copies"]), + Csv(T["report.col.site"]), + Csv(T["report.col.name"]), + Csv(T["report.col.library"]), + Csv(T["report.col.path"]), + Csv(T["report.col.size_bytes"]), + Csv(T["report.col.created"]), + Csv(T["report.col.modified"]), + })); + + foreach (var g in groups) + { + int i = 0; + foreach (var item in g.Items) + { + i++; + sb.AppendLine(string.Join(",", new[] + { + Csv(i.ToString()), + Csv(g.Name), + Csv(g.Items.Count.ToString()), + Csv(item.SiteTitle), + Csv(item.Name), + Csv(item.Library), + Csv(item.Path), + Csv(item.SizeBytes?.ToString() ?? string.Empty), + Csv(item.Created?.ToString("yyyy-MM-dd") ?? string.Empty), + Csv(item.Modified?.ToString("yyyy-MM-dd") ?? string.Empty), + })); + } + } + + return sb.ToString(); + } + + private static string Csv(string value) => CsvSanitizer.Escape(value); +} diff --git a/SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs b/SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs index 73d0abb..2eef276 100644 --- a/SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs +++ b/SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs @@ -1,4 +1,5 @@ using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; using System.Text; namespace SharepointToolbox.Services.Export; @@ -10,17 +11,23 @@ namespace SharepointToolbox.Services.Export; /// public class DuplicatesHtmlExportService { + /// + /// Builds a self-contained HTML string rendering one collapsible card per + /// . The document ships with inline CSS and a + /// tiny JS toggle so no external assets are needed. + /// public string BuildHtml(IReadOnlyList groups, ReportBranding? branding = null) { + var T = TranslationSource.Instance; var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{T["report.title.duplicates"]}"); sb.AppendLine(""" - - - - - - SharePoint Duplicate Detection Report "); - sb.AppendLine(""); - - // ── BODY ─────────────────────────────────────────────────────────────── + AppendHead(sb, T["report.title.permissions"], includeRiskCss: false); sb.AppendLine(""); sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); - sb.AppendLine("

SharePoint Permissions Report

"); - - // Stats cards - sb.AppendLine("
"); - sb.AppendLine($"
{totalEntries}
Total Entries
"); - sb.AppendLine($"
{uniquePermSets}
Unique Permission Sets
"); - sb.AppendLine($"
{distinctUsers}
Distinct Users/Groups
"); - sb.AppendLine("
"); - - // Filter input - sb.AppendLine("
"); - sb.AppendLine(" "); - sb.AppendLine("
"); - - // Table - sb.AppendLine("
"); - sb.AppendLine(""); + sb.AppendLine($"

{T["report.title.permissions"]}

"); + AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers); + AppendFilterInput(sb); + AppendTableOpen(sb); sb.AppendLine(""); - sb.AppendLine(" "); + sb.AppendLine($" "); sb.AppendLine(""); sb.AppendLine(""); @@ -105,99 +53,236 @@ a:hover { text-decoration: underline; } { var typeCss = ObjectTypeCss(entry.ObjectType); var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited"; - var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited"; + var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"]; - // Build user pills: zip UserLogins and Users (both semicolon-delimited) - var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries); - var names = entry.Users.Split(';', StringSplitOptions.RemoveEmptyEntries); - var pillsBuilder = new StringBuilder(); - var memberSubRows = 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 isExpandableGroup = entry.PrincipalType == "SharePointGroup" - && groupMembers != null - && groupMembers.TryGetValue(name, out var members); - - if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers)) - { - var grpId = $"grpmem{grpMemIdx}"; - pillsBuilder.Append($"{HtmlEncode(name)} ▼"); - - string memberContent; - if (resolvedMembers.Count > 0) - { - var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>"); - memberContent = string.Join(" • ", memberParts); - } - else - { - memberContent = "members unavailable"; - } - memberSubRows.AppendLine($""); - grpMemIdx++; - } - else - { - var pillCss = isExt ? "user-pill external-user" : "user-pill"; - pillsBuilder.Append($"{HtmlEncode(name)}"); - } - } + var (pills, subRows) = BuildUserPillsCell( + entry.UserLogins, entry.Users, entry.PrincipalType, groupMembers, + colSpan: 7, grpMemIdx: ref grpMemIdx); sb.AppendLine(""); sb.AppendLine($" "); sb.AppendLine($" "); - sb.AppendLine($" "); + sb.AppendLine($" "); sb.AppendLine($" "); - sb.AppendLine($" "); + sb.AppendLine($" "); sb.AppendLine($" "); sb.AppendLine($" "); sb.AppendLine(""); - if (memberSubRows.Length > 0) - sb.Append(memberSubRows); + if (subRows.Length > 0) sb.Append(subRows); } - sb.AppendLine(""); - sb.AppendLine("
ObjectTitleURLUniqueUsers/GroupsPermission LevelGranted Through{T["report.col.object"]}{T["report.col.title"]}{T["report.col.url"]}{T["report.badge.unique"]}{T["report.col.users_groups"]}{T["report.col.permission_level"]}{T["report.col.granted_through"]}
{memberContent}
{HtmlEncode(entry.ObjectType)}{HtmlEncode(entry.Title)}Link{T["report.text.link"]}{uniqueLbl}{pillsBuilder}{pills}{HtmlEncode(entry.PermissionLevels)}{HtmlEncode(entry.GrantedThrough)}
"); - sb.AppendLine("
"); - - // Inline JS - sb.AppendLine(""); + AppendTableClose(sb); + AppendInlineJs(sb); sb.AppendLine(""); sb.AppendLine(""); - return sb.ToString(); } /// - /// Writes the HTML report to the specified file path using UTF-8 without BOM. + /// Builds a self-contained HTML string from simplified permission entries. + /// Adds a risk-level summary card strip plus two columns (Simplified, + /// Risk) relative to . + /// Color-coded risk badges use . /// - public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct, + public string BuildHtml( + IReadOnlyList entries, + ReportBranding? branding = null, + IReadOnlyDictionary>? groupMembers = null) + { + var T = TranslationSource.Instance; + var summaries = PermissionSummaryBuilder.Build(entries); + var (totalEntries, uniquePermSets, distinctUsers) = ComputeStats( + entries.Count, + entries.Select(e => e.PermissionLevels), + entries.SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries))); + + var sb = new StringBuilder(); + AppendHead(sb, T["report.title.permissions_simplified"], includeRiskCss: true); + sb.AppendLine(""); + sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); + sb.AppendLine($"

{T["report.title.permissions_simplified"]}

"); + AppendStatsCards(sb, totalEntries, uniquePermSets, distinctUsers); + + sb.AppendLine("
"); + foreach (var s in summaries) + { + var (bg, text, border) = RiskLevelColors(s.RiskLevel); + sb.AppendLine($"
"); + sb.AppendLine($"
{s.Count}
"); + sb.AppendLine($"
{HtmlEncode(s.Label)}
"); + sb.AppendLine($"
{s.DistinctUsers} {T["report.text.users_parens"]}
"); + sb.AppendLine("
"); + } + sb.AppendLine("
"); + + AppendFilterInput(sb); + AppendTableOpen(sb); + sb.AppendLine(""); + sb.AppendLine($" {T["report.col.object"]}{T["report.col.title"]}{T["report.col.url"]}{T["report.badge.unique"]}{T["report.col.users_groups"]}{T["report.col.permission_level"]}{T["report.col.simplified"]}{T["report.col.risk"]}{T["report.col.granted_through"]}"); + sb.AppendLine(""); + sb.AppendLine(""); + + int grpMemIdx = 0; + foreach (var entry in entries) + { + var typeCss = ObjectTypeCss(entry.ObjectType); + var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited"; + var uniqueLbl = entry.HasUniquePermissions ? T["report.badge.unique"] : T["report.badge.inherited"]; + var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel); + + var (pills, subRows) = BuildUserPillsCell( + entry.UserLogins, entry.Inner.Users, entry.Inner.PrincipalType, groupMembers, + colSpan: 9, grpMemIdx: ref grpMemIdx); + + sb.AppendLine(""); + sb.AppendLine($" {HtmlEncode(entry.ObjectType)}"); + sb.AppendLine($" {HtmlEncode(entry.Title)}"); + sb.AppendLine($" {T["report.text.link"]}"); + sb.AppendLine($" {uniqueLbl}"); + sb.AppendLine($" {pills}"); + sb.AppendLine($" {HtmlEncode(entry.PermissionLevels)}"); + sb.AppendLine($" {HtmlEncode(entry.SimplifiedLabels)}"); + sb.AppendLine($" {HtmlEncode(entry.RiskLevel.ToString())}"); + sb.AppendLine($" {HtmlEncode(entry.GrantedThrough)}"); + sb.AppendLine(""); + if (subRows.Length > 0) sb.Append(subRows); + } + + AppendTableClose(sb); + AppendInlineJs(sb); + sb.AppendLine(""); + sb.AppendLine(""); + return sb.ToString(); + } + + /// Writes the HTML report to the specified file path using UTF-8 without BOM. + public async Task WriteAsync( + IReadOnlyList entries, + string filePath, + CancellationToken ct, ReportBranding? branding = null, IReadOnlyDictionary>? groupMembers = null) { var html = BuildHtml(entries, branding, groupMembers); - await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct); + await ExportFileWriter.WriteHtmlAsync(filePath, html, ct); } - /// Returns inline CSS background and text color for a risk level. + /// Writes the simplified HTML report to the specified file path using UTF-8 without BOM. + public async Task WriteAsync( + IReadOnlyList entries, + string filePath, + CancellationToken ct, + ReportBranding? branding = null, + IReadOnlyDictionary>? groupMembers = null) + { + var html = BuildHtml(entries, branding, groupMembers); + await ExportFileWriter.WriteHtmlAsync(filePath, html, ct); + } + + /// + /// Split-aware write for permission entries. + /// Single → one file. BySite + SeparateFiles → one file per site. + /// BySite + SingleTabbed → one file with per-site iframe tabs. + /// + public async Task WriteAsync( + IReadOnlyList entries, + string basePath, + ReportSplitMode splitMode, + HtmlSplitLayout layout, + CancellationToken ct, + ReportBranding? branding = null, + IReadOnlyDictionary>? groupMembers = null) + { + if (splitMode != ReportSplitMode.BySite) + { + await WriteAsync(entries, basePath, ct, branding, groupMembers); + return; + } + + var partitions = CsvExportService.PartitionBySite(entries).ToList(); + if (layout == HtmlSplitLayout.SingleTabbed) + { + var parts = partitions + .Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers))) + .ToList(); + var title = TranslationSource.Instance["report.title.permissions"]; + var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title); + await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct); + return; + } + + foreach (var (label, partEntries) in partitions) + { + ct.ThrowIfCancellationRequested(); + var path = ReportSplitHelper.BuildPartitionPath(basePath, label); + await WriteAsync(partEntries, path, ct, branding, groupMembers); + } + } + + /// Simplified-entry split variant of . + public async Task WriteAsync( + IReadOnlyList entries, + string basePath, + ReportSplitMode splitMode, + HtmlSplitLayout layout, + CancellationToken ct, + ReportBranding? branding = null, + IReadOnlyDictionary>? groupMembers = null) + { + if (splitMode != ReportSplitMode.BySite) + { + await WriteAsync(entries, basePath, ct, branding, groupMembers); + return; + } + + var partitions = CsvExportService.PartitionBySite(entries).ToList(); + if (layout == HtmlSplitLayout.SingleTabbed) + { + var parts = partitions + .Select(p => (p.Label, Html: BuildHtml(p.Partition, branding, groupMembers))) + .ToList(); + var title = TranslationSource.Instance["report.title.permissions_simplified"]; + var tabbed = ReportSplitHelper.BuildTabbedHtml(parts, title); + await ExportFileWriter.WriteHtmlAsync(basePath, tabbed, ct); + return; + } + + foreach (var (label, partEntries) in partitions) + { + ct.ThrowIfCancellationRequested(); + var path = ReportSplitHelper.BuildPartitionPath(basePath, label); + await WriteAsync(partEntries, path, ct, branding, groupMembers); + } + } + + private static (int total, int uniquePerms, int distinctUsers) ComputeStats( + int totalEntries, + IEnumerable permissionLevels, + IEnumerable userLogins) + { + var uniquePermSets = permissionLevels.Distinct().Count(); + var distinctUsers = userLogins + .Select(u => u.Trim()) + .Where(u => u.Length > 0) + .Distinct() + .Count(); + return (totalEntries, uniquePermSets, distinctUsers); + } + + private static void AppendTableOpen(StringBuilder sb) + { + sb.AppendLine("
"); + sb.AppendLine(""); + } + + private static void AppendTableClose(StringBuilder sb) + { + sb.AppendLine(""); + sb.AppendLine("
"); + sb.AppendLine("
"); + } + + /// Returns inline CSS background, text, and border colors for a risk level. private static (string bg, string text, string border) RiskLevelColors(RiskLevel level) => level switch { RiskLevel.High => ("#FEE2E2", "#991B1B", "#FECACA"), @@ -206,228 +291,4 @@ function toggleGroup(id) { RiskLevel.ReadOnly => ("#DBEAFE", "#1E40AF", "#BFDBFE"), _ => ("#F3F4F6", "#374151", "#E5E7EB") }; - - /// - /// Builds a self-contained HTML string from simplified permission entries. - /// Includes risk-level summary cards, color-coded rows, and simplified labels column. - /// When is provided, SharePoint group pills become expandable. - /// - public string BuildHtml(IReadOnlyList entries, ReportBranding? branding = null, - IReadOnlyDictionary>? groupMembers = null) - { - var summaries = PermissionSummaryBuilder.Build(entries); - - var totalEntries = entries.Count; - var uniquePermSets = entries.Select(e => e.PermissionLevels).Distinct().Count(); - var distinctUsers = entries - .SelectMany(e => e.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries)) - .Select(u => u.Trim()) - .Where(u => u.Length > 0) - .Distinct() - .Count(); - - var sb = new StringBuilder(); - - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine("SharePoint Permissions Report (Simplified)"); - sb.AppendLine(""); - sb.AppendLine(""); - - sb.AppendLine(""); - sb.Append(BrandingHtmlHelper.BuildBrandingHeader(branding)); - sb.AppendLine("

SharePoint Permissions Report (Simplified)

"); - - // Stats cards - sb.AppendLine("
"); - sb.AppendLine($"
{totalEntries}
Total Entries
"); - sb.AppendLine($"
{uniquePermSets}
Unique Permission Sets
"); - sb.AppendLine($"
{distinctUsers}
Distinct Users/Groups
"); - sb.AppendLine("
"); - - // Risk-level summary cards - sb.AppendLine("
"); - foreach (var summary in summaries) - { - var (bg, text, border) = RiskLevelColors(summary.RiskLevel); - sb.AppendLine($"
"); - sb.AppendLine($"
{summary.Count}
"); - sb.AppendLine($"
{HtmlEncode(summary.Label)}
"); - sb.AppendLine($"
{summary.DistinctUsers} user(s)
"); - sb.AppendLine("
"); - } - sb.AppendLine("
"); - - // Filter input - sb.AppendLine("
"); - sb.AppendLine(" "); - sb.AppendLine("
"); - - // Table with simplified columns - sb.AppendLine("
"); - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(" "); - sb.AppendLine(""); - sb.AppendLine(""); - - int grpMemIdx = 0; - foreach (var entry in entries) - { - var typeCss = ObjectTypeCss(entry.ObjectType); - var uniqueCss = entry.HasUniquePermissions ? "badge unique" : "badge inherited"; - var uniqueLbl = entry.HasUniquePermissions ? "Unique" : "Inherited"; - var (riskBg, riskText, riskBorder) = RiskLevelColors(entry.RiskLevel); - - var logins = entry.UserLogins.Split(';', StringSplitOptions.RemoveEmptyEntries); - var names = entry.Inner.Users.Split(';', StringSplitOptions.RemoveEmptyEntries); - var pillsBuilder = new StringBuilder(); - var memberSubRows = 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 isExpandableGroup = entry.Inner.PrincipalType == "SharePointGroup" - && groupMembers != null - && groupMembers.TryGetValue(name, out _); - - if (isExpandableGroup && groupMembers != null && groupMembers.TryGetValue(name, out var resolvedMembers)) - { - var grpId = $"grpmem{grpMemIdx}"; - pillsBuilder.Append($"{HtmlEncode(name)} ▼"); - - string memberContent; - if (resolvedMembers.Count > 0) - { - var memberParts = resolvedMembers.Select(m => $"{HtmlEncode(m.DisplayName)} <{HtmlEncode(m.Login)}>"); - memberContent = string.Join(" • ", memberParts); - } - else - { - memberContent = "members unavailable"; - } - memberSubRows.AppendLine($""); - grpMemIdx++; - } - else - { - var pillCss = isExt ? "user-pill external-user" : "user-pill"; - pillsBuilder.Append($"{HtmlEncode(name)}"); - } - } - - sb.AppendLine(""); - sb.AppendLine($" "); - sb.AppendLine($" "); - sb.AppendLine($" "); - sb.AppendLine($" "); - sb.AppendLine($" "); - sb.AppendLine($" "); - sb.AppendLine($" "); - sb.AppendLine($" "); - sb.AppendLine($" "); - sb.AppendLine(""); - if (memberSubRows.Length > 0) - sb.Append(memberSubRows); - } - - sb.AppendLine(""); - sb.AppendLine("
ObjectTitleURLUniqueUsers/GroupsPermission LevelSimplifiedRiskGranted Through
{memberContent}
{HtmlEncode(entry.ObjectType)}{HtmlEncode(entry.Title)}Link{uniqueLbl}{pillsBuilder}{HtmlEncode(entry.PermissionLevels)}{HtmlEncode(entry.SimplifiedLabels)}{HtmlEncode(entry.RiskLevel.ToString())}{HtmlEncode(entry.GrantedThrough)}
"); - sb.AppendLine("
"); - - sb.AppendLine(""); - sb.AppendLine(""); - sb.AppendLine(""); - - return sb.ToString(); - } - - /// - /// Writes the simplified HTML report to the specified file path. - /// - public async Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct, - ReportBranding? branding = null, - IReadOnlyDictionary>? groupMembers = null) - { - var html = BuildHtml(entries, branding, groupMembers); - await File.WriteAllTextAsync(filePath, html, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), ct); - } - - /// Returns the CSS class for the object-type badge. - private 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. - private static string HtmlEncode(string value) - { - if (string.IsNullOrEmpty(value)) return string.Empty; - return value - .Replace("&", "&") - .Replace("<", "<") - .Replace(">", ">") - .Replace("\"", """) - .Replace("'", "'"); - } } diff --git a/SharepointToolbox/Services/Export/PermissionHtmlFragments.cs b/SharepointToolbox/Services/Export/PermissionHtmlFragments.cs new file mode 100644 index 0000000..3da4bd6 --- /dev/null +++ b/SharepointToolbox/Services/Export/PermissionHtmlFragments.cs @@ -0,0 +1,207 @@ +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("'", "'"); + } +} diff --git a/SharepointToolbox/Services/Export/ReportSplitHelper.cs b/SharepointToolbox/Services/Export/ReportSplitHelper.cs new file mode 100644 index 0000000..e57f997 --- /dev/null +++ b/SharepointToolbox/Services/Export/ReportSplitHelper.cs @@ -0,0 +1,199 @@ +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Text; + +namespace SharepointToolbox.Services.Export; + +/// +/// Shared helpers for split report exports: filename partitioning, site label +/// derivation, and bundling per-partition HTML into a single tabbed document. +/// +public static class ReportSplitHelper +{ + /// + /// Returns a file-safe variant of . Invalid filename + /// characters are replaced with underscores; whitespace runs are collapsed. + /// + public static string SanitizeFileName(string name) + { + if (string.IsNullOrWhiteSpace(name)) return "part"; + var invalid = Path.GetInvalidFileNameChars(); + var sb = new StringBuilder(name.Length); + foreach (var c in name) + sb.Append(invalid.Contains(c) || c == ' ' ? '_' : c); + var trimmed = sb.ToString().Trim('_'); + if (trimmed.Length > 80) trimmed = trimmed.Substring(0, 80); + return trimmed.Length == 0 ? "part" : trimmed; + } + + /// + /// Given a user-selected (e.g. "C:\reports\duplicates.csv"), + /// returns a partitioned path like "C:\reports\duplicates_{label}.csv". + /// + public static string BuildPartitionPath(string basePath, string partitionLabel) + { + var dir = Path.GetDirectoryName(basePath); + var stem = Path.GetFileNameWithoutExtension(basePath); + var ext = Path.GetExtension(basePath); + var safe = SanitizeFileName(partitionLabel); + var file = $"{stem}_{safe}{ext}"; + return string.IsNullOrEmpty(dir) ? file : Path.Combine(dir, file); + } + + /// + /// Extracts the site-collection root URL from an arbitrary SharePoint object URL. + /// e.g. https://t.sharepoint.com/sites/hr/Shared%20Documents/foo.docx → + /// https://t.sharepoint.com/sites/hr + /// Falls back to scheme+host for root site collections. + /// + public static string DeriveSiteCollectionUrl(string objectUrl) + { + if (string.IsNullOrWhiteSpace(objectUrl)) return string.Empty; + if (!Uri.TryCreate(objectUrl, UriKind.Absolute, out var uri)) + return objectUrl.TrimEnd('/'); + + var baseUrl = $"{uri.Scheme}://{uri.Host}"; + var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + + if (segments.Length >= 2 && + (segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) || + segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase))) + { + return $"{baseUrl}/{segments[0]}/{segments[1]}"; + } + return baseUrl; + } + + /// + /// Derives a short, human-friendly site label from a SharePoint site URL. + /// Falls back to the raw URL (sanitized) when parsing fails. + /// + public static string DeriveSiteLabel(string siteUrl, string? siteTitle = null) + { + if (!string.IsNullOrWhiteSpace(siteTitle)) return siteTitle!; + if (string.IsNullOrWhiteSpace(siteUrl)) return "site"; + try + { + var uri = new Uri(siteUrl); + var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length >= 2 && + (segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) || + segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase))) + { + return segments[1]; + } + return uri.Host; + } + catch (Exception ex) when (ex is UriFormatException or ArgumentException) + { + Debug.WriteLine($"[ReportSplitHelper] DeriveSiteLabel: malformed URL '{siteUrl}' ({ex.GetType().Name}: {ex.Message}) — falling back to raw value."); + return siteUrl; + } + } + + /// + /// Generic dispatcher for split-aware export: if + /// is not BySite, writes a single file via + /// ; otherwise partitions via + /// and writes one file per partition, + /// each at a filename derived from plus the + /// partition label. + /// + public static async Task WritePartitionedAsync( + IReadOnlyList items, + string basePath, + ReportSplitMode splitMode, + Func, IEnumerable<(string Label, IReadOnlyList Partition)>> partitioner, + Func, string, CancellationToken, Task> writer, + CancellationToken ct) + { + if (splitMode != ReportSplitMode.BySite) + { + await writer(items, basePath, ct); + return; + } + + foreach (var (label, partition) in partitioner(items)) + { + ct.ThrowIfCancellationRequested(); + var path = BuildPartitionPath(basePath, label); + await writer(partition, path, ct); + } + } + + /// + /// Bundles per-partition HTML documents into one self-contained tabbed + /// HTML. Each partition HTML is embedded in an <iframe srcdoc> so + /// their inline styles and scripts remain isolated. + /// + public static string BuildTabbedHtml( + IReadOnlyList<(string Label, string Html)> parts, + string title) + { + var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{WebUtility.HtmlEncode(title)}"); + sb.AppendLine(""" + + + + """); + sb.Append("
"); + for (int i = 0; i < parts.Count; i++) + { + var cls = i == 0 ? "tab active" : "tab"; + sb.Append($"
{WebUtility.HtmlEncode(parts[i].Label)}
"); + } + sb.AppendLine("
"); + + for (int i = 0; i < parts.Count; i++) + { + var cls = i == 0 ? "active" : string.Empty; + var escaped = EscapeForSrcdoc(parts[i].Html); + sb.AppendLine($""); + } + sb.AppendLine(""" + + + """); + return sb.ToString(); + } + + /// + /// Escapes an HTML document so it can safely appear inside an + /// <iframe srcdoc="..."> attribute. Only ampersands and double + /// quotes must be encoded; angle brackets are kept literal because the + /// parser treats srcdoc as CDATA-like content. + /// + private static string EscapeForSrcdoc(string html) + { + if (string.IsNullOrEmpty(html)) return string.Empty; + return html + .Replace("&", "&") + .Replace("\"", """); + } +} diff --git a/SharepointToolbox/Services/Export/ReportSplitMode.cs b/SharepointToolbox/Services/Export/ReportSplitMode.cs new file mode 100644 index 0000000..61575f3 --- /dev/null +++ b/SharepointToolbox/Services/Export/ReportSplitMode.cs @@ -0,0 +1,16 @@ +namespace SharepointToolbox.Services.Export; + +/// How a report export is partitioned. +public enum ReportSplitMode +{ + Single, + BySite, + ByUser +} + +/// When a report is split, how HTML output is laid out. +public enum HtmlSplitLayout +{ + SeparateFiles, + SingleTabbed +} diff --git a/SharepointToolbox/Services/Export/SearchCsvExportService.cs b/SharepointToolbox/Services/Export/SearchCsvExportService.cs index 2edc5be..070544e 100644 --- a/SharepointToolbox/Services/Export/SearchCsvExportService.cs +++ b/SharepointToolbox/Services/Export/SearchCsvExportService.cs @@ -1,6 +1,7 @@ using System.IO; using System.Text; using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; namespace SharepointToolbox.Services.Export; @@ -10,12 +11,17 @@ namespace SharepointToolbox.Services.Export; ///
public class SearchCsvExportService { + /// + /// Builds the CSV payload. Column order mirrors + /// . + /// public string BuildCsv(IReadOnlyList results) { + var T = TranslationSource.Instance; var sb = new StringBuilder(); // Header - sb.AppendLine("File Name,Extension,Path,Created,Created By,Modified,Modified By,Size (bytes)"); + sb.AppendLine($"{T["report.col.file_name"]},{T["report.col.extension"]},{T["report.col.path"]},{T["report.col.created"]},{T["report.col.created_by"]},{T["report.col.modified"]},{T["report.col.modified_by"]},{T["report.col.size_bytes"]}"); foreach (var r in results) { @@ -33,19 +39,14 @@ public class SearchCsvExportService return sb.ToString(); } + /// Writes the CSV to with UTF-8 BOM. public async Task WriteAsync(IReadOnlyList results, string filePath, CancellationToken ct) { var csv = BuildCsv(results); - await System.IO.File.WriteAllTextAsync(filePath, csv, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct); + await ExportFileWriter.WriteCsvAsync(filePath, csv, ct); } - private static string Csv(string value) - { - if (string.IsNullOrEmpty(value)) return string.Empty; - if (value.Contains(',') || value.Contains('"') || value.Contains('\n')) - return $"\"{value.Replace("\"", "\"\"")}\""; - return value; - } + private static string Csv(string value) => CsvSanitizer.EscapeMinimal(value); private static string IfEmpty(string? value, string fallback = "") => string.IsNullOrEmpty(value) ? fallback : value!; diff --git a/SharepointToolbox/Services/Export/SearchHtmlExportService.cs b/SharepointToolbox/Services/Export/SearchHtmlExportService.cs index 97cc59c..2f3c8c6 100644 --- a/SharepointToolbox/Services/Export/SearchHtmlExportService.cs +++ b/SharepointToolbox/Services/Export/SearchHtmlExportService.cs @@ -1,6 +1,7 @@ using System.IO; using System.Text; using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; namespace SharepointToolbox.Services.Export; @@ -11,17 +12,23 @@ namespace SharepointToolbox.Services.Export; /// public class SearchHtmlExportService { + /// + /// Builds a self-contained HTML table with inline sort/filter scripts. + /// Each becomes one row; the document has no + /// external dependencies. + /// public string BuildHtml(IReadOnlyList results, ReportBranding? branding = null) { + var T = TranslationSource.Instance; var sb = new StringBuilder(); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine($"{T["report.title.search"]}"); sb.AppendLine(""" - - - - - - SharePoint File Search Results + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/SharepointToolbox/ViewModels/Dialogs/SitePickerDialogLogic.cs b/SharepointToolbox/ViewModels/Dialogs/SitePickerDialogLogic.cs new file mode 100644 index 0000000..6e4cfad --- /dev/null +++ b/SharepointToolbox/ViewModels/Dialogs/SitePickerDialogLogic.cs @@ -0,0 +1,104 @@ +using System.ComponentModel; +using System.Globalization; +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services; +using SharepointToolbox.Views.Dialogs; + +namespace SharepointToolbox.ViewModels.Dialogs; + +/// +/// Headless logic for . Loads sites via +/// and applies filter/sort in memory so the +/// dialog's code-behind stays a thin shim and the logic is unit-testable +/// without a WPF host. +/// +public class SitePickerDialogLogic +{ + private readonly ISiteListService _siteListService; + private readonly TenantProfile _profile; + + public SitePickerDialogLogic(ISiteListService siteListService, TenantProfile profile) + { + _siteListService = siteListService; + _profile = profile; + } + + /// + /// Loads all accessible sites for the tenant profile and wraps them in + /// so the dialog can bind checkboxes. + /// + public async Task> LoadAsync( + IProgress progress, + CancellationToken ct) + { + var sites = await _siteListService.GetSitesAsync(_profile, progress, ct); + return sites + .Select(s => new SitePickerItem(s.Url, s.Title, s.StorageUsedMb, s.StorageQuotaMb, s.Template)) + .ToList(); + } + + /// + /// Filters items by free-text (title/url substring), storage-size range, + /// and site kind. Empty or zero-range parameters become no-ops. + /// + public static IEnumerable ApplyFilter( + IEnumerable items, + string text, + long minMb, + long maxMb, + string kindFilter) + { + var result = items; + + if (!string.IsNullOrEmpty(text)) + { + result = result.Where(i => + i.Url.Contains(text, StringComparison.OrdinalIgnoreCase) || + i.Title.Contains(text, StringComparison.OrdinalIgnoreCase)); + } + + result = result.Where(i => i.StorageUsedMb >= minMb && i.StorageUsedMb <= maxMb); + + if (!string.IsNullOrEmpty(kindFilter) && kindFilter != "All") + result = result.Where(i => i.Kind.ToString() == kindFilter); + + return result; + } + + /// + /// Stable sort by a named column and direction. Unknown column names + /// return the input sequence unchanged. + /// + public static IEnumerable ApplySort( + IEnumerable items, + string column, + ListSortDirection direction) + { + var asc = direction == ListSortDirection.Ascending; + return column switch + { + "Title" => asc ? items.OrderBy(i => i.Title) : items.OrderByDescending(i => i.Title), + "Url" => asc ? items.OrderBy(i => i.Url) : items.OrderByDescending(i => i.Url), + "Kind" => asc ? items.OrderBy(i => i.KindDisplay) : items.OrderByDescending(i => i.KindDisplay), + "StorageUsedMb" => asc + ? items.OrderBy(i => i.StorageUsedMb) + : items.OrderByDescending(i => i.StorageUsedMb), + "IsSelected" => asc + ? items.OrderBy(i => i.IsSelected) + : items.OrderByDescending(i => i.IsSelected), + _ => items + }; + } + + /// + /// Lenient long parse: whitespace-only or unparseable input yields + /// instead of throwing. + /// + public static long ParseLongOrDefault(string text, long fallback) + { + if (string.IsNullOrWhiteSpace(text)) return fallback; + return long.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) + ? v + : fallback; + } +} diff --git a/SharepointToolbox/ViewModels/FeatureViewModelBase.cs b/SharepointToolbox/ViewModels/FeatureViewModelBase.cs index 92ad9af..cf3190e 100644 --- a/SharepointToolbox/ViewModels/FeatureViewModelBase.cs +++ b/SharepointToolbox/ViewModels/FeatureViewModelBase.cs @@ -23,6 +23,9 @@ public abstract partial class FeatureViewModelBase : ObservableRecipient [ObservableProperty] private int _progressValue; + [ObservableProperty] + private bool _isIndeterminate; + /// /// Sites selected in the global toolbar picker. Updated via GlobalSitesChangedMessage. /// Derived VMs check this in RunOperationAsync before falling back to per-tab SiteUrl. @@ -46,24 +49,44 @@ public abstract partial class FeatureViewModelBase : ObservableRecipient IsRunning = true; StatusMessage = string.Empty; ProgressValue = 0; + IsIndeterminate = false; try { var progress = new Progress(p => { - ProgressValue = p.Total > 0 ? (int)(100.0 * p.Current / p.Total) : 0; + // Indeterminate reports (throttle waits, inner scan steps) must not + // reset the determinate bar to 0%; only update the status message + // and flip the bar into marquee mode. The next determinate report + // restores % and clears the marquee flag. + if (p.IsIndeterminate) + { + IsIndeterminate = true; + } + else + { + IsIndeterminate = false; + ProgressValue = p.Total > 0 ? (int)(100.0 * p.Current / p.Total) : 0; + } StatusMessage = p.Message; WeakReferenceMessenger.Default.Send(new ProgressUpdatedMessage(p)); }); await RunOperationAsync(_cts.Token, progress); + // Success path: replace any lingering "Scanning X…" with a neutral + // completion marker so stale in-progress labels don't stick around. + StatusMessage = TranslationSource.Instance["status.complete"]; + ProgressValue = 100; + IsIndeterminate = false; } catch (OperationCanceledException) { StatusMessage = TranslationSource.Instance["status.cancelled"]; + IsIndeterminate = false; _logger.LogInformation("Operation cancelled by user."); } catch (Exception ex) { StatusMessage = $"{TranslationSource.Instance["err.generic"]} {ex.Message}"; + IsIndeterminate = false; _logger.LogError(ex, "Operation failed."); } finally diff --git a/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs b/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs index 78c32aa..efcf160 100644 --- a/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs +++ b/SharepointToolbox/ViewModels/ProfileManagementViewModel.cs @@ -60,7 +60,7 @@ public partial class ProfileManagementViewModel : ObservableObject public ObservableCollection Profiles { get; } = new(); public IAsyncRelayCommand AddCommand { get; } - public IAsyncRelayCommand RenameCommand { get; } + public IAsyncRelayCommand SaveCommand { get; } public IAsyncRelayCommand DeleteCommand { get; } public IAsyncRelayCommand BrowseClientLogoCommand { get; } public IAsyncRelayCommand ClearClientLogoCommand { get; } @@ -82,7 +82,7 @@ public partial class ProfileManagementViewModel : ObservableObject _appRegistrationService = appRegistrationService; AddCommand = new AsyncRelayCommand(AddAsync, CanAdd); - RenameCommand = new AsyncRelayCommand(RenameAsync, () => SelectedProfile != null && !string.IsNullOrWhiteSpace(NewName)); + SaveCommand = new AsyncRelayCommand(SaveAsync, CanSave); DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => SelectedProfile != null); BrowseClientLogoCommand = new AsyncRelayCommand(BrowseClientLogoAsync, () => SelectedProfile != null); ClearClientLogoCommand = new AsyncRelayCommand(ClearClientLogoAsync, () => SelectedProfile != null); @@ -112,11 +112,24 @@ public partial class ProfileManagementViewModel : ObservableObject partial void OnSelectedProfileChanged(TenantProfile? value) { + if (value != null) + { + NewName = value.Name; + NewTenantUrl = value.TenantUrl; + NewClientId = value.ClientId ?? string.Empty; + } + else + { + NewName = string.Empty; + NewTenantUrl = string.Empty; + NewClientId = string.Empty; + } + ValidationMessage = string.Empty; ClientLogoPreview = FormatLogoPreview(value?.ClientLogo); BrowseClientLogoCommand.NotifyCanExecuteChanged(); ClearClientLogoCommand.NotifyCanExecuteChanged(); AutoPullClientLogoCommand.NotifyCanExecuteChanged(); - RenameCommand.NotifyCanExecuteChanged(); + SaveCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged(); OnPropertyChanged(nameof(HasRegisteredApp)); RegisterAppCommand.NotifyCanExecuteChanged(); @@ -135,17 +148,30 @@ public partial class ProfileManagementViewModel : ObservableObject private void NotifyCommandsCanExecuteChanged() { AddCommand.NotifyCanExecuteChanged(); - RenameCommand.NotifyCanExecuteChanged(); + SaveCommand.NotifyCanExecuteChanged(); } private bool CanAdd() { if (string.IsNullOrWhiteSpace(NewName)) return false; if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false; + // Fields mirror the selected profile after selection; block Add so the user doesn't + // create a duplicate — they should use Save to update, or change the name to fork. + if (SelectedProfile != null && + string.Equals(NewName.Trim(), SelectedProfile.Name, StringComparison.Ordinal)) + return false; // ClientId is optional — leaving it blank lets the user register the app from within the tool. return true; } + private bool CanSave() + { + if (SelectedProfile == null) return false; + if (string.IsNullOrWhiteSpace(NewName)) return false; + if (!Uri.TryCreate(NewTenantUrl, UriKind.Absolute, out _)) return false; + return true; + } + private async Task AddAsync() { if (!CanAdd()) return; @@ -171,19 +197,43 @@ public partial class ProfileManagementViewModel : ObservableObject } } - private async Task RenameAsync() + private async Task SaveAsync() { - if (SelectedProfile == null || string.IsNullOrWhiteSpace(NewName)) return; + if (!CanSave()) return; + var target = SelectedProfile!; try { - await _profileService.RenameProfileAsync(SelectedProfile.Name, NewName.Trim()); - SelectedProfile.Name = NewName.Trim(); - NewName = string.Empty; + var newName = NewName.Trim(); + var newUrl = NewTenantUrl.Trim(); + var newClientId = NewClientId?.Trim() ?? string.Empty; + var oldName = target.Name; + + if (!string.Equals(oldName, newName, StringComparison.Ordinal)) + { + await _profileService.RenameProfileAsync(oldName, newName); + target.Name = newName; + } + + target.TenantUrl = newUrl; + target.ClientId = newClientId; + await _profileService.UpdateProfileAsync(target); + + // Force ListBox to pick up the renamed entry (TenantProfile is a plain POCO, + // so mutating Name does not raise PropertyChanged). + var idx = Profiles.IndexOf(target); + if (idx >= 0) + { + Profiles.RemoveAt(idx); + Profiles.Insert(idx, target); + SelectedProfile = target; + } + + ValidationMessage = string.Empty; } catch (Exception ex) { ValidationMessage = ex.Message; - _logger.LogError(ex, "Failed to rename profile."); + _logger.LogError(ex, "Failed to save profile."); } } @@ -306,21 +356,17 @@ public partial class ProfileManagementViewModel : ObservableObject { // Use the profile's own ClientId if it has one; otherwise bootstrap with the // Microsoft Graph Command Line Tools public client so a first-time profile - // (name + URL only) can still perform the admin check and registration. + // (name + URL only) can still perform registration. var authClientId = string.IsNullOrWhiteSpace(SelectedProfile.ClientId) ? BootstrapClientId : SelectedProfile.ClientId; - var isAdmin = await _appRegistrationService.IsGlobalAdminAsync(authClientId, ct); - if (!isAdmin) - { - ShowFallbackInstructions = true; - RegistrationStatus = TranslationSource.Instance["profile.register.noperm"]; - return; - } - + // No preflight admin check: it used Global Admin as the criterion and + // rejected Application Admins / Cloud Application Admins who can also + // create apps. Let Entra enforce authorization via the POST itself — + // any 401/403 returns FallbackRequired and triggers the tutorial. RegistrationStatus = TranslationSource.Instance["profile.register.registering"]; - var result = await _appRegistrationService.RegisterAsync(authClientId, SelectedProfile.Name, ct); + var result = await _appRegistrationService.RegisterAsync(authClientId, SelectedProfile.TenantUrl, SelectedProfile.Name, ct); if (result.IsSuccess) { @@ -330,9 +376,18 @@ public partial class ProfileManagementViewModel : ObservableObject if (string.IsNullOrWhiteSpace(SelectedProfile.ClientId)) SelectedProfile.ClientId = result.AppId!; await _profileService.UpdateProfileAsync(SelectedProfile); + // Reflect adopted ClientId in the bound TextBox. Without this the + // field stays blank and the next Save would overwrite the stored + // ClientId with an empty string. + NewClientId = SelectedProfile.ClientId; RegistrationStatus = TranslationSource.Instance["profile.register.success"]; OnPropertyChanged(nameof(HasRegisteredApp)); } + else if (result.IsFallback) + { + ShowFallbackInstructions = true; + RegistrationStatus = TranslationSource.Instance["profile.register.noperm"]; + } else { RegistrationStatus = result.ErrorMessage ?? TranslationSource.Instance["profile.register.failed"]; @@ -356,7 +411,7 @@ public partial class ProfileManagementViewModel : ObservableObject RegistrationStatus = TranslationSource.Instance["profile.remove.removing"]; try { - await _appRegistrationService.RemoveAsync(SelectedProfile.ClientId, SelectedProfile.AppId!, ct); + await _appRegistrationService.RemoveAsync(SelectedProfile.ClientId, SelectedProfile.TenantUrl, SelectedProfile.AppId!, ct); await _appRegistrationService.ClearMsalSessionAsync(SelectedProfile.ClientId, SelectedProfile.TenantUrl); SelectedProfile.AppId = null; await _profileService.UpdateProfileAsync(SelectedProfile); diff --git a/SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs b/SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs index 2c703d7..89f81ab 100644 --- a/SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/BulkMembersViewModel.cs @@ -105,9 +105,9 @@ public partial class BulkMembersViewModel : FeatureViewModelBase protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) { - if (_currentProfile == null) throw new InvalidOperationException("No tenant connected."); + if (_currentProfile == null) throw new InvalidOperationException(TranslationSource.Instance["err.no_tenant"]); if (_validRows == null || _validRows.Count == 0) - throw new InvalidOperationException("No valid rows to process. Import a CSV first."); + throw new InvalidOperationException(TranslationSource.Instance["err.no_valid_rows"]); var message = string.Format(TranslationSource.Instance["bulk.confirm.message"], $"{_validRows.Count} members will be added"); diff --git a/SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs b/SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs index 032213a..e577272 100644 --- a/SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/BulkSitesViewModel.cs @@ -105,9 +105,9 @@ public partial class BulkSitesViewModel : FeatureViewModelBase protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) { - if (_currentProfile == null) throw new InvalidOperationException("No tenant connected."); + if (_currentProfile == null) throw new InvalidOperationException(TranslationSource.Instance["err.no_tenant"]); if (_validRows == null || _validRows.Count == 0) - throw new InvalidOperationException("No valid rows to process. Import a CSV first."); + throw new InvalidOperationException(TranslationSource.Instance["err.no_valid_rows"]); var message = string.Format(TranslationSource.Instance["bulk.confirm.message"], $"{_validRows.Count} sites will be created"); diff --git a/SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs b/SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs index b8eaf27..49caae1 100644 --- a/SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs @@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; using Microsoft.Win32; using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; using SharepointToolbox.Services; using SharepointToolbox.Services.Export; @@ -31,6 +32,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase private readonly IDuplicatesService _duplicatesService; private readonly ISessionManager _sessionManager; private readonly DuplicatesHtmlExportService _htmlExportService; + private readonly DuplicatesCsvExportService _csvExportService; private readonly IBrandingService _brandingService; private readonly ILogger _logger; private TenantProfile? _currentProfile; @@ -46,6 +48,14 @@ public partial class DuplicatesViewModel : FeatureViewModelBase [ObservableProperty] private bool _includeSubsites; [ObservableProperty] private string _library = string.Empty; + /// 0 = Single file, 1 = Split by site. + [ObservableProperty] private int _splitModeIndex; + /// 0 = Separate HTML files, 1 = Single tabbed HTML. + [ObservableProperty] private int _htmlLayoutIndex; + + private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single; + private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles; + private ObservableCollection _results = new(); public ObservableCollection Results { @@ -55,16 +65,19 @@ public partial class DuplicatesViewModel : FeatureViewModelBase _results = value; OnPropertyChanged(); ExportHtmlCommand.NotifyCanExecuteChanged(); + ExportCsvCommand.NotifyCanExecuteChanged(); } } public IAsyncRelayCommand ExportHtmlCommand { get; } + public IAsyncRelayCommand ExportCsvCommand { get; } public TenantProfile? CurrentProfile => _currentProfile; public DuplicatesViewModel( IDuplicatesService duplicatesService, ISessionManager sessionManager, DuplicatesHtmlExportService htmlExportService, + DuplicatesCsvExportService csvExportService, IBrandingService brandingService, ILogger logger) : base(logger) @@ -72,24 +85,26 @@ public partial class DuplicatesViewModel : FeatureViewModelBase _duplicatesService = duplicatesService; _sessionManager = sessionManager; _htmlExportService = htmlExportService; + _csvExportService = csvExportService; _brandingService = brandingService; _logger = logger; ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); + ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); } protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) { if (_currentProfile == null) { - StatusMessage = "No tenant selected. Please connect to a tenant first."; + StatusMessage = TranslationSource.Instance["err.no_tenant_connected"]; return; } var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList(); if (urls.Count == 0) { - StatusMessage = "Select at least one site from the toolbar."; + StatusMessage = TranslationSource.Instance["err.no_sites_selected"]; return; } @@ -152,6 +167,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase _lastGroups = Array.Empty(); OnPropertyChanged(nameof(CurrentProfile)); ExportHtmlCommand.NotifyCanExecuteChanged(); + ExportCsvCommand.NotifyCanExecuteChanged(); } internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile; @@ -179,9 +195,28 @@ public partial class DuplicatesViewModel : FeatureViewModelBase branding = new ReportBranding(mspLogo, clientLogo); } - await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CancellationToken.None, branding); + await _htmlExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding); Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true }); } catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "HTML export failed."); } } + + private async Task ExportCsvAsync() + { + if (_lastGroups.Count == 0) return; + var dialog = new SaveFileDialog + { + Title = "Export duplicates report to CSV", + Filter = "CSV files (*.csv)|*.csv|All files (*.*)|*.*", + DefaultExt = "csv", + FileName = "duplicates_report" + }; + if (dialog.ShowDialog() != true) return; + try + { + await _csvExportService.WriteAsync(_lastGroups, dialog.FileName, CurrentSplit, CancellationToken.None); + Process.Start(new ProcessStartInfo(dialog.FileName) { UseShellExecute = true }); + } + catch (Exception ex) { StatusMessage = $"Export failed: {ex.Message}"; _logger.LogError(ex, "CSV export failed."); } + } } diff --git a/SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs b/SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs index 57aa990..09ed5fc 100644 --- a/SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/FolderStructureViewModel.cs @@ -103,15 +103,16 @@ public partial class FolderStructureViewModel : FeatureViewModelBase protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) { - if (_currentProfile == null) throw new InvalidOperationException("No tenant connected."); + var T = TranslationSource.Instance; + if (_currentProfile == null) throw new InvalidOperationException(T["err.no_tenant"]); if (_validRows == null || _validRows.Count == 0) - throw new InvalidOperationException("No valid rows. Import a CSV first."); + throw new InvalidOperationException(T["err.no_valid_rows"]); if (string.IsNullOrWhiteSpace(LibraryTitle)) - throw new InvalidOperationException("Library title is required."); + throw new InvalidOperationException(T["err.library_title_required"]); var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList(); if (urls.Count == 0) - throw new InvalidOperationException("Select at least one site from the toolbar."); + throw new InvalidOperationException(T["err.no_sites_selected"]); var uniquePaths = FolderStructureService.BuildUniquePaths(_validRows); var message = string.Format(TranslationSource.Instance["bulk.confirm.message"], diff --git a/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs index dba8661..bd17501 100644 --- a/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Win32; using SharepointToolbox.Core.Messages; using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; using SharepointToolbox.Services; using SharepointToolbox.Core.Helpers; using SharepointToolbox.Services.Export; @@ -84,6 +85,14 @@ public partial class PermissionsViewModel : FeatureViewModelBase [ObservableProperty] private bool _isDetailView = true; + /// 0 = Single file, 1 = Split by site. + [ObservableProperty] private int _splitModeIndex; + /// 0 = Separate HTML files, 1 = Single tabbed HTML. + [ObservableProperty] private int _htmlLayoutIndex; + + private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single; + private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles; + /// /// Simplified wrappers computed from Results. Rebuilt when Results changes. /// @@ -214,7 +223,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList(); if (urls.Count == 0) { - StatusMessage = "Select at least one site from the toolbar."; + StatusMessage = TranslationSource.Instance["err.no_sites_selected"]; return; } @@ -308,6 +317,32 @@ public partial class PermissionsViewModel : FeatureViewModelBase /// Derives the tenant admin URL from a standard tenant URL. /// E.g. https://tenant.sharepoint.com → https://tenant-admin.sharepoint.com /// + /// + /// Extracts the site-collection root URL from an arbitrary SharePoint object URL. + /// E.g. https://t.sharepoint.com/sites/hr/Shared%20Documents/Reports → https://t.sharepoint.com/sites/hr + /// Falls back to scheme+host for root-collection URLs. + /// + internal static string DeriveSiteCollectionUrl(string objectUrl) + { + if (string.IsNullOrWhiteSpace(objectUrl)) return string.Empty; + if (!Uri.TryCreate(objectUrl, UriKind.Absolute, out var uri)) + return objectUrl.TrimEnd('/'); + + var baseUrl = $"{uri.Scheme}://{uri.Host}"; + var segments = uri.AbsolutePath.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + + // Managed paths: /sites/ or /teams/ + if (segments.Length >= 2 && + (segments[0].Equals("sites", StringComparison.OrdinalIgnoreCase) || + segments[0].Equals("teams", StringComparison.OrdinalIgnoreCase))) + { + return $"{baseUrl}/{segments[0]}/{segments[1]}"; + } + + // Root site collection + return baseUrl; + } + internal static string DeriveAdminUrl(string tenantUrl) { var uri = new Uri(tenantUrl.TrimEnd('/')); @@ -374,9 +409,9 @@ public partial class PermissionsViewModel : FeatureViewModelBase try { if (IsSimplifiedMode && SimplifiedResults.Count > 0) - await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None); + await _csvExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CancellationToken.None); else - await _csvExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None); + await _csvExportService.WriteAsync((IReadOnlyList)Results, dialog.FileName, CurrentSplit, CancellationToken.None); OpenFile(dialog.FileName); } catch (Exception ex) @@ -408,36 +443,64 @@ public partial class PermissionsViewModel : FeatureViewModelBase } IReadOnlyDictionary>? groupMembers = null; - if (_groupResolver != null && Results.Count > 0) + if (_groupResolver != null && Results.Count > 0 && _currentProfile != null) { - var groupNames = Results + // SharePoint groups live per site collection. Bucket each group + // by the site it was observed on, then resolve against that + // site's context. Using the root tenant ctx for a group that + // lives on a sub-site makes CSOM fail with "Group not found". + var groupsBySite = Results .Where(r => r.PrincipalType == "SharePointGroup") - .SelectMany(r => r.Users.Split(';', StringSplitOptions.RemoveEmptyEntries)) - .Select(n => n.Trim()) - .Where(n => n.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) + .SelectMany(r => r.Users + .Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(n => (SiteUrl: DeriveSiteCollectionUrl(r.Url), GroupName: n.Trim()))) + .Where(x => x.GroupName.Length > 0) + .GroupBy(x => x.SiteUrl, StringComparer.OrdinalIgnoreCase) .ToList(); - if (groupNames.Count > 0 && _currentProfile != null) + if (groupsBySite.Count > 0) { - try + var merged = new Dictionary>( + StringComparer.OrdinalIgnoreCase); + + foreach (var bucket in groupsBySite) { - var ctx = await _sessionManager.GetOrCreateContextAsync( - _currentProfile, CancellationToken.None); - groupMembers = await _groupResolver.ResolveGroupsAsync( - ctx, _currentProfile.ClientId, groupNames, CancellationToken.None); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Group resolution failed — exporting without member expansion."); + var distinctNames = bucket + .Select(x => x.GroupName) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + try + { + var siteProfile = new TenantProfile + { + TenantUrl = bucket.Key, + ClientId = _currentProfile.ClientId, + Name = _currentProfile.Name + }; + var ctx = await _sessionManager.GetOrCreateContextAsync( + siteProfile, CancellationToken.None); + var resolved = await _groupResolver.ResolveGroupsAsync( + ctx, _currentProfile.ClientId, distinctNames, CancellationToken.None); + foreach (var kv in resolved) + merged[kv.Key] = kv.Value; + } + catch (Exception ex) + { + _logger.LogWarning(ex, + "Group resolution failed for {Site} — continuing without member expansion.", + bucket.Key); + } } + + groupMembers = merged; } } if (IsSimplifiedMode && SimplifiedResults.Count > 0) - await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CancellationToken.None, branding, groupMembers); + await _htmlExportService.WriteAsync(SimplifiedResults.ToList(), dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers); else - await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, branding, groupMembers); + await _htmlExportService.WriteAsync((IReadOnlyList)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding, groupMembers); OpenFile(dialog.FileName); } catch (Exception ex) diff --git a/SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs b/SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs index 63461ce..78ada98 100644 --- a/SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/SearchViewModel.cs @@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; using Microsoft.Win32; using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; using SharepointToolbox.Services; using SharepointToolbox.Services.Export; @@ -79,14 +80,14 @@ public partial class SearchViewModel : FeatureViewModelBase { if (_currentProfile == null) { - StatusMessage = "No tenant selected. Please connect to a tenant first."; + StatusMessage = TranslationSource.Instance["err.no_tenant_connected"]; return; } var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList(); if (urls.Count == 0) { - StatusMessage = "Select at least one site from the toolbar."; + StatusMessage = TranslationSource.Instance["err.no_sites_selected"]; return; } diff --git a/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs b/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs index 9260762..0cc7e6e 100644 --- a/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/SettingsViewModel.cs @@ -12,6 +12,7 @@ public partial class SettingsViewModel : FeatureViewModelBase { private readonly SettingsService _settingsService; private readonly IBrandingService _brandingService; + private readonly ThemeManager _themeManager; private string _selectedLanguage = "en"; public string SelectedLanguage @@ -39,6 +40,19 @@ public partial class SettingsViewModel : FeatureViewModelBase } } + private string _selectedTheme = "System"; + public string SelectedTheme + { + get => _selectedTheme; + set + { + if (_selectedTheme == value) return; + _selectedTheme = value; + OnPropertyChanged(); + _ = ApplyThemeAsync(value); + } + } + private bool _autoTakeOwnership; public bool AutoTakeOwnership { @@ -63,11 +77,12 @@ public partial class SettingsViewModel : FeatureViewModelBase public IAsyncRelayCommand BrowseMspLogoCommand { get; } public IAsyncRelayCommand ClearMspLogoCommand { get; } - public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ILogger logger) + public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ThemeManager themeManager, ILogger logger) : base(logger) { _settingsService = settingsService; _brandingService = brandingService; + _themeManager = themeManager; BrowseFolderCommand = new RelayCommand(BrowseFolder); BrowseMspLogoCommand = new AsyncRelayCommand(BrowseMspLogoAsync); ClearMspLogoCommand = new AsyncRelayCommand(ClearMspLogoAsync); @@ -79,14 +94,29 @@ public partial class SettingsViewModel : FeatureViewModelBase _selectedLanguage = settings.Lang; _dataFolder = settings.DataFolder; _autoTakeOwnership = settings.AutoTakeOwnership; + _selectedTheme = settings.Theme; OnPropertyChanged(nameof(SelectedLanguage)); OnPropertyChanged(nameof(DataFolder)); OnPropertyChanged(nameof(AutoTakeOwnership)); + OnPropertyChanged(nameof(SelectedTheme)); var mspLogo = await _brandingService.GetMspLogoAsync(); MspLogoPreview = mspLogo is not null ? $"data:{mspLogo.MimeType};base64,{mspLogo.Base64}" : null; } + private async Task ApplyThemeAsync(string mode) + { + try + { + _themeManager.ApplyFromString(mode); + await _settingsService.SetThemeAsync(mode); + } + catch (Exception ex) + { + StatusMessage = ex.Message; + } + } + private async Task ApplyLanguageAsync(string code) { try diff --git a/SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs b/SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs index 7f15f40..507d270 100644 --- a/SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs @@ -9,6 +9,7 @@ using LiveChartsCore.SkiaSharpView.Painting; using Microsoft.Extensions.Logging; using Microsoft.Win32; using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; using SharepointToolbox.Services; using SharepointToolbox.Services.Export; using SkiaSharp; @@ -22,6 +23,9 @@ public partial class StorageViewModel : FeatureViewModelBase private readonly StorageCsvExportService _csvExportService; private readonly StorageHtmlExportService _htmlExportService; private readonly IBrandingService? _brandingService; + private readonly IOwnershipElevationService? _ownershipService; + private readonly SettingsService? _settingsService; + private readonly ThemeManager? _themeManager; private readonly ILogger _logger; private TenantProfile? _currentProfile; @@ -37,6 +41,14 @@ public partial class StorageViewModel : FeatureViewModelBase [ObservableProperty] private bool _isDonutChart = true; + /// 0 = Single file, 1 = Split by site. + [ObservableProperty] private int _splitModeIndex; + /// 0 = Separate HTML files, 1 = Single tabbed HTML. + [ObservableProperty] private int _htmlLayoutIndex; + + private ReportSplitMode CurrentSplit => SplitModeIndex == 1 ? ReportSplitMode.BySite : ReportSplitMode.Single; + private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles; + private ObservableCollection _fileTypeMetrics = new(); public ObservableCollection FileTypeMetrics { @@ -79,6 +91,15 @@ public partial class StorageViewModel : FeatureViewModelBase private set { _barYAxes = value; OnPropertyChanged(); } } + // Stable paint instances. SKDefaultTooltip/Legend bake the Fill paint reference + // into their geometry on first Initialize() and never re-read the chart's paint + // properties. Replacing instances on theme change has no effect — we mutate + // .Color in place so the new theme color renders on the next frame. + public SolidColorPaint LegendTextPaint { get; } = new(default(SKColor)); + public SolidColorPaint LegendBackgroundPaint { get; } = new(default(SKColor)); + public SolidColorPaint TooltipTextPaint { get; } = new(default(SKColor)); + public SolidColorPaint TooltipBackgroundPaint { get; } = new(default(SKColor)); + public bool IsMaxDepth { get => FolderDepth >= 999; @@ -136,7 +157,10 @@ public partial class StorageViewModel : FeatureViewModelBase StorageCsvExportService csvExportService, StorageHtmlExportService htmlExportService, IBrandingService brandingService, - ILogger logger) + ILogger logger, + IOwnershipElevationService? ownershipService = null, + SettingsService? settingsService = null, + ThemeManager? themeManager = null) : base(logger) { _storageService = storageService; @@ -144,10 +168,17 @@ public partial class StorageViewModel : FeatureViewModelBase _csvExportService = csvExportService; _htmlExportService = htmlExportService; _brandingService = brandingService; + _ownershipService = ownershipService; + _settingsService = settingsService; + _themeManager = themeManager; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); + + ApplyChartThemeColors(); + if (_themeManager is not null) + _themeManager.ThemeChanged += (_, _) => UpdateChartSeries(); } /// Test constructor — omits export services. @@ -173,14 +204,14 @@ public partial class StorageViewModel : FeatureViewModelBase { if (_currentProfile == null) { - StatusMessage = "No tenant selected. Please connect to a tenant first."; + StatusMessage = TranslationSource.Instance["err.no_tenant_connected"]; return; } var urls = GlobalSites.Select(s => s.Url).Where(u => !string.IsNullOrWhiteSpace(u)).ToList(); if (urls.Count == 0) { - StatusMessage = "Select at least one site from the toolbar."; + StatusMessage = TranslationSource.Instance["err.no_sites_selected"]; return; } @@ -194,6 +225,8 @@ public partial class StorageViewModel : FeatureViewModelBase var allNodes = new List(); var allTypeMetrics = new List(); + var autoOwnership = await IsAutoTakeOwnershipEnabled(); + int i = 0; foreach (var url in nonEmpty) { @@ -207,9 +240,30 @@ public partial class StorageViewModel : FeatureViewModelBase ClientId = _currentProfile.ClientId, Name = _currentProfile.Name }; + var ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct); - var nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct); + IReadOnlyList nodes; + try + { + nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct); + } + catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException) when (_ownershipService != null && autoOwnership) + { + _logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", url); + var adminUrl = DeriveAdminUrl(_currentProfile.TenantUrl ?? url); + var adminProfile = new TenantProfile + { + TenantUrl = adminUrl, + ClientId = _currentProfile.ClientId, + Name = _currentProfile.Name + }; + var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct); + await _ownershipService.ElevateAsync(adminCtx, url, string.Empty, ct); + + ctx = await _sessionManager.GetOrCreateContextAsync(siteProfile, ct); + nodes = await _storageService.CollectStorageAsync(ctx, options, progress, ct); + } // Backfill any libraries where StorageMetrics returned zeros await _storageService.BackfillZeroNodesAsync(ctx, nodes, progress, ct); @@ -258,6 +312,24 @@ public partial class StorageViewModel : FeatureViewModelBase ExportHtmlCommand.NotifyCanExecuteChanged(); } + private async Task IsAutoTakeOwnershipEnabled() + { + if (_settingsService == null) return false; + var settings = await _settingsService.GetSettingsAsync(); + return settings.AutoTakeOwnership; + } + + internal static string DeriveAdminUrl(string tenantUrl) + { + var uri = new Uri(tenantUrl.TrimEnd('/')); + var host = uri.Host; + if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase)) + return tenantUrl; + var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com", + StringComparison.OrdinalIgnoreCase); + return $"{uri.Scheme}://{adminHost}"; + } + internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile; internal Task TestRunOperationAsync(CancellationToken ct, IProgress progress) @@ -278,7 +350,7 @@ public partial class StorageViewModel : FeatureViewModelBase if (dialog.ShowDialog() != true) return; try { - await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None); + await _csvExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CancellationToken.None); OpenFile(dialog.FileName); } catch (Exception ex) @@ -309,7 +381,7 @@ public partial class StorageViewModel : FeatureViewModelBase branding = new ReportBranding(mspLogo, clientLogo); } - await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CancellationToken.None, branding); + await _htmlExportService.WriteAsync(Results, FileTypeMetrics, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, branding); OpenFile(dialog.FileName); } catch (Exception ex) @@ -324,11 +396,25 @@ public partial class StorageViewModel : FeatureViewModelBase UpdateChartSeries(); } + private SKColor ChartFgColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0xE7, 0xEA, 0xF1) : new SKColor(0x1F, 0x24, 0x30); + private SKColor ChartSeparatorColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x32, 0x38, 0x49) : new SKColor(0xE3, 0xE6, 0xEC); + private SKColor ChartSurfaceColor => (_themeManager?.IsDarkActive ?? false) ? new SKColor(0x1E, 0x22, 0x30) : new SKColor(0xFF, 0xFF, 0xFF); + + private void ApplyChartThemeColors() + { + LegendTextPaint.Color = ChartFgColor; + LegendBackgroundPaint.Color = ChartSurfaceColor; + TooltipTextPaint.Color = ChartFgColor; + TooltipBackgroundPaint.Color = ChartSurfaceColor; + } + private void UpdateChartSeries() { var metrics = FileTypeMetrics.ToList(); OnPropertyChanged(nameof(HasChartData)); + ApplyChartThemeColors(); + if (metrics.Count == 0) { PieChartSeries = Enumerable.Empty(); @@ -361,6 +447,7 @@ public partial class StorageViewModel : FeatureViewModelBase HoverPushout = 8, MaxRadialColumnWidth = 60, DataLabelsFormatter = _ => m.DisplayLabel, + DataLabelsPaint = new SolidColorPaint(ChartFgColor), ToolTipLabelFormatter = _ => $"{m.DisplayLabel}: {FormatBytes(m.TotalSizeBytes)} ({m.FileCount} files)", IsVisibleAtLegend = true, @@ -379,7 +466,8 @@ public partial class StorageViewModel : FeatureViewModelBase { int idx = (int)point.Index; return idx < chartItems.Count ? FormatBytes(chartItems[idx].TotalSizeBytes) : ""; - } + }, + DataLabelsPaint = new SolidColorPaint(ChartFgColor) } }; @@ -388,7 +476,10 @@ public partial class StorageViewModel : FeatureViewModelBase new Axis { Labels = chartItems.Select(m => m.DisplayLabel).ToArray(), - LabelsRotation = -45 + LabelsRotation = -45, + LabelsPaint = new SolidColorPaint(ChartFgColor), + TicksPaint = new SolidColorPaint(ChartFgColor), + SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor) } }; @@ -396,7 +487,10 @@ public partial class StorageViewModel : FeatureViewModelBase { new Axis { - Labeler = value => FormatBytes((long)value) + Labeler = value => FormatBytes((long)value), + LabelsPaint = new SolidColorPaint(ChartFgColor), + TicksPaint = new SolidColorPaint(ChartFgColor), + SeparatorsPaint = new SolidColorPaint(ChartSeparatorColor) } }; } diff --git a/SharepointToolbox/ViewModels/Tabs/TemplatesViewModel.cs b/SharepointToolbox/ViewModels/Tabs/TemplatesViewModel.cs index 071c8c2..f6db506 100644 --- a/SharepointToolbox/ViewModels/Tabs/TemplatesViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/TemplatesViewModel.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using Serilog; using SharepointToolbox.Core.Models; using SharepointToolbox.Infrastructure.Persistence; +using SharepointToolbox.Localization; using SharepointToolbox.Services; namespace SharepointToolbox.ViewModels.Tabs; @@ -39,6 +40,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase // Apply options [ObservableProperty] private string _newSiteTitle = string.Empty; [ObservableProperty] private string _newSiteAlias = string.Empty; + private bool _aliasManuallyEdited; public IAsyncRelayCommand CaptureCommand { get; } public IAsyncRelayCommand ApplyCommand { get; } @@ -78,19 +80,20 @@ public partial class TemplatesViewModel : FeatureViewModelBase private async Task CaptureAsync() { + var T = TranslationSource.Instance; if (_currentProfile == null) - throw new InvalidOperationException("No tenant connected."); + throw new InvalidOperationException(T["err.no_tenant"]); if (string.IsNullOrWhiteSpace(TemplateName)) - throw new InvalidOperationException("Template name is required."); + throw new InvalidOperationException(T["err.template_name_required"]); var captureSiteUrl = GlobalSites.Select(s => s.Url).FirstOrDefault(u => !string.IsNullOrWhiteSpace(u)); if (string.IsNullOrWhiteSpace(captureSiteUrl)) - throw new InvalidOperationException("Select at least one site from the toolbar."); + throw new InvalidOperationException(T["err.no_sites_selected"]); try { IsRunning = true; - StatusMessage = "Capturing template..."; + StatusMessage = T["templates.status.capturing"]; var profile = new TenantProfile { @@ -117,11 +120,11 @@ public partial class TemplatesViewModel : FeatureViewModelBase Log.Information("Template captured: {Name} from {Url}", template.Name, captureSiteUrl); await RefreshListAsync(); - StatusMessage = $"Template captured successfully."; + StatusMessage = T["templates.status.success"]; } catch (Exception ex) { - StatusMessage = $"Capture failed: {ex.Message}"; + StatusMessage = string.Format(T["templates.status.capture_failed"], ex.Message); Log.Error(ex, "Template capture failed"); } finally @@ -133,15 +136,23 @@ public partial class TemplatesViewModel : FeatureViewModelBase private async Task ApplyAsync() { if (_currentProfile == null || SelectedTemplate == null) return; + var T = TranslationSource.Instance; if (string.IsNullOrWhiteSpace(NewSiteTitle)) - throw new InvalidOperationException("New site title is required."); + throw new InvalidOperationException(T["err.site_title_required"]); + + // Auto-fill alias from title if user left it blank if (string.IsNullOrWhiteSpace(NewSiteAlias)) - throw new InvalidOperationException("New site alias is required."); + { + var generated = GenerateAliasFromTitle(NewSiteTitle); + if (string.IsNullOrWhiteSpace(generated)) + throw new InvalidOperationException(T["err.site_alias_required"]); + NewSiteAlias = generated; + } try { IsRunning = true; - StatusMessage = $"Applying template..."; + StatusMessage = T["templates.status.applying"]; var ctx = await _sessionManager.GetOrCreateContextAsync(_currentProfile, CancellationToken.None); var progress = new Progress(p => StatusMessage = p.Message); @@ -150,12 +161,12 @@ public partial class TemplatesViewModel : FeatureViewModelBase ctx, SelectedTemplate, NewSiteTitle, NewSiteAlias, progress, CancellationToken.None); - StatusMessage = $"Template applied. Site created at: {siteUrl}"; + StatusMessage = string.Format(T["templates.status.applied"], siteUrl); Log.Information("Template applied. New site: {Url}", siteUrl); } catch (Exception ex) { - StatusMessage = $"Apply failed: {ex.Message}"; + StatusMessage = string.Format(T["templates.status.apply_failed"], ex.Message); Log.Error(ex, "Template apply failed"); } finally @@ -204,6 +215,7 @@ public partial class TemplatesViewModel : FeatureViewModelBase TemplateName = string.Empty; NewSiteTitle = string.Empty; NewSiteAlias = string.Empty; + _aliasManuallyEdited = false; StatusMessage = string.Empty; _ = RefreshListAsync(); @@ -215,4 +227,44 @@ public partial class TemplatesViewModel : FeatureViewModelBase RenameCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged(); } + + partial void OnNewSiteTitleChanged(string value) + { + if (_aliasManuallyEdited) return; + var generated = GenerateAliasFromTitle(value); + if (NewSiteAlias != generated) + { + // Bypass user-edit flag while we sync alias to title + _suppressAliasEditedFlag = true; + NewSiteAlias = generated; + _suppressAliasEditedFlag = false; + } + } + + private bool _suppressAliasEditedFlag; + + partial void OnNewSiteAliasChanged(string value) + { + if (_suppressAliasEditedFlag) return; + _aliasManuallyEdited = !string.IsNullOrWhiteSpace(value) + && value != GenerateAliasFromTitle(NewSiteTitle); + if (string.IsNullOrWhiteSpace(value)) _aliasManuallyEdited = false; + } + + internal static string GenerateAliasFromTitle(string title) + { + if (string.IsNullOrWhiteSpace(title)) return string.Empty; + + var normalized = title.Normalize(System.Text.NormalizationForm.FormD); + var sb = new System.Text.StringBuilder(normalized.Length); + foreach (var c in normalized) + { + var cat = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c); + if (cat == System.Globalization.UnicodeCategory.NonSpacingMark) continue; + if (char.IsLetterOrDigit(c)) sb.Append(c); + else if (c == ' ' || c == '-' || c == '_') sb.Append('-'); + } + var collapsed = System.Text.RegularExpressions.Regex.Replace(sb.ToString(), "-+", "-"); + return collapsed.Trim('-'); + } } diff --git a/SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs b/SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs index 45c8ecd..fbf10a3 100644 --- a/SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Win32; using Serilog; using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; using SharepointToolbox.Services; using SharepointToolbox.Services.Export; @@ -15,6 +16,8 @@ public partial class TransferViewModel : FeatureViewModelBase private readonly IFileTransferService _transferService; private readonly ISessionManager _sessionManager; private readonly BulkResultCsvExportService _exportService; + private readonly IOwnershipElevationService? _ownershipService; + private readonly SettingsService? _settingsService; private readonly ILogger _logger; private TenantProfile? _currentProfile; private bool _hasLocalSourceSiteOverride; @@ -32,6 +35,17 @@ public partial class TransferViewModel : FeatureViewModelBase // Transfer options [ObservableProperty] private TransferMode _transferMode = TransferMode.Copy; [ObservableProperty] private ConflictPolicy _conflictPolicy = ConflictPolicy.Skip; + [ObservableProperty] private bool _includeSourceFolder; + [ObservableProperty] private bool _copyFolderContents = true; + + /// + /// Library-relative file paths the user checked in the source picker. + /// When non-empty, only these files are transferred — folder recursion is skipped. + /// + public List SelectedFilePaths { get; } = new(); + + /// Count of per-file selections, for display in the view. + public int SelectedFileCount => SelectedFilePaths.Count; // Results [ObservableProperty] private string _resultSummary = string.Empty; @@ -51,12 +65,16 @@ public partial class TransferViewModel : FeatureViewModelBase IFileTransferService transferService, ISessionManager sessionManager, BulkResultCsvExportService exportService, - ILogger logger) + ILogger logger, + IOwnershipElevationService? ownershipService = null, + SettingsService? settingsService = null) : base(logger) { _transferService = transferService; _sessionManager = sessionManager; _exportService = exportService; + _ownershipService = ownershipService; + _settingsService = settingsService; _logger = logger; ExportFailedCommand = new AsyncRelayCommand(ExportFailedAsync, () => HasFailures); @@ -84,14 +102,15 @@ public partial class TransferViewModel : FeatureViewModelBase protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) { + var T = TranslationSource.Instance; if (_currentProfile == null) - throw new InvalidOperationException("No tenant connected."); + throw new InvalidOperationException(T["err.no_tenant"]); if (string.IsNullOrWhiteSpace(SourceSiteUrl) || string.IsNullOrWhiteSpace(SourceLibrary)) - throw new InvalidOperationException("Source site and library must be selected."); + throw new InvalidOperationException(T["err.transfer_source_required"]); if (string.IsNullOrWhiteSpace(DestSiteUrl) || string.IsNullOrWhiteSpace(DestLibrary)) - throw new InvalidOperationException("Destination site and library must be selected."); + throw new InvalidOperationException(T["err.transfer_dest_required"]); // Confirmation dialog var message = $"{TransferMode} files from {SourceLibrary} to {DestLibrary} ({ConflictPolicy} on conflict)"; @@ -108,6 +127,9 @@ public partial class TransferViewModel : FeatureViewModelBase DestinationFolderPath = DestFolderPath, Mode = TransferMode, ConflictPolicy = ConflictPolicy, + SelectedFilePaths = SelectedFilePaths.ToList(), + IncludeSourceFolder = IncludeSourceFolder, + CopyFolderContents = CopyFolderContents, }; // Build per-site profiles so SessionManager can resolve contexts @@ -127,7 +149,33 @@ public partial class TransferViewModel : FeatureViewModelBase var srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct); var dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, ct); - _lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct); + var autoOwnership = await IsAutoTakeOwnershipEnabled(); + + try + { + _lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct); + } + catch (Microsoft.SharePoint.Client.ServerUnauthorizedAccessException ex) + when (_ownershipService != null && autoOwnership) + { + _logger.LogWarning(ex, "Transfer hit access denied — auto-elevating on source and destination."); + var adminUrl = DeriveAdminUrl(_currentProfile.TenantUrl ?? SourceSiteUrl); + var adminProfile = new TenantProfile + { + Name = _currentProfile.Name, + TenantUrl = adminUrl, + ClientId = _currentProfile.ClientId, + }; + var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, ct); + + await _ownershipService.ElevateAsync(adminCtx, SourceSiteUrl, string.Empty, ct); + await _ownershipService.ElevateAsync(adminCtx, DestSiteUrl, string.Empty, ct); + + // Retry with fresh contexts so the new admin membership is honoured. + srcCtx = await _sessionManager.GetOrCreateContextAsync(srcProfile, ct); + dstCtx = await _sessionManager.GetOrCreateContextAsync(dstProfile, ct); + _lastResult = await _transferService.TransferAsync(srcCtx, dstCtx, job, progress, ct); + } // Update UI on dispatcher await Application.Current.Dispatcher.InvokeAsync(() => @@ -182,6 +230,34 @@ public partial class TransferViewModel : FeatureViewModelBase DestFolderPath = string.Empty; ResultSummary = string.Empty; HasFailures = false; + SelectedFilePaths.Clear(); + OnPropertyChanged(nameof(SelectedFileCount)); _lastResult = null; } + + /// Replaces the current per-file selection and notifies the view. + public void SetSelectedFiles(IEnumerable libraryRelativePaths) + { + SelectedFilePaths.Clear(); + SelectedFilePaths.AddRange(libraryRelativePaths); + OnPropertyChanged(nameof(SelectedFileCount)); + } + + private async Task IsAutoTakeOwnershipEnabled() + { + if (_settingsService == null) return false; + var settings = await _settingsService.GetSettingsAsync(); + return settings.AutoTakeOwnership; + } + + internal static string DeriveAdminUrl(string tenantUrl) + { + var uri = new Uri(tenantUrl.TrimEnd('/')); + var host = uri.Host; + if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase)) + return tenantUrl; + var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com", + StringComparison.OrdinalIgnoreCase); + return $"{uri.Scheme}://{adminHost}"; + } } diff --git a/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs b/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs index ae909c8..31cc62b 100644 --- a/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs @@ -8,6 +8,7 @@ using CommunityToolkit.Mvvm.Input; using Microsoft.Extensions.Logging; using Microsoft.Win32; using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; using SharepointToolbox.Services; using SharepointToolbox.Services.Export; @@ -27,6 +28,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase private readonly UserAccessHtmlExportService? _htmlExportService; private readonly IBrandingService? _brandingService; private readonly IGraphUserDirectoryService? _graphUserDirectoryService; + private readonly IOwnershipElevationService? _ownershipService; + private readonly SettingsService? _settingsService; private readonly ILogger _logger; // ── People picker debounce ────────────────────────────────────────────── @@ -105,6 +108,19 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase [ObservableProperty] private bool _mergePermissions; + /// 0 = Single file, 1 = Split by site, 2 = Split by user. + [ObservableProperty] private int _splitModeIndex; + /// 0 = Separate HTML files, 1 = Single tabbed HTML. + [ObservableProperty] private int _htmlLayoutIndex; + + private ReportSplitMode CurrentSplit => SplitModeIndex switch + { + 1 => ReportSplitMode.BySite, + 2 => ReportSplitMode.ByUser, + _ => ReportSplitMode.Single + }; + private HtmlSplitLayout CurrentLayout => HtmlLayoutIndex == 1 ? HtmlSplitLayout.SingleTabbed : HtmlSplitLayout.SeparateFiles; + private CancellationTokenSource? _directoryCts = null; // ── Computed summary properties ───────────────────────────────────────── @@ -163,7 +179,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase UserAccessHtmlExportService htmlExportService, IBrandingService brandingService, IGraphUserDirectoryService graphUserDirectoryService, - ILogger logger) + ILogger logger, + IOwnershipElevationService? ownershipService = null, + SettingsService? settingsService = null) : base(logger) { _auditService = auditService; @@ -173,6 +191,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase _htmlExportService = htmlExportService; _brandingService = brandingService; _graphUserDirectoryService = graphUserDirectoryService; + _ownershipService = ownershipService; + _settingsService = settingsService; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); @@ -248,13 +268,13 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase { if (SelectedUsers.Count == 0) { - StatusMessage = "Add at least one user to audit."; + StatusMessage = TranslationSource.Instance["err.no_users_selected"]; return; } if (GlobalSites.Count == 0) { - StatusMessage = "Select at least one site from the toolbar."; + StatusMessage = TranslationSource.Instance["err.no_sites_selected"]; return; } @@ -269,10 +289,39 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase if (_currentProfile == null) { - StatusMessage = "No tenant profile selected. Please connect first."; + StatusMessage = TranslationSource.Instance["err.no_profile_selected"]; return; } + var autoOwnership = await IsAutoTakeOwnershipEnabled(); + + Func>? onAccessDenied = null; + if (_ownershipService != null && autoOwnership) + { + onAccessDenied = async (siteUrl, token) => + { + try + { + _logger.LogWarning("Access denied on {Url}, auto-elevating ownership...", siteUrl); + var adminUrl = DeriveAdminUrl(_currentProfile?.TenantUrl ?? siteUrl); + var adminProfile = new TenantProfile + { + TenantUrl = adminUrl, + ClientId = _currentProfile?.ClientId ?? string.Empty, + Name = _currentProfile?.Name ?? string.Empty + }; + var adminCtx = await _sessionManager.GetOrCreateContextAsync(adminProfile, token); + await _ownershipService.ElevateAsync(adminCtx, siteUrl, string.Empty, token); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Auto-elevation failed for {Url}", siteUrl); + return false; + } + }; + } + var entries = await _auditService.AuditUsersAsync( _sessionManager, _currentProfile, @@ -280,7 +329,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase effectiveSites, scanOptions, progress, - ct); + ct, + onAccessDenied); // Update Results on the UI thread — clear + repopulate (not replace) // so the CollectionViewSource bound to ResultsView stays connected. @@ -307,6 +357,26 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase ExportHtmlCommand.NotifyCanExecuteChanged(); } + // ── Auto-ownership helpers ─────────────────────────────────────────────── + + private async Task IsAutoTakeOwnershipEnabled() + { + if (_settingsService == null) return false; + var settings = await _settingsService.GetSettingsAsync(); + return settings.AutoTakeOwnership; + } + + internal static string DeriveAdminUrl(string tenantUrl) + { + var uri = new Uri(tenantUrl.TrimEnd('/')); + var host = uri.Host; + if (host.Contains("-admin.sharepoint.com", StringComparison.OrdinalIgnoreCase)) + return tenantUrl; + var adminHost = host.Replace(".sharepoint.com", "-admin.sharepoint.com", + StringComparison.OrdinalIgnoreCase); + return $"{uri.Scheme}://{adminHost}"; + } + // ── Tenant switching ───────────────────────────────────────────────────── protected override void OnTenantSwitched(TenantProfile profile) @@ -402,7 +472,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase var clientId = _currentProfile?.ClientId; if (string.IsNullOrEmpty(clientId)) { - StatusMessage = "No tenant profile selected. Please connect first."; + StatusMessage = TranslationSource.Instance["err.no_profile_selected"]; return; } @@ -496,7 +566,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase if (dialog.ShowDialog() != true) return; try { - await _csvExportService.WriteSingleFileAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions); + await _csvExportService.WriteAsync((IReadOnlyList)Results, dialog.FileName, CurrentSplit, CancellationToken.None, MergePermissions); OpenFile(dialog.FileName); } catch (Exception ex) @@ -527,7 +597,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase branding = new ReportBranding(mspLogo, clientLogo); } - await _htmlExportService.WriteAsync(Results, dialog.FileName, CancellationToken.None, MergePermissions, branding); + await _htmlExportService.WriteAsync((IReadOnlyList)Results, dialog.FileName, CurrentSplit, CurrentLayout, CancellationToken.None, MergePermissions, branding); OpenFile(dialog.FileName); } catch (Exception ex) diff --git a/SharepointToolbox/Views/Common/Spinner.xaml b/SharepointToolbox/Views/Common/Spinner.xaml new file mode 100644 index 0000000..c3a7409 --- /dev/null +++ b/SharepointToolbox/Views/Common/Spinner.xaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + diff --git a/SharepointToolbox/Views/Common/Spinner.xaml.cs b/SharepointToolbox/Views/Common/Spinner.xaml.cs new file mode 100644 index 0000000..1cc6737 --- /dev/null +++ b/SharepointToolbox/Views/Common/Spinner.xaml.cs @@ -0,0 +1,11 @@ +using System.Windows.Controls; + +namespace SharepointToolbox.Views.Common; + +public partial class Spinner : UserControl +{ + public Spinner() + { + InitializeComponent(); + } +} diff --git a/SharepointToolbox/Views/Dialogs/ConfirmBulkOperationDialog.xaml b/SharepointToolbox/Views/Dialogs/ConfirmBulkOperationDialog.xaml index dc8180f..337c144 100644 --- a/SharepointToolbox/Views/Dialogs/ConfirmBulkOperationDialog.xaml +++ b/SharepointToolbox/Views/Dialogs/ConfirmBulkOperationDialog.xaml @@ -4,6 +4,9 @@ xmlns:loc="clr-namespace:SharepointToolbox.Localization" Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[bulk.confirm.title]}" Width="450" Height="220" WindowStartupLocation="CenterOwner" + Background="{DynamicResource AppBgBrush}" + Foreground="{DynamicResource TextBrush}" + TextOptions.TextFormattingMode="Ideal" ResizeMode="NoResize"> diff --git a/SharepointToolbox/Views/Dialogs/FolderBrowserDialog.xaml b/SharepointToolbox/Views/Dialogs/FolderBrowserDialog.xaml index 62c9633..8e24325 100644 --- a/SharepointToolbox/Views/Dialogs/FolderBrowserDialog.xaml +++ b/SharepointToolbox/Views/Dialogs/FolderBrowserDialog.xaml @@ -3,13 +3,24 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:loc="clr-namespace:SharepointToolbox.Localization" Title="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[folderbrowser.title]}" - Width="400" Height="500" WindowStartupLocation="CenterOwner" + Width="520" Height="560" WindowStartupLocation="CenterOwner" + Background="{DynamicResource AppBgBrush}" + Foreground="{DynamicResource TextBrush}" + TextOptions.TextFormattingMode="Ideal" ResizeMode="CanResizeWithGrip"> + + +