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:
Dev
2026-04-20 11:23:11 +02:00
parent 8f30a60d2a
commit f4cc81bb71
64 changed files with 3315 additions and 405 deletions
@@ -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)