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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<FeatureViewModelBase> _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<FeatureViewModelBase> 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<OperationProgress> progress)
|
||||
@@ -152,6 +158,7 @@ public partial class DuplicatesViewModel : FeatureViewModelBase
|
||||
_lastGroups = Array.Empty<DuplicateGroup>();
|
||||
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."); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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/<name> or /teams/<name>
|
||||
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<string, IReadOnlyList<ResolvedMember>>? 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<string, IReadOnlyList<ResolvedMember>>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<FeatureViewModelBase> logger)
|
||||
public SettingsViewModel(SettingsService settingsService, IBrandingService brandingService, ThemeManager themeManager, ILogger<FeatureViewModelBase> 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
|
||||
|
||||
@@ -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<FeatureViewModelBase> _logger;
|
||||
private TenantProfile? _currentProfile;
|
||||
|
||||
@@ -136,7 +139,10 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
StorageCsvExportService csvExportService,
|
||||
StorageHtmlExportService htmlExportService,
|
||||
IBrandingService brandingService,
|
||||
ILogger<FeatureViewModelBase> logger)
|
||||
ILogger<FeatureViewModelBase> 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();
|
||||
}
|
||||
|
||||
/// <summary>Test constructor — omits export services.</summary>
|
||||
@@ -194,6 +206,8 @@ public partial class StorageViewModel : FeatureViewModelBase
|
||||
var allNodes = new List<StorageNode>();
|
||||
var allTypeMetrics = new List<FileTypeMetric>();
|
||||
|
||||
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<StorageNode> 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<bool> 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<OperationProgress> 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)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<FeatureViewModelBase> _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;
|
||||
|
||||
/// <summary>
|
||||
/// Library-relative file paths the user checked in the source picker.
|
||||
/// When non-empty, only these files are transferred — folder recursion is skipped.
|
||||
/// </summary>
|
||||
public List<string> SelectedFilePaths { get; } = new();
|
||||
|
||||
/// <summary>Count of per-file selections, for display in the view.</summary>
|
||||
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<FeatureViewModelBase> logger)
|
||||
ILogger<FeatureViewModelBase> 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;
|
||||
}
|
||||
|
||||
/// <summary>Replaces the current per-file selection and notifies the view.</summary>
|
||||
public void SetSelectedFiles(IEnumerable<string> libraryRelativePaths)
|
||||
{
|
||||
SelectedFilePaths.Clear();
|
||||
SelectedFilePaths.AddRange(libraryRelativePaths);
|
||||
OnPropertyChanged(nameof(SelectedFileCount));
|
||||
}
|
||||
|
||||
private async Task<bool> 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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<FeatureViewModelBase> _logger;
|
||||
|
||||
// ── People picker debounce ──────────────────────────────────────────────
|
||||
@@ -163,7 +165,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
|
||||
UserAccessHtmlExportService htmlExportService,
|
||||
IBrandingService brandingService,
|
||||
IGraphUserDirectoryService graphUserDirectoryService,
|
||||
ILogger<FeatureViewModelBase> logger)
|
||||
ILogger<FeatureViewModelBase> 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<string, CancellationToken, Task<bool>>? 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<bool> 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)
|
||||
|
||||
Reference in New Issue
Block a user