feat(02-07): create PermissionsView XAML + code-behind and register DI

- Created PermissionsView.xaml with left scan-config panel and right results DataGrid
- Created PermissionsView.xaml.cs wiring ViewModel via IServiceProvider, factory for SitePickerDialog
- Updated App.xaml.cs: registered IPermissionsService, ISiteListService, CsvExportService,
  HtmlExportService, PermissionsViewModel, PermissionsView, SitePickerDialog, and
  Func<TenantProfile, SitePickerDialog> factory; also registered ISessionManager -> SessionManager
- Updated MainWindow.xaml: replaced FeatureTabBase stub with named PermissionsTabItem
- Updated MainWindow.xaml.cs: wires PermissionsTabItem.Content from DI-resolved PermissionsView
- Added CurrentProfile public accessor, SitesSelectedLabel computed property, and
  IsMaxDepth toggle property to PermissionsViewModel
- Build: 0 errors, 0 warnings. Tests: 60 passed, 3 skipped (live/interactive)
This commit is contained in:
Dev
2026-04-02 14:13:45 +02:00
parent e74cffbe31
commit afe69bd37f
6 changed files with 200 additions and 2 deletions

View File

@@ -3,10 +3,12 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Serilog; using Serilog;
using SharepointToolbox.Core.Models;
using SharepointToolbox.Infrastructure.Auth; using SharepointToolbox.Infrastructure.Auth;
using SharepointToolbox.Infrastructure.Logging; using SharepointToolbox.Infrastructure.Logging;
using SharepointToolbox.Infrastructure.Persistence; using SharepointToolbox.Infrastructure.Persistence;
using SharepointToolbox.Services; using SharepointToolbox.Services;
using SharepointToolbox.Services.Export;
using SharepointToolbox.ViewModels; using SharepointToolbox.ViewModels;
using SharepointToolbox.ViewModels.Tabs; using SharepointToolbox.ViewModels.Tabs;
using SharepointToolbox.Views.Dialogs; using SharepointToolbox.Views.Dialogs;
@@ -77,6 +79,7 @@ public partial class App : Application
services.AddSingleton(_ => new SettingsRepository(Path.Combine(appData, "settings.json"))); services.AddSingleton(_ => new SettingsRepository(Path.Combine(appData, "settings.json")));
services.AddSingleton<MsalClientFactory>(); services.AddSingleton<MsalClientFactory>();
services.AddSingleton<SessionManager>(); services.AddSingleton<SessionManager>();
services.AddSingleton<ISessionManager>(sp => sp.GetRequiredService<SessionManager>());
services.AddSingleton<ProfileService>(); services.AddSingleton<ProfileService>();
services.AddSingleton<SettingsService>(); services.AddSingleton<SettingsService>();
services.AddSingleton<MainWindowViewModel>(); services.AddSingleton<MainWindowViewModel>();
@@ -84,6 +87,18 @@ public partial class App : Application
services.AddTransient<SettingsViewModel>(); services.AddTransient<SettingsViewModel>();
services.AddTransient<ProfileManagementDialog>(); services.AddTransient<ProfileManagementDialog>();
services.AddTransient<SettingsView>(); services.AddTransient<SettingsView>();
// Phase 2: Permissions
services.AddTransient<IPermissionsService, PermissionsService>();
services.AddTransient<ISiteListService, SiteListService>();
services.AddTransient<CsvExportService>();
services.AddTransient<HtmlExportService>();
services.AddTransient<PermissionsViewModel>();
services.AddTransient<PermissionsView>();
services.AddTransient<SitePickerDialog>();
services.AddTransient<Func<TenantProfile, SitePickerDialog>>(sp =>
profile => new SitePickerDialog(sp.GetRequiredService<ISiteListService>(), profile));
services.AddSingleton<MainWindow>(); services.AddSingleton<MainWindow>();
} }
} }

View File

@@ -41,8 +41,8 @@
<!-- TabControl: 7 stub tabs use FeatureTabBase; Settings tab wired in plan 01-07 --> <!-- TabControl: 7 stub tabs use FeatureTabBase; Settings tab wired in plan 01-07 -->
<TabControl> <TabControl>
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.permissions]}"> <TabItem x:Name="PermissionsTabItem"
<controls:FeatureTabBase /> Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.permissions]}">
</TabItem> </TabItem>
<TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}"> <TabItem Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[tab.storage]}">
<controls:FeatureTabBase /> <controls:FeatureTabBase />

View File

@@ -20,6 +20,9 @@ public partial class MainWindow : Window
// Wire profile management dialog factory // Wire profile management dialog factory
viewModel.OpenProfileManagementDialog = () => serviceProvider.GetRequiredService<ProfileManagementDialog>(); viewModel.OpenProfileManagementDialog = () => serviceProvider.GetRequiredService<ProfileManagementDialog>();
// Replace Permissions tab placeholder with the DI-resolved PermissionsView
PermissionsTabItem.Content = serviceProvider.GetRequiredService<PermissionsView>();
// Replace Settings tab placeholder with the DI-resolved SettingsView // Replace Settings tab placeholder with the DI-resolved SettingsView
SettingsTabItem.Content = serviceProvider.GetRequiredService<SettingsView>(); SettingsTabItem.Content = serviceProvider.GetRequiredService<SettingsView>();

View File

@@ -44,9 +44,27 @@ public partial class PermissionsViewModel : FeatureViewModelBase
[ObservableProperty] [ObservableProperty]
private int _folderDepth = 1; private int _folderDepth = 1;
/// <summary>
/// When true, sets FolderDepth to 999 (scan all levels).
/// </summary>
public bool IsMaxDepth
{
get => FolderDepth >= 999;
set
{
if (value)
FolderDepth = 999;
else if (FolderDepth >= 999)
FolderDepth = 1;
OnPropertyChanged();
}
}
[ObservableProperty] [ObservableProperty]
private ObservableCollection<PermissionEntry> _results = new(); private ObservableCollection<PermissionEntry> _results = new();
partial void OnFolderDepthChanged(int value) => OnPropertyChanged(nameof(IsMaxDepth));
// ── Commands ──────────────────────────────────────────────────────────── // ── Commands ────────────────────────────────────────────────────────────
public IAsyncRelayCommand ExportCsvCommand { get; } public IAsyncRelayCommand ExportCsvCommand { get; }
@@ -69,6 +87,19 @@ public partial class PermissionsViewModel : FeatureViewModelBase
internal TenantProfile? _currentProfile; internal TenantProfile? _currentProfile;
/// <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
? string.Format(Localization.TranslationSource.Instance["perm.sites.selected"], SelectedSites.Count)
: string.Empty;
// ── Constructors ──────────────────────────────────────────────────────── // ── Constructors ────────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -93,6 +124,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker); OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
} }
/// <summary> /// <summary>
@@ -115,6 +147,7 @@ public partial class PermissionsViewModel : FeatureViewModelBase
ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport); ExportCsvCommand = new AsyncRelayCommand(ExportCsvAsync, CanExport);
ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport); ExportHtmlCommand = new AsyncRelayCommand(ExportHtmlAsync, CanExport);
OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker); OpenSitePickerCommand = new RelayCommand(ExecuteOpenSitePicker);
SelectedSites.CollectionChanged += (_, _) => OnPropertyChanged(nameof(SitesSelectedLabel));
} }
// ── FeatureViewModelBase implementation ───────────────────────────────── // ── FeatureViewModelBase implementation ─────────────────────────────────
@@ -184,6 +217,8 @@ public partial class PermissionsViewModel : FeatureViewModelBase
Results = new ObservableCollection<PermissionEntry>(); Results = new ObservableCollection<PermissionEntry>();
SiteUrl = string.Empty; SiteUrl = string.Empty;
SelectedSites.Clear(); SelectedSites.Clear();
OnPropertyChanged(nameof(SitesSelectedLabel));
OnPropertyChanged(nameof(CurrentProfile));
ExportCsvCommand.NotifyCanExecuteChanged(); ExportCsvCommand.NotifyCanExecuteChanged();
ExportHtmlCommand.NotifyCanExecuteChanged(); ExportHtmlCommand.NotifyCanExecuteChanged();
} }

View File

@@ -0,0 +1,123 @@
<UserControl x:Class="SharepointToolbox.Views.Tabs.PermissionsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="clr-namespace:SharepointToolbox.Localization">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="290" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Left panel: Scan configuration -->
<DockPanel Grid.Column="0" Grid.Row="0" Margin="8">
<!-- Scan Options GroupBox -->
<GroupBox Header="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[grp.scan.opts]}"
DockPanel.Dock="Top" Margin="0,0,0,8" Padding="8">
<StackPanel>
<!-- Site URL -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.site.url]}"
Margin="0,0,0,2" />
<TextBox Text="{Binding SiteUrl, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,6" />
<!-- View Sites + selected label -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[perm.or.select]}"
Margin="0,0,0,2" />
<Button Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.view.sites]}"
Command="{Binding OpenSitePickerCommand}"
HorizontalAlignment="Left" Padding="8,2" Margin="0,0,0,4" />
<TextBlock Text="{Binding SitesSelectedLabel}"
FontStyle="Italic" Foreground="Gray" Margin="0,0,0,8" />
<!-- Checkboxes -->
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.scan.folders]}"
IsChecked="{Binding ScanFolders}" Margin="0,0,0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.inherited.perms]}"
IsChecked="{Binding IncludeInherited}" Margin="0,0,0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.recursive]}"
IsChecked="{Binding IncludeSubsites}" Margin="0,0,0,8" />
<!-- Folder depth -->
<TextBlock Text="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[lbl.folder.depth]}"
Margin="0,0,0,2" />
<TextBox Text="{Binding FolderDepth, UpdateSourceTrigger=PropertyChanged}"
Width="60" HorizontalAlignment="Left" Margin="0,0,0,4" />
<CheckBox Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[chk.max.depth]}"
IsChecked="{Binding IsMaxDepth, Mode=TwoWay}"
Margin="0,0,0,0" />
</StackPanel>
</GroupBox>
<!-- Action buttons -->
<StackPanel DockPanel.Dock="Top" Margin="0,0,0,4">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.gen.perms]}"
Command="{Binding RunCommand}"
Margin="0,0,4,4" Padding="6,3" />
<Button Grid.Column="1"
Content="{Binding Source={x:Static loc:TranslationSource.Instance}, Path=[btn.cancel]}"
Command="{Binding CancelCommand}"
Margin="0,0,0,4" Padding="6,3" />
</Grid>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Content="Export CSV"
Command="{Binding ExportCsvCommand}"
Margin="0,0,4,0" Padding="6,3" />
<Button Grid.Column="1"
Content="Export HTML"
Command="{Binding ExportHtmlCommand}"
Margin="0,0,0,0" Padding="6,3" />
</Grid>
</StackPanel>
</DockPanel>
<!-- Right panel: Results DataGrid -->
<DataGrid Grid.Column="1" Grid.Row="0"
ItemsSource="{Binding Results}"
AutoGenerateColumns="False"
IsReadOnly="True"
VirtualizingPanel.IsVirtualizing="True"
EnableRowVirtualization="True"
Margin="0,8,8,8">
<DataGrid.Columns>
<DataGridTextColumn Header="Object Type" Binding="{Binding ObjectType}" Width="100" />
<DataGridTextColumn Header="Title" Binding="{Binding Title}" Width="140" />
<DataGridTextColumn Header="URL" Binding="{Binding Url}" Width="200" />
<DataGridTextColumn Header="Unique Perms" Binding="{Binding HasUniquePermissions}" Width="90" />
<DataGridTextColumn Header="Users" Binding="{Binding Users}" Width="140" />
<DataGridTextColumn Header="Permission Levels" Binding="{Binding PermissionLevels}" Width="140" />
<DataGridTextColumn Header="Granted Through" Binding="{Binding GrantedThrough}" Width="140" />
<DataGridTextColumn Header="Principal Type" Binding="{Binding PrincipalType}" Width="110" />
</DataGrid.Columns>
</DataGrid>
<!-- Bottom: status bar spanning both columns -->
<StatusBar Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="1">
<StatusBarItem>
<ProgressBar Width="150" Height="14"
Value="{Binding ProgressValue}"
Minimum="0" Maximum="100" />
</StatusBarItem>
<StatusBarItem Content="{Binding StatusMessage}" />
</StatusBar>
</Grid>
</UserControl>

View File

@@ -0,0 +1,22 @@
using System.Windows.Controls;
using Microsoft.Extensions.DependencyInjection;
using SharepointToolbox.Core.Models;
using SharepointToolbox.ViewModels.Tabs;
using SharepointToolbox.Views.Dialogs;
namespace SharepointToolbox.Views.Tabs;
public partial class PermissionsView : UserControl
{
public PermissionsView(IServiceProvider serviceProvider)
{
InitializeComponent();
var vm = serviceProvider.GetRequiredService<PermissionsViewModel>();
DataContext = vm;
vm.OpenSitePickerDialog = () =>
{
var factory = serviceProvider.GetRequiredService<Func<TenantProfile, SitePickerDialog>>();
return factory(vm.CurrentProfile ?? new TenantProfile());
};
}
}