docs(02-permissions): create phase 2 plan — 7 plans across 4 waves
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
332
.planning/phases/02-permissions/02-06-PLAN.md
Normal file
332
.planning/phases/02-permissions/02-06-PLAN.md
Normal file
@@ -0,0 +1,332 @@
|
||||
---
|
||||
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<PermissionEntry>"
|
||||
- "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"
|
||||
---
|
||||
|
||||
<objective>
|
||||
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).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@C:/Users/SebastienQUEROL/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@C:/Users/SebastienQUEROL/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/phases/02-permissions/02-RESEARCH.md
|
||||
|
||||
<interfaces>
|
||||
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<OperationProgress> progress);
|
||||
protected virtual void OnTenantSwitched(TenantProfile profile) { }
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/Services/IPermissionsService.cs (Plan 02):
|
||||
```csharp
|
||||
public interface IPermissionsService
|
||||
{
|
||||
Task<IReadOnlyList<PermissionEntry>> ScanSiteAsync(
|
||||
ClientContext ctx, ScanOptions options,
|
||||
IProgress<OperationProgress> progress, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
From SharepointToolbox/Services/ISiteListService.cs (Plan 03):
|
||||
```csharp
|
||||
public interface ISiteListService
|
||||
{
|
||||
Task<IReadOnlyList<SiteInfo>> GetSitesAsync(
|
||||
TenantProfile profile, IProgress<OperationProgress> progress, CancellationToken ct);
|
||||
}
|
||||
public record SiteInfo(string Url, string Title);
|
||||
```
|
||||
|
||||
From SharepointToolbox/Services/Export/:
|
||||
```csharp
|
||||
public class CsvExportService
|
||||
{
|
||||
public Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct);
|
||||
}
|
||||
public class HtmlExportService
|
||||
{
|
||||
public Task WriteAsync(IReadOnlyList<PermissionEntry> entries, string filePath, CancellationToken ct);
|
||||
}
|
||||
```
|
||||
|
||||
Dialog factory pattern (established in Phase 1 — ProfileManagementViewModel):
|
||||
```csharp
|
||||
// ViewModel exposes a Func<Window>? property set by the View layer:
|
||||
public Func<Window>? 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.
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Implement PermissionsViewModel</name>
|
||||
<files>
|
||||
SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs
|
||||
</files>
|
||||
<behavior>
|
||||
- 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
|
||||
</behavior>
|
||||
<action>
|
||||
Create `SharepointToolbox/ViewModels/Tabs/PermissionsViewModel.cs`.
|
||||
|
||||
Constructor: inject `IPermissionsService`, `ISiteListService`, `CsvExportService`, `HtmlExportService`, `SessionManager`, `ILogger<PermissionsViewModel>`.
|
||||
|
||||
Key implementation:
|
||||
```csharp
|
||||
protected override async Task RunOperationAsync(CancellationToken ct, IProgress<OperationProgress> progress)
|
||||
{
|
||||
var urls = SelectedSites.Count > 0
|
||||
? SelectedSites.Select(s => s.Url).ToList()
|
||||
: new List<string> { SiteUrl };
|
||||
|
||||
if (urls.All(string.IsNullOrWhiteSpace))
|
||||
{
|
||||
StatusMessage = "Enter a site URL or select sites.";
|
||||
return;
|
||||
}
|
||||
|
||||
var allEntries = new List<PermissionEntry>();
|
||||
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<PermissionEntry>(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`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet test C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --filter "FullyQualifiedName~PermissionsViewModelTests" -x</automated>
|
||||
</verify>
|
||||
<done>PermissionsViewModelTests: StartScanAsync_WithMultipleSiteUrls_CallsServiceOncePerUrl passes (using mock IPermissionsService). dotnet build 0 errors.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Implement SitePickerDialog XAML and code-behind</name>
|
||||
<files>
|
||||
SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml
|
||||
SharepointToolbox/Views/Dialogs/SitePickerDialog.xaml.cs
|
||||
</files>
|
||||
<action>
|
||||
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<SitePickerItem> _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<SiteInfo> 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<OperationProgress>(), 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.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>dotnet build C:/Users/dev/Documents/projets/Sharepoint/SharepointToolbox.slnx 2>&1 | tail -5</automated>
|
||||
</verify>
|
||||
<done>dotnet build succeeds with 0 errors. SitePickerDialog.xaml and .cs exist and compile. No XAML parse errors.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `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
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 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
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/02-permissions/02-06-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user