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/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/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/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/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/Localization/Strings.fr.resx b/SharepointToolbox/Localization/Strings.fr.resx index 5f39a1d..8a0c938 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 @@ -139,6 +151,9 @@ Prêt + + Terminé + Opération annulée @@ -437,4 +452,89 @@ 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 + # + 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 diff --git a/SharepointToolbox/Localization/Strings.resx b/SharepointToolbox/Localization/Strings.resx index 14a3075..f116aad 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 @@ -139,6 +151,9 @@ Ready + + Complete + Operation cancelled @@ -437,4 +452,89 @@ 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 + # + 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 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/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..3098c59 100644 --- a/SharepointToolbox/Services/DuplicatesService.cs +++ b/SharepointToolbox/Services/DuplicatesService.cs @@ -102,10 +102,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)) diff --git a/SharepointToolbox/Services/Export/BulkResultCsvExportService.cs b/SharepointToolbox/Services/Export/BulkResultCsvExportService.cs index 58498f7..bef30d2 100644 --- a/SharepointToolbox/Services/Export/BulkResultCsvExportService.cs +++ b/SharepointToolbox/Services/Export/BulkResultCsvExportService.cs @@ -3,6 +3,7 @@ using System.IO; using System.Text; using CsvHelper; using SharepointToolbox.Core.Models; +using SharepointToolbox.Localization; namespace SharepointToolbox.Services.Export; @@ -10,12 +11,13 @@ public class BulkResultCsvExportService { public string BuildFailedItemsCsv(IReadOnlyList> failedItems) { + var TL = TranslationSource.Instance; using var writer = new StringWriter(); using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); 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)) diff --git a/SharepointToolbox/Services/Export/CsvExportService.cs b/SharepointToolbox/Services/Export/CsvExportService.cs index 4f96c44..1bfdc68 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 @@ -61,8 +65,11 @@ public class CsvExportService /// /// 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)) diff --git a/SharepointToolbox/Services/Export/DuplicatesCsvExportService.cs b/SharepointToolbox/Services/Export/DuplicatesCsvExportService.cs new file mode 100644 index 0000000..2148c5e --- /dev/null +++ b/SharepointToolbox/Services/Export/DuplicatesCsvExportService.cs @@ -0,0 +1,74 @@ +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 +{ + public async Task WriteAsync( + IReadOnlyList groups, + string filePath, + CancellationToken ct) + { + 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("Group"), + Csv(T["report.text.copies"]), + 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"]), + })); + + // Rows + 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.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), + })); + } + } + + await File.WriteAllTextAsync(filePath, sb.ToString(), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: true), ct); + } + + private static string Csv(string value) + { + if (string.IsNullOrEmpty(value)) return "\"\""; + return $"\"{value.Replace("\"", "\"\"")}\""; + } +} diff --git a/SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs b/SharepointToolbox/Services/Export/DuplicatesHtmlExportService.cs index 73d0abb..c762166 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; @@ -12,15 +13,16 @@ public class DuplicatesHtmlExportService { 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/Tabs/DuplicatesViewModel.cs b/SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs index b8eaf27..5759e67 100644 --- a/SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/DuplicatesViewModel.cs @@ -31,6 +31,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; @@ -55,16 +56,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,10 +76,12 @@ 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) @@ -152,6 +158,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase _lastGroups = Array.Empty(); OnPropertyChanged(nameof(CurrentProfile)); ExportHtmlCommand.NotifyCanExecuteChanged(); + ExportCsvCommand.NotifyCanExecuteChanged(); } internal void SetCurrentProfile(TenantProfile profile) => _currentProfile = profile; @@ -184,4 +191,23 @@ public partial class DuplicatesViewModel : FeatureViewModelBase } 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, 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/PermissionsViewModel.cs b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs index dba8661..e9e034f 100644 --- a/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs @@ -308,6 +308,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('/')); @@ -408,29 +434,57 @@ 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; } } 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..8043d19 100644 --- a/SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/StorageViewModel.cs @@ -22,6 +22,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; @@ -136,7 +139,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 +150,16 @@ 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); + + if (_themeManager is not null) + _themeManager.ThemeChanged += (_, _) => UpdateChartSeries(); } /// Test constructor — omits export services. @@ -194,6 +206,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 +221,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 +293,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) @@ -324,6 +377,9 @@ 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 void UpdateChartSeries() { var metrics = FileTypeMetrics.ToList(); @@ -361,6 +417,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 +436,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 +446,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 +457,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/TransferViewModel.cs b/SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs index 45c8ecd..6864581 100644 --- a/SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/TransferViewModel.cs @@ -15,6 +15,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 +34,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 +64,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); @@ -108,6 +125,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 +147,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 +228,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..04d7bdb 100644 --- a/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs +++ b/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs @@ -27,6 +27,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 ────────────────────────────────────────────── @@ -163,7 +165,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 +177,8 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase _htmlExportService = htmlExportService; _brandingService = brandingService; _graphUserDirectoryService = graphUserDirectoryService; + _ownershipService = ownershipService; + _settingsService = settingsService; _logger = logger; ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); @@ -273,6 +279,35 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase 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 +315,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 +343,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) 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..cff3ccc 100644 --- a/SharepointToolbox/Views/Dialogs/FolderBrowserDialog.xaml +++ b/SharepointToolbox/Views/Dialogs/FolderBrowserDialog.xaml @@ -3,13 +3,23 @@ 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"> + + +