--- phase: 02-permissions plan: 06 type: execute wave: 3 depends_on: - 02-02 - 02-03 - 02-04 - 02-05 files_modified: - SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs - SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml - SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml.cs autonomous: true requirements: - PERM-01 - PERM-02 - PERM-04 - PERM-05 - PERM-06 must_haves: truths: - "PermissionsViewModel.RunOperationAsync calls PermissionsService.ScanSiteAsync for each selected site URL" - "Single-site mode uses the URL from the SiteUrl property; multi-site mode uses the list from SelectedSites" - "After scan completes, Results is a non-null ObservableCollection" - "Export commands are only enabled when Results.Count > 0 (CanExecute guard)" - "SitePickerDialog shows a list of sites (loaded via SiteListService) with checkboxes and a filter textbox" - "PermissionsViewModel.ScanOptions property exposes IncludeInherited, ScanFolders, FolderDepth bound to UI checkboxes" artifacts: - path: "SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs" provides: "FeatureViewModelBase subclass for the Permissions tab" exports: ["PermissionsViewModel"] - path: "SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml" provides: "Multi-site selection dialog with checkboxes and filter" - path: "SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml.cs" provides: "Code-behind: loads sites on Open, exposes SelectedUrls" key_links: - from: "PermissionsViewModel.cs" to: "IPermissionsService.ScanSiteAsync" via: "RunOperationAsync loop per site" pattern: "_permissionsService\\.ScanSiteAsync" - from: "PermissionsViewModel.cs" to: "CsvExportService.WriteAsync" via: "ExportCsvCommand handler" pattern: "_csvExportService\\.WriteAsync" - from: "PermissionsViewModel.cs" to: "HtmlExportService.WriteAsync" via: "ExportHtmlCommand handler" pattern: "_htmlExportService\\.WriteAsync" - from: "SitePickerDialog.xaml.cs" to: "ISiteListService.GetSitesAsync" via: "Window.Loaded handler" pattern: "_siteListService\\.GetSitesAsync" --- Implement `PermissionsViewModel` (the full feature orchestrator) and `SitePickerDialog` (the multi-site picker UI). After this plan, all business logic for the Permissions tab is complete — only DI wiring and tab replacement remain (Plan 07). Purpose: Wire all services (scan, site list, export) into the ViewModel, and create the SitePickerDialog used for PERM-02. Output: PermissionsViewModel + SitePickerDialog (XAML + code-behind). @C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md @C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/phases/02-permissions/02-RESEARCH.md From SharepointToolbox/ViewModels/FeatureViewModelBase.cs: ```csharp public abstract partial class FeatureViewModelBase : ObservableRecipient { [ObservableProperty] private bool _isRunning; [ObservableProperty] private string _statusMessage = string.Empty; [ObservableProperty] private int _progressValue; public IAsyncRelayCommand RunCommand { get; } // calls ExecuteAsync → RunOperationAsync public RelayCommand CancelCommand { get; } protected abstract Task RunOperationAsync(CancellationToken ct, IProgress progress); protected virtual void OnTenantSwitched(TenantProfile profile) { } } ``` From SharepointToolbox/Services/IPermissionsService.cs (Plan 02): ```csharp public interface IPermissionsService { Task> ScanSiteAsync( ClientContext ctx, ScanOptions options, IProgress progress, CancellationToken ct); } ``` From SharepointToolbox/Services/ISiteListService.cs (Plan 03): ```csharp public interface ISiteListService { Task> GetSitesAsync( TenantProfile profile, IProgress progress, CancellationToken ct); } public record SiteInfo(string Url, string Title); ``` From SharepointToolbox/Services/Export/: ```csharp public class CsvExportService { public Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct); } public class HtmlExportService { public Task WriteAsync(IReadOnlyList entries, string filePath, CancellationToken ct); } ``` Dialog factory pattern (established in Phase 1 — ProfileManagementViewModel): ```csharp // ViewModel exposes a Func? property set by the View layer: public Func? OpenSitePickerDialog { get; set; } // ViewModel calls: var dlg = OpenSitePickerDialog?.Invoke(); dlg?.ShowDialog(); // This avoids Window/DI coupling in the ViewModel. ``` SessionManager usage in ViewModel (established pattern): ```csharp // At scan start, ViewModel calls SessionManager.GetOrCreateContextAsync per site URL: var profile = new TenantProfile { TenantUrl = siteUrl, ClientId = _currentProfile.ClientId, Name = _currentProfile.Name }; var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct); // Each site URL gets its own context from SessionManager's cache. ``` Task 1: Implement PermissionsViewModel SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs - Extends FeatureViewModelBase; implements RunOperationAsync - [ObservableProperty] SiteUrl (string) — single-site mode input - [ObservableProperty] ScanOptions (ScanOptions) — bound to UI checkboxes (IncludeInherited, ScanFolders, FolderDepth) - [ObservableProperty] Results (ObservableCollection<PermissionEntry>) — bound to DataGrid - [ObservableProperty] SelectedSites (ObservableCollection<SiteInfo>) — multi-site picker result - ExportCsvCommand: AsyncRelayCommand, only enabled when Results.Count > 0 - ExportHtmlCommand: AsyncRelayCommand, only enabled when Results.Count > 0 - OpenSitePickerCommand: RelayCommand, opens SitePickerDialog via dialog factory - Multi-site mode: if SelectedSites.Count > 0, scan each URL; else scan SiteUrl - RunOperationAsync: for each site URL, get ClientContext from SessionManager, call PermissionsService.ScanSiteAsync, accumulate results, set Results on UI thread via Dispatcher - OnTenantSwitched: clear Results, SiteUrl, SelectedSites - Multi-site test from Plan 01 (StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl) should pass using a mock IPermissionsService Create `SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs`. Constructor: inject `IPermissionsService`, `ISiteListService`, `CsvExportService`, `HtmlExportService`, `SessionManager`, `ILogger`. Key implementation: ```csharp protected override async Task RunOperationAsync(CancellationToken ct, IProgress progress) { var urls = SelectedSites.Count > 0 ? SelectedSites.Select(s => s.Url).ToList() : new List { SiteUrl }; if (urls.All(string.IsNullOrWhiteSpace)) { StatusMessage = "Enter a site URL or select sites."; return; } var allEntries = new List(); int i = 0; foreach (var url in urls.Where(u => !string.IsNullOrWhiteSpace(u))) { ct.ThrowIfCancellationRequested(); progress.Report(new OperationProgress(i, urls.Count, $"Scanning {url}...")); var profile = new TenantProfile { TenantUrl = url, ClientId = _currentProfile!.ClientId, Name = _currentProfile.Name }; var ctx = await _sessionManager.GetOrCreateContextAsync(profile, ct); var siteEntries = await _permissionsService.ScanSiteAsync(ctx, ScanOptions, progress, ct); allEntries.AddRange(siteEntries); i++; } await Application.Current.Dispatcher.InvokeAsync(() => Results = new ObservableCollection(allEntries)); ExportCsvCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged(); } ``` Export commands open SaveFileDialog (Microsoft.Win32), then call the respective service WriteAsync. After writing, call `Process.Start(new ProcessStartInfo(filePath) { UseShellExecute = true })` to open the file. OpenSitePickerCommand: `OpenSitePickerDialog?.Invoke()?.ShowDialog()` — if dialog returns true, update SelectedSites from the dialog's SelectedUrls. _currentProfile: received via WeakReferenceMessenger TenantSwitchedMessage (same as Phase 1 pattern). OnTenantSwitched sets _currentProfile. ObservableProperty ScanOptions default: `new ScanOptions()` (IncludeInherited=false, ScanFolders=true, FolderDepth=1, IncludeSubsites=false). Note: ScanOptions is a record — individual bool/int properties bound in UI must be via wrapper properties or a ScanOptionsViewModel. For simplicity, expose flat [ObservableProperty] booleans (IncludeInherited, ScanFolders, IncludeSubsites, FolderDepth) and build the ScanOptions record in RunOperationAsync from these flat properties. Namespace: `SharepointToolbox.ViewModels.Tabs`. dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~PermissionsViewModelTests" -x PermissionsViewModelTests: StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl passes (using mock IPermissionsService). dotnet build 0 errors. Task 2: Implement SitePickerDialog XAML and code-behind SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml.cs Create `SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml`: - Window Title bound to "Select Sites" (hardcoded or localized) - Width=600, Height=500, WindowStartupLocation=CenterOwner - Layout: StackPanel (DockPanel or Grid) - Top: TextBlock "Filter:" + TextBox (x:Name="FilterBox") with TextChanged binding to filter the list - Middle: ListView (x:Name="SiteList", SelectionMode=Multiple) with CheckBox column and Site URL/Title columns - Use `DataTemplate` with `CheckBox` bound to `IsSelected` on the list item wrapper - Columns: checkbox, Title, URL - Bottom buttons row: "Load Sites" button, "Select All", "Deselect All", "OK" (IsDefault=True), "Cancel" (IsCancel=True) - Status TextBlock for loading/error messages Create `SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml.cs`: ```csharp public partial class SitePickerDialog : Window { private readonly ISiteListService _siteListService; private readonly TenantProfile _profile; private List _allItems = new(); // SitePickerItem is a local class: record SitePickerItem(string Url, string Title) with bool IsSelected property (not record so it can be mutable) public IReadOnlyList SelectedUrls => _allItems.Where(i => i.IsSelected).Select(i => new SiteInfo(i.Url, i.Title)).ToList(); public SitePickerDialog(ISiteListService siteListService, TenantProfile profile) { InitializeComponent(); _siteListService = siteListService; _profile = profile; } private async void Window_Loaded(object sender, RoutedEventArgs e) => await LoadSitesAsync(); private async Task LoadSitesAsync() { StatusText.Text = "Loading sites..."; LoadButton.IsEnabled = false; try { var sites = await _siteListService.GetSitesAsync(_profile, new Progress(), CancellationToken.None); _allItems = sites.Select(s => new SitePickerItem(s.Url, s.Title)).ToList(); ApplyFilter(); StatusText.Text = $"{_allItems.Count} sites loaded."; } catch (InvalidOperationException ex) { StatusText.Text = ex.Message; } catch (Exception ex) { StatusText.Text = $"Error: {ex.Message}"; } finally { LoadButton.IsEnabled = true; } } private void ApplyFilter() { var filter = FilterBox.Text.Trim(); SiteList.ItemsSource = string.IsNullOrEmpty(filter) ? _allItems : _allItems.Where(i => i.Url.Contains(filter, StringComparison.OrdinalIgnoreCase) || i.Title.Contains(filter, StringComparison.OrdinalIgnoreCase)).ToList(); } private void FilterBox_TextChanged(object s, TextChangedEventArgs e) => ApplyFilter(); private void SelectAll_Click(object s, RoutedEventArgs e) { foreach (var i in _allItems) i.IsSelected = true; ApplyFilter(); } private void DeselectAll_Click(object s, RoutedEventArgs e) { foreach (var i in _allItems) i.IsSelected = false; ApplyFilter(); } private async void LoadButton_Click(object s, RoutedEventArgs e) => await LoadSitesAsync(); private void OK_Click(object s, RoutedEventArgs e) { DialogResult = true; Close(); } } public class SitePickerItem : INotifyPropertyChanged { private bool _isSelected; public string Url { get; init; } = string.Empty; public string Title { get; init; } = string.Empty; public bool IsSelected { get => _isSelected; set { _isSelected = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsSelected))); } } public event PropertyChangedEventHandler? PropertyChanged; } ``` The SitePickerDialog is registered as Transient in DI (Plan 07). PermissionsViewModel's OpenSitePickerDialog factory is set in PermissionsView code-behind. dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx 2>&1 | tail -5 dotnet build succeeds with 0 errors. SitePickerDialog.xaml and .cs exist and compile. No XAML parse errors. - `dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx` → 0 errors - `dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx` → all tests pass - PermissionsViewModelTests: StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl passes - PermissionsViewModel references _permissionsService.ScanSiteAsync (grep verifiable) - SitePickerDialog.xaml exists and has a ListView with checkboxes - PermissionsViewModel extends FeatureViewModelBase and implements all required commands (RunCommand inherited, ExportCsvCommand, ExportHtmlCommand, OpenSitePickerCommand) - Multi-site scan loops over SelectedSites, single-site scan uses SiteUrl - SitePickerDialog loads sites from ISiteListService on Window.Loaded - ExportCsv and ExportHtml commands are disabled when Results is empty - OnTenantSwitched clears Results, SiteUrl, SelectedSites After completion, create `.planning/phases/02-permissions/02-06-SUMMARY.md`