diff --git a/SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs b/SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs
new file mode 100644
index 0000000..1bcb1cc
--- /dev/null
+++ b/SharepointToolbox.Tests/ViewModels/UserAccessAuditViewModelDirectoryTests.cs
@@ -0,0 +1,351 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using CommunityToolkit.Mvvm.Messaging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using SharepointToolbox.Core.Models;
+using SharepointToolbox.Services;
+using SharepointToolbox.ViewModels;
+using SharepointToolbox.ViewModels.Tabs;
+
+namespace SharepointToolbox.Tests.ViewModels;
+
+///
+/// Unit tests for directory browse mode in UserAccessAuditViewModel (Phase 13 Plan 02).
+/// Verifies: directory load, progress, cancellation, member/guest filter, text filter,
+/// sorting, tenant switch reset, and no regression on search mode.
+///
+[Trait("Category", "Unit")]
+public class UserAccessAuditViewModelDirectoryTests
+{
+ public UserAccessAuditViewModelDirectoryTests()
+ {
+ WeakReferenceMessenger.Default.Reset();
+ }
+
+ // ── Helper factories ──────────────────────────────────────────────────────
+
+ private static GraphDirectoryUser MakeMember(string name = "Alice", string dept = "IT", string jobTitle = "Engineer") =>
+ new(name, $"{name.ToLower().Replace(" ", "")}@contoso.com", null, dept, jobTitle, "Member");
+
+ private static GraphDirectoryUser MakeGuest(string name = "Bob External") =>
+ new(name, $"{name.ToLower().Replace(" ", "")}@external.com", null, null, null, "Guest");
+
+ private static (UserAccessAuditViewModel vm, Mock dirMock, Mock auditMock)
+ CreateViewModel(IReadOnlyList? directoryResult = null)
+ {
+ var mockAudit = new Mock();
+ var mockGraph = new Mock();
+ var mockSession = new Mock();
+ var mockDir = new Mock();
+ mockDir.Setup(s => s.GetUsersAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(directoryResult ?? Array.Empty());
+
+ var vm = new UserAccessAuditViewModel(
+ mockAudit.Object,
+ mockGraph.Object,
+ mockSession.Object,
+ NullLogger.Instance,
+ graphUserDirectoryService: mockDir.Object);
+
+ vm._currentProfile = new TenantProfile
+ {
+ Name = "Test",
+ TenantUrl = "https://contoso.sharepoint.com",
+ ClientId = "test-client-id"
+ };
+
+ return (vm, mockDir, mockAudit);
+ }
+
+ // ── Test 1: IsBrowseMode defaults to false ───────────────────────────────
+
+ [Fact]
+ public void IsBrowseMode_defaults_to_false()
+ {
+ var (vm, _, _) = CreateViewModel();
+ Assert.False(vm.IsBrowseMode);
+ }
+
+ // ── Test 2: DirectoryUsers is empty by default ───────────────────────────
+
+ [Fact]
+ public void DirectoryUsers_empty_by_default()
+ {
+ var (vm, _, _) = CreateViewModel();
+ Assert.Empty(vm.DirectoryUsers);
+ }
+
+ // ── Test 3: Commands are not null ─────────────────────────────────────────
+
+ [Fact]
+ public void LoadDirectoryCommand_and_CancelDirectoryLoadCommand_not_null()
+ {
+ var (vm, _, _) = CreateViewModel();
+ Assert.NotNull(vm.LoadDirectoryCommand);
+ Assert.NotNull(vm.CancelDirectoryLoadCommand);
+ }
+
+ // ── Test 4: LoadDirectoryAsync populates DirectoryUsers ──────────────────
+
+ [Fact]
+ public async Task LoadDirectoryAsync_populates_DirectoryUsers()
+ {
+ var users = new List { MakeMember("Alice"), MakeMember("Charlie") };
+ var (vm, _, _) = CreateViewModel(users);
+
+ await vm.TestLoadDirectoryAsync();
+
+ Assert.Equal(2, vm.DirectoryUsers.Count);
+ Assert.Contains(vm.DirectoryUsers, u => u.DisplayName == "Alice");
+ Assert.Contains(vm.DirectoryUsers, u => u.DisplayName == "Charlie");
+ }
+
+ // ── Test 5: LoadDirectoryAsync reports progress via DirectoryLoadStatus ──
+
+ [Fact]
+ public async Task LoadDirectoryAsync_sets_DirectoryLoadStatus_on_completion()
+ {
+ var users = new List { MakeMember("Alice") };
+ var (vm, _, _) = CreateViewModel(users);
+
+ await vm.TestLoadDirectoryAsync();
+
+ Assert.Equal("1 users loaded", vm.DirectoryLoadStatus);
+ }
+
+ // ── Test 6: LoadDirectoryAsync with no profile sets StatusMessage ─────────
+
+ [Fact]
+ public async Task LoadDirectoryAsync_with_no_profile_sets_StatusMessage()
+ {
+ var (vm, _, _) = CreateViewModel();
+ vm._currentProfile = null;
+
+ await vm.TestLoadDirectoryAsync();
+
+ Assert.Equal("No tenant profile selected. Please connect first.", vm.StatusMessage);
+ Assert.Empty(vm.DirectoryUsers);
+ }
+
+ // ── Test 7: CancelDirectoryLoadCommand cancels in-flight load ────────────
+
+ [Fact]
+ public async Task CancelDirectoryLoad_cancels_inflight_load()
+ {
+ var tcs = new TaskCompletionSource>();
+ var mockDir = new Mock();
+ mockDir.Setup(s => s.GetUsersAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns?, CancellationToken>((_, _, _, ct) =>
+ {
+ var localTcs = new TaskCompletionSource>();
+ ct.Register(() => localTcs.TrySetCanceled(ct));
+ return localTcs.Task;
+ });
+
+ var vm = new UserAccessAuditViewModel(
+ new Mock().Object,
+ new Mock().Object,
+ new Mock().Object,
+ NullLogger.Instance,
+ graphUserDirectoryService: mockDir.Object);
+ vm._currentProfile = new TenantProfile
+ {
+ Name = "Test",
+ TenantUrl = "https://contoso.sharepoint.com",
+ ClientId = "test-client-id"
+ };
+
+ // Start load (will block on the mock)
+ var loadTask = vm.TestLoadDirectoryAsync();
+
+ // Cancel
+ vm.CancelDirectoryLoadCommand.Execute(null);
+
+ await loadTask;
+
+ Assert.Equal("Load cancelled.", vm.DirectoryLoadStatus);
+ Assert.False(vm.IsLoadingDirectory);
+ }
+
+ // ── Test 8: IncludeGuests=false filters out Guest users ──────────────────
+
+ [Fact]
+ public void IncludeGuests_false_filters_out_guest_users()
+ {
+ var (vm, _, _) = CreateViewModel();
+ vm.DirectoryUsers.Add(MakeMember("Alice"));
+ vm.DirectoryUsers.Add(MakeGuest("Bob External"));
+ vm.DirectoryUsers.Add(MakeMember("Charlie"));
+
+ vm.IncludeGuests = false;
+
+ var visible = vm.DirectoryUsersView.Cast().ToList();
+ Assert.Equal(2, visible.Count);
+ Assert.All(visible, u => Assert.Equal("Member", u.UserType));
+ }
+
+ // ── Test 9: IncludeGuests=true shows all users ───────────────────────────
+
+ [Fact]
+ public void IncludeGuests_true_shows_all_users()
+ {
+ var (vm, _, _) = CreateViewModel();
+ vm.DirectoryUsers.Add(MakeMember("Alice"));
+ vm.DirectoryUsers.Add(MakeGuest("Bob External"));
+
+ vm.IncludeGuests = true;
+
+ var visible = vm.DirectoryUsersView.Cast().ToList();
+ Assert.Equal(2, visible.Count);
+ }
+
+ // ── Test 10: DirectoryFilterText filters by DisplayName ──────────────────
+
+ [Fact]
+ public void DirectoryFilterText_filters_by_DisplayName()
+ {
+ var (vm, _, _) = CreateViewModel();
+ vm.DirectoryUsers.Add(MakeMember("Alice"));
+ vm.DirectoryUsers.Add(MakeMember("Charlie"));
+ vm.IncludeGuests = true;
+
+ vm.DirectoryFilterText = "Ali";
+
+ var visible = vm.DirectoryUsersView.Cast().ToList();
+ Assert.Single(visible);
+ Assert.Equal("Alice", visible[0].DisplayName);
+ }
+
+ // ── Test 11: DirectoryFilterText filters by Department ───────────────────
+
+ [Fact]
+ public void DirectoryFilterText_filters_by_Department()
+ {
+ var (vm, _, _) = CreateViewModel();
+ vm.DirectoryUsers.Add(MakeMember("Alice", dept: "Engineering"));
+ vm.DirectoryUsers.Add(MakeMember("Charlie", dept: "Marketing"));
+ vm.IncludeGuests = true;
+
+ vm.DirectoryFilterText = "Market";
+
+ var visible = vm.DirectoryUsersView.Cast().ToList();
+ Assert.Single(visible);
+ Assert.Equal("Charlie", visible[0].DisplayName);
+ }
+
+ // ── Test 12: DirectoryUsersView default sort is DisplayName ascending ────
+
+ [Fact]
+ public void DirectoryUsersView_sorted_by_DisplayName_ascending()
+ {
+ var (vm, _, _) = CreateViewModel();
+ vm.DirectoryUsers.Add(MakeMember("Charlie"));
+ vm.DirectoryUsers.Add(MakeMember("Alice"));
+ vm.DirectoryUsers.Add(MakeMember("Bob"));
+ vm.IncludeGuests = true;
+
+ var visible = vm.DirectoryUsersView.Cast().ToList();
+ Assert.Equal("Alice", visible[0].DisplayName);
+ Assert.Equal("Bob", visible[1].DisplayName);
+ Assert.Equal("Charlie", visible[2].DisplayName);
+ }
+
+ // ── Test 13: OnTenantSwitched clears directory state ─────────────────────
+
+ [Fact]
+ public async Task OnTenantSwitched_clears_directory_state()
+ {
+ var users = new List { MakeMember("Alice") };
+ var (vm, _, _) = CreateViewModel(users);
+
+ // Load directory
+ await vm.TestLoadDirectoryAsync();
+ Assert.NotEmpty(vm.DirectoryUsers);
+ vm.IsBrowseMode = true;
+ vm.DirectoryFilterText = "test";
+ vm.IncludeGuests = true;
+
+ // Act: switch tenant
+ var newProfile = new TenantProfile
+ {
+ Name = "NewTenant",
+ TenantUrl = "https://newtenant.sharepoint.com",
+ ClientId = "new-client-id"
+ };
+ WeakReferenceMessenger.Default.Send(new Core.Messages.TenantSwitchedMessage(newProfile));
+
+ // Assert
+ Assert.Empty(vm.DirectoryUsers);
+ Assert.False(vm.IsBrowseMode);
+ Assert.Empty(vm.DirectoryFilterText);
+ Assert.Empty(vm.DirectoryLoadStatus);
+ Assert.False(vm.IsLoadingDirectory);
+ Assert.False(vm.IncludeGuests);
+ }
+
+ // ── Test 14: DirectoryUserCount reflects filtered count ───────────────────
+
+ [Fact]
+ public void DirectoryUserCount_reflects_filtered_count()
+ {
+ var (vm, _, _) = CreateViewModel();
+ vm.DirectoryUsers.Add(MakeMember("Alice"));
+ vm.DirectoryUsers.Add(MakeGuest("Bob External"));
+ vm.DirectoryUsers.Add(MakeMember("Charlie"));
+
+ // With guests hidden (default IncludeGuests=false)
+ vm.IncludeGuests = false;
+ Assert.Equal(2, vm.DirectoryUserCount);
+
+ // With guests shown
+ vm.IncludeGuests = true;
+ Assert.Equal(3, vm.DirectoryUserCount);
+
+ // With text filter
+ vm.DirectoryFilterText = "Ali";
+ Assert.Equal(1, vm.DirectoryUserCount);
+ }
+
+ // ── Test 15: Search mode still works (no regression) ─────────────────────
+
+ [Fact]
+ public void Search_mode_SelectedUsers_still_works()
+ {
+ var (vm, _, _) = CreateViewModel();
+
+ // Search mode properties should be functional
+ Assert.Empty(vm.SelectedUsers);
+ vm.SelectedUsers.Add(new GraphUserResult("Alice Smith", "alice@contoso.com", "alice@contoso.com"));
+ Assert.Single(vm.SelectedUsers);
+ Assert.Equal("1 user(s) selected", vm.SelectedUsersLabel);
+ }
+
+ // ── Test 16: DirectoryFilterText filters by JobTitle ─────────────────────
+
+ [Fact]
+ public void DirectoryFilterText_filters_by_JobTitle()
+ {
+ var (vm, _, _) = CreateViewModel();
+ vm.DirectoryUsers.Add(MakeMember("Alice", jobTitle: "Senior Developer"));
+ vm.DirectoryUsers.Add(MakeMember("Charlie", jobTitle: "Product Manager"));
+ vm.IncludeGuests = true;
+
+ vm.DirectoryFilterText = "Developer";
+
+ var visible = vm.DirectoryUsersView.Cast().ToList();
+ Assert.Single(visible);
+ Assert.Equal("Alice", visible[0].DisplayName);
+ }
+}
diff --git a/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs b/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
index 4218885..a0a331f 100644
--- a/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
+++ b/SharepointToolbox/ViewModels/Tabs/UserAccessAuditViewModel.cs
@@ -26,6 +26,7 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
private readonly UserAccessCsvExportService? _csvExportService;
private readonly UserAccessHtmlExportService? _htmlExportService;
private readonly IBrandingService? _brandingService;
+ private readonly IGraphUserDirectoryService? _graphUserDirectoryService;
private readonly ILogger _logger;
// ── People picker debounce ──────────────────────────────────────────────
@@ -74,6 +75,34 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
[ObservableProperty]
private bool _isSearching;
+ // ── Directory browse mode properties ───────────────────────────────────
+
+ /// When true, the UI shows the directory browse panel instead of the people-picker search.
+ [ObservableProperty]
+ private bool _isBrowseMode;
+
+ /// All directory users loaded from Graph.
+ [ObservableProperty]
+ private ObservableCollection _directoryUsers = new();
+
+ /// True while a directory load is in progress.
+ [ObservableProperty]
+ private bool _isLoadingDirectory;
+
+ /// Status text for directory load progress, e.g. "Loading... 500 users".
+ [ObservableProperty]
+ private string _directoryLoadStatus = string.Empty;
+
+ /// When true, guest users are shown in the directory view; when false, only members.
+ [ObservableProperty]
+ private bool _includeGuests;
+
+ /// Text filter applied to DirectoryUsersView (DisplayName, UPN, Department, JobTitle).
+ [ObservableProperty]
+ private string _directoryFilterText = string.Empty;
+
+ private CancellationTokenSource? _directoryCts = null;
+
// ── Computed summary properties ─────────────────────────────────────────
/// Total number of access entries in current results.
@@ -91,17 +120,25 @@ public partial class UserAccessAuditViewModel : FeatureViewModelBase
? $"{SelectedUsers.Count} user(s) selected"
: string.Empty;
+ /// Number of users currently visible in the filtered directory view.
+ public int DirectoryUserCount => DirectoryUsersView?.Cast