chore: archive v1.1 Enhanced Reports milestone
Some checks failed
Release SharePoint Toolbox v2 / release (push) Failing after 14s

v1.1 shipped with 4 phases (25 plans), 10/10 requirements complete:
- Global site selection (toolbar picker, all tabs consume)
- User access audit (Graph people-picker, direct/group/inherited)
- Simplified permissions (plain-language labels, risk levels, detail toggle)
- Storage visualization (LiveCharts2 pie/donut + bar charts)

Post-phase polish: centralized site selection (removed per-tab pickers),
claims prefix stripping, StorageMetrics backfill, chart tooltip fix,
summary stats in app + HTML exports.

205 tests passing, 10,484 LOC.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-08 10:21:02 +02:00
parent fa793c5489
commit fd442f3b4c
35 changed files with 1062 additions and 760 deletions

View File

@@ -99,25 +99,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
public IAsyncRelayCommand ExportCsvCommand { get; }
public IAsyncRelayCommand ExportHtmlCommand { get; }
public RelayCommand OpenSitePickerCommand { get; }
public RelayCommand<GraphUserResult> AddUserCommand { get; }
public RelayCommand<GraphUserResult> RemoveUserCommand { get; }
// ── Multi-site ──────────────────────────────────────────────────────────
public ObservableCollection<SiteInfo> SelectedSites { get; } = new();
/// <summary>
/// True when the user has manually selected sites via the site picker.
/// Prevents global site changes from overwriting the local selection.
/// </summary>
private bool _hasLocalSiteOverride;
// ── Dialog factory ──────────────────────────────────────────────────────
/// <summary>Factory set by the View layer to open the SitePickerDialog without importing Window into ViewModel.</summary>
public Func<Window>? OpenSitePickerDialog { get; set; }
// ── Current tenant profile ──────────────────────────────────────────────
internal TenantProfile? _currentProfile;
@@ -125,12 +109,6 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
/// <summary>Public accessor for the current tenant profile — used by View layer dialog factory.</summary>
public TenantProfile? CurrentProfile => _currentProfile;
/// <summary>Label shown in the UI: "3 site(s) selected" or empty when none are selected.</summary>
public string SitesSelectedLabel =>
SelectedSites.Count > 0
? $"{SelectedSites.Count} site(s) selected"
: string.Empty;
// ── Constructors ────────────────────────────────────────────────────────
/// <summary>Full constructor — used by DI and production code.</summary>
@@ -152,11 +130,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
var cvs = new CollectionViewSource { Source = Results };
@@ -181,11 +157,9 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
AddUserCommand = new RelayCommand<GraphUserResult>(ExecuteAddUser);
RemoveUserCommand = new RelayCommand<GraphUserResult>(ExecuteRemoveUser);
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
SelectedUsers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SelectedUsersLabel));
var cvs = new CollectionViewSource { Source = Results };
@@ -195,15 +169,6 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
// ── FeatureViewModelBase implementation ─────────────────────────────────
protected override void OnGlobalSitesChanged(IReadOnlyList<SiteInfo> sites)
{
if (_hasLocalSiteOverride) return;
SelectedSites.Clear();
foreach (var site in sites)
SelectedSites.Add(site);
}
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
{
if (SelectedUsers.Count == 0)
@@ -212,16 +177,14 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
return;
}
var effectiveSites = SelectedSites.Count > 0
? SelectedSites.ToList()
: GlobalSites.ToList();
if (effectiveSites.Count == 0)
if (GlobalSites.Count == 0)
{
StatusMessage = "Select at least one site to scan.";
StatusMessage = "Select at least one site from the toolbar.";
return;
}
var effectiveSites = GlobalSites.ToList();
var userLogins = SelectedUsers.Select(u => u.UserPrincipalName).ToList();
var scanOptions = new ScanOptions(
IncludeInherited: IncludeInherited,
@@ -244,19 +207,24 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
progress,
ct);
// Update Results on the UI thread
// Update Results on the UI thread — clear + repopulate (not replace)
// so the CollectionViewSource bound to ResultsView stays connected.
var dispatcher = Application.Current?.Dispatcher;
if (dispatcher != null)
{
await dispatcher.InvokeAsync(() =>
{
Results = new ObservableCollection<UserAccessEntry>(entries);
Results.Clear();
foreach (var entry in entries)
Results.Add(entry);
NotifySummaryProperties();
});
}
else
{
Results = new ObservableCollection<UserAccessEntry>(entries);
Results.Clear();
foreach (var entry in entries)
Results.Add(entry);
NotifySummaryProperties();
}
@@ -269,14 +237,11 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
protected override void OnTenantSwitched(TenantProfile profile)
{
_currentProfile = profile;
_hasLocalSiteOverride = false;
Results = new ObservableCollection<UserAccessEntry>();
Results.Clear();
SelectedUsers.Clear();
SearchQuery = string.Empty;
SearchResults.Clear();
SelectedSites.Clear();
FilterText = string.Empty;
OnPropertyChanged(nameof(SitesSelectedLabel));
OnPropertyChanged(nameof(CurrentProfile));
NotifySummaryProperties();
ExportCsvCommand.NotifyCanExecuteChanged();
@@ -308,15 +273,10 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
partial void OnResultsChanged(ObservableCollection<UserAccessEntry> value)
{
// Rebind CollectionViewSource when the collection reference changes
if (ResultsView is CollectionView cv && cv.SourceCollection != value)
{
// CollectionViewSource.View is already live-bound in constructor;
// for a new collection reference we need to refresh grouping/filter
ApplyGrouping();
ResultsView.Filter = FilterPredicate;
ResultsView.Refresh();
}
// Safety net: if the collection reference ever changes, rebind grouping/filter
ApplyGrouping();
ResultsView.Filter = FilterPredicate;
ResultsView.Refresh();
NotifySummaryProperties();
}
@@ -379,19 +339,6 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
}
}
private void ExecuteOpenSitePicker()
{
if (OpenSitePickerDialog == null) return;
var dialog = OpenSitePickerDialog.Invoke();
if (dialog?.ShowDialog() == true && dialog is Views.Dialogs.SitePickerDialog picker)
{
_hasLocalSiteOverride = true;
SelectedSites.Clear();
foreach (var site in picker.SelectedUrls)
SelectedSites.Add(site);
}
}
private void ExecuteAddUser(GraphUserResult? user)
{
if (user == null) return;