diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..9dd16c3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,4 @@ +# Copilot Instructions + +## Project Guidelines +- In the Wollmilchsau project, Fluxor state features (e.g., AdminState/AdminActions/AdminEffects/AdminReducers) follow a pattern: cache external service data (Moodle/LDAP/Office users) via IDistributedCache with 10-minute sliding expiration, expose Loaded/LoadFailed flags on state, and combine data sources via username-based outer joins in the Razor page. \ No newline at end of file diff --git a/Wollmilchsau/Components/Pages/Admin/AdminHome.razor b/Wollmilchsau/Components/Pages/Admin/AdminHome.razor index 5099f61..90e5760 100644 --- a/Wollmilchsau/Components/Pages/Admin/AdminHome.razor +++ b/Wollmilchsau/Components/Pages/Admin/AdminHome.razor @@ -10,7 +10,7 @@ User Comparison @@ -18,14 +18,19 @@ Export CSV Refresh Moodle users Refresh LDAP users +Refresh Office users Delete selected Moodle users + + Delete selected Office users + User Comparison -@if (!AdminState.Value.LdapUsersLoaded || !AdminState.Value.MoodleUsersLoaded) +@if (!AdminState.Value.LdapUsersLoaded || !AdminState.Value.MoodleUsersLoaded || !AdminState.Value.OfficeUsersLoaded) { Loading @string.Join(" and ", PendingUserImports)... @@ -40,6 +45,13 @@ else } + @if (AdminState.Value.OfficeUsersLoadFailed) + { + + Office users could not be loaded. Users are shown without Office matches. + + } + @if (AdminState.Value.MoodleUsersDeletionInProgress) { @@ -47,6 +59,13 @@ else } + @if (AdminState.Value.OfficeUsersDeletionInProgress) + { + + Deleting Office users: @AdminState.Value.OfficeUsersDeletedCount of @AdminState.Value.OfficeUsersDeletionTotalCount deleted... + + } + @if (!CombinedUsers.Any()) { @@ -56,7 +75,32 @@ else else { + MultiSelection="true" @bind-SelectedItems="SelectedUsers" RowClassFunc="GetRowClass" CustomHeader="true" + Filter="@FilterFunc"> + + + All classrooms + @foreach (var classroom in AdminState.Value.Classrooms) + { + @classroom.Name + } + + + Any + Yes + No + + + Any + Yes + No + + + Any + Yes + No + + @@ -70,6 +114,7 @@ else Role Exists in LDAP Exists in Moodle + Exists in Office @@ -80,6 +125,7 @@ else @context.Role @(context.ExistsInLdap ? "Yes" : "No") @(context.ExistsInMoodle ? "Yes" : "No") + @(context.ExistsInOffice ? "Yes" : "No") @@ -96,11 +142,16 @@ else private static readonly Func SortByRole = user => user.Role?.ToString() ?? string.Empty; private static readonly Func SortByLdapExistence = user => user.ExistsInLdap; private static readonly Func SortByMoodleExistence = user => user.ExistsInMoodle; + private static readonly Func SortByOfficeExistence = user => user.ExistsInOffice; private MudTable? UserTable { get; set; } private HashSet SelectedUsers { get; set; } = []; private HashSet InvalidDeleteSelection { get; } = []; private IEnumerable LdapUsers => AdminState.Value.LdapUsers.Values; + private string? ClassroomFilter { get; set; } + private bool? LdapFilter { get; set; } + private bool? MoodleFilter { get; set; } + private bool? OfficeFilter { get; set; } private IEnumerable CurrentPageUsers { @@ -144,6 +195,11 @@ else { yield return "Moodle"; } + + if (!AdminState.Value.OfficeUsersLoaded) + { + yield return "Office"; + } } } @@ -158,25 +214,80 @@ else .GroupBy(moodleUser => moodleUser.Username!, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); + var officeUsersByUsername = BuildOfficeUsersByUsername(AdminState.Value.OfficeUsers.Values); + + var matchedUsernames = new HashSet(StringComparer.OrdinalIgnoreCase); + matchedUsernames.UnionWith(ldapUsers.Keys); + matchedUsernames.UnionWith(moodleUsersByUsername.Keys); + return ldapUsers.Values .Select(ldapUser => CreateCombinedUser( + ldapUser.Username, ldapUser, - moodleUsersByUsername.GetValueOrDefault(ldapUser.Username))) + moodleUsersByUsername.GetValueOrDefault(ldapUser.Username), + officeUsersByUsername.GetValueOrDefault(ldapUser.Username))) .Concat(moodleUsers .Where(moodleUser => string.IsNullOrWhiteSpace(moodleUser.Username) || !ldapUsers.ContainsKey(moodleUser.Username)) - .Select(moodleUser => CreateCombinedUser(null, moodleUser))) + .Select(moodleUser => CreateCombinedUser( + moodleUser.Username, + null, + moodleUser, + string.IsNullOrWhiteSpace(moodleUser.Username) ? null : officeUsersByUsername.GetValueOrDefault(moodleUser.Username)))) + .Concat(officeUsersByUsername + .Where(entry => !matchedUsernames.Contains(entry.Key)) + .GroupBy(entry => entry.Value.Id) + .Select(group => group.First()) + .Select(entry => CreateCombinedUser(entry.Key, null, null, entry.Value))) .OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase); } } + private static Dictionary BuildOfficeUsersByUsername( + IEnumerable officeUsers) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var officeUser in officeUsers) + { + foreach (var proxyAddress in officeUser.ProxyAddresses) + { + var username = ExtractUsernameFromProxyAddress(proxyAddress); + if (username is not null && !result.ContainsKey(username)) + { + result[username] = officeUser; + } + } + } + + return result; + } + + private static string? ExtractUsernameFromProxyAddress(string proxyAddress) + { + if (!proxyAddress.Contains(':', StringComparison.Ordinal)) + { + return null; + } + + var parts = proxyAddress.Split(':', 2); + if (parts.Length != 2 || !string.Equals(parts[0], "smtp", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var atIndex = parts[1].IndexOf('@'); + return atIndex > 0 ? parts[1][..atIndex] : null; + } + protected override void OnInitialized() { base.OnInitialized(); Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllMoodleUsers()); Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers()); Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms()); + Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllOfficeUsers()); } private Task DownloadCsvAsync() @@ -204,6 +315,43 @@ else Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.RefreshLdapUsers()); } + private void RefreshOfficeUsers() + { + Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.RefreshOfficeUsers()); + } + + private bool FilterFunc(CombinedUser user) + { + return (string.IsNullOrEmpty(ClassroomFilter) || string.Equals(user.Classroom, ClassroomFilter, StringComparison.OrdinalIgnoreCase)) && + (LdapFilter is null || user.ExistsInLdap == LdapFilter) && + (MoodleFilter is null || user.ExistsInMoodle == MoodleFilter) && + (OfficeFilter is null || user.ExistsInOffice == OfficeFilter); + } + + private void OnClassroomFilterChanged(string? value) + { + ClassroomFilter = value; + UserTable?.NavigateTo(Page.First); + } + + private void OnLdapFilterChanged(bool? value) + { + LdapFilter = value; + UserTable?.NavigateTo(Page.First); + } + + private void OnMoodleFilterChanged(bool? value) + { + MoodleFilter = value; + UserTable?.NavigateTo(Page.First); + } + + private void OnOfficeFilterChanged(bool? value) + { + OfficeFilter = value; + UserTable?.NavigateTo(Page.First); + } + private void SetCurrentPageSelection(bool? selected) { var users = CurrentPageUsers.ToArray(); @@ -247,9 +395,39 @@ else SelectedUsers = []; } + private void DeleteSelectedOfficeUsers() + { + InvalidDeleteSelection.Clear(); + + var protectedUsers = SelectedUsers + .Where(user => user.ExistsInLdap || ContainsAdminTerm(user)) + .ToArray(); + if (protectedUsers.Length > 0) + { + InvalidDeleteSelection.UnionWith(protectedUsers); + Snackbar.Add("No users were deleted because the selection contains LDAP users or names containing 'admin'.", Severity.Error); + return; + } + + var officeUserIds = SelectedUsers + .Where(user => user.ExistsInOffice && user.OfficeUserId is not null) + .Select(user => user.OfficeUserId!) + .Distinct() + .ToArray(); + + if (officeUserIds.Length == 0) + { + Snackbar.Add("No Office users were selected for deletion.", Severity.Warning); + return; + } + + Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.DeleteOfficeUsers(officeUserIds)); + SelectedUsers = []; + } + private string GetRowClass(CombinedUser user, int rowIndex) { - return InvalidDeleteSelection.Contains(user) ? "moodle-delete-blocked" : string.Empty; + return InvalidDeleteSelection.Contains(user) ? "user-delete-blocked" : string.Empty; } private static bool ContainsAdminTerm(CombinedUser user) @@ -266,25 +444,42 @@ else } private static CombinedUser CreateCombinedUser( + string? username, Wollmilchsau.Models.LdapUser? ldapUser, - Wollmilchsau.Models.MoodleUser? moodleUser) + Wollmilchsau.Models.MoodleUser? moodleUser, + Wollmilchsau.Models.OfficeUser? officeUser) { return new CombinedUser( - ldapUser?.Username ?? moodleUser?.Username ?? string.Empty, + username ?? ldapUser?.Username ?? moodleUser?.Username ?? string.Empty, moodleUser?.Id, - ldapUser?.Classroom, + officeUser?.Id, + ldapUser?.Classroom ?? officeUser?.JobTitle, ldapUser?.Surname ?? moodleUser?.Lastname, ldapUser?.GivenName ?? moodleUser?.Firstname, moodleUser?.Fullname, moodleUser?.Auth, - ldapUser?.Role, + ldapUser?.Role ?? GetOfficeDerivedRole(officeUser), ldapUser is not null, - moodleUser is not null); + moodleUser is not null, + officeUser is not null); + } + + private static Wollmilchsau.Models.LdapUserRole? GetOfficeDerivedRole(Wollmilchsau.Models.OfficeUser? officeUser) + { + if (officeUser is null) + { + return null; + } + + return string.Equals(officeUser.Department, "Lehrer", StringComparison.OrdinalIgnoreCase) + ? Wollmilchsau.Models.LdapUserRole.Teacher + : Wollmilchsau.Models.LdapUserRole.Student; } private sealed record CombinedUser( string Username, int? MoodleUserId, + string? OfficeUserId, string? Classroom, string? Surname, string? GivenName, @@ -292,5 +487,6 @@ else string? MoodleAuth, Wollmilchsau.Models.LdapUserRole? Role, bool ExistsInLdap, - bool ExistsInMoodle); + bool ExistsInMoodle, + bool ExistsInOffice); } diff --git a/Wollmilchsau/Models/MsolUser.cs b/Wollmilchsau/Models/MsolUser.cs deleted file mode 100644 index f1e529b..0000000 --- a/Wollmilchsau/Models/MsolUser.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace Wollmilchsau.Models; - -public sealed record MsolUser -{ -} diff --git a/Wollmilchsau/Models/OfficeUser.cs b/Wollmilchsau/Models/OfficeUser.cs new file mode 100644 index 0000000..5246195 --- /dev/null +++ b/Wollmilchsau/Models/OfficeUser.cs @@ -0,0 +1,12 @@ +namespace Wollmilchsau.Models; + +public sealed record OfficeUser +{ + public required string Id { get; init; } + public string? DisplayName { get; init; } + public string? UserPrincipalName { get; init; } + public string? Mail { get; init; } + public string? Department { get; init; } + public string? JobTitle { get; init; } + public IReadOnlyList ProxyAddresses { get; init; } = []; +} diff --git a/Wollmilchsau/Store/AdminStore/AdminActions.cs b/Wollmilchsau/Store/AdminStore/AdminActions.cs index 855e2a9..1704fed 100644 --- a/Wollmilchsau/Store/AdminStore/AdminActions.cs +++ b/Wollmilchsau/Store/AdminStore/AdminActions.cs @@ -17,4 +17,12 @@ public sealed record RefreshLdapUsers(); public sealed record SetLdapUsers(IReadOnlyCollection Users); public sealed record GetAllClassrooms(); public sealed record SetClassrooms(IReadOnlyCollection Classrooms); -public sealed record GetAllMsolUsers(); +public sealed record GetAllOfficeUsers(); +public sealed record RefreshOfficeUsers(); +public sealed record SetOfficeUsers(IReadOnlyCollection Users); +public sealed record SetOfficeUsersLoadFailed(); +public sealed record DeleteOfficeUsers(IReadOnlyCollection UserIds); +public sealed record OfficeUsersDeletionStarted(int TotalUsers); +public sealed record OfficeUsersDeleted(IReadOnlyCollection UserIds); +public sealed record OfficeUsersDeletionFailed(); +public sealed record OfficeUsersDeletionCompleted(); diff --git a/Wollmilchsau/Store/AdminStore/AdminEffects.cs b/Wollmilchsau/Store/AdminStore/AdminEffects.cs index b022062..20b5483 100644 --- a/Wollmilchsau/Store/AdminStore/AdminEffects.cs +++ b/Wollmilchsau/Store/AdminStore/AdminEffects.cs @@ -6,15 +6,18 @@ using StackExchange.Redis; using Wollmilchsau.Models; using Wollmilchsau.Services.Ldap; using Wollmilchsau.Services.Moodle; +using Wollmilchsau.Services.Office; namespace Wollmilchsau.Store.AdminStore; -public sealed class AdminEffects(IMoodleService moodleService, ILdapService ldapService, IDistributedCache cache, ISnackbar snackbar) +public sealed class AdminEffects(IMoodleService moodleService, ILdapService ldapService, IOfficeService officeService, IDistributedCache cache, ISnackbar snackbar) { private const int MoodleDeleteBatchSize = 5; + private const int OfficeDeleteBatchSize = 5; private const string MoodleUsersCacheKey = "admin:moodle-users"; private const string LdapUsersCacheKey = "admin:ldap-users"; private const string ClassroomsCacheKey = "admin:classrooms"; + private const string OfficeUsersCacheKey = "admin:office-users"; [EffectMethod] public async Task HandleGetAllMoodleUsers(GetAllMoodleUsers action, IDispatcher dispatcher) @@ -359,4 +362,236 @@ public sealed class AdminEffects(IMoodleService moodleService, ILdapService ldap return loadedClassrooms; } + + [EffectMethod] + public async Task HandleGetAllOfficeUsers(GetAllOfficeUsers action, IDispatcher dispatcher) + { + try + { + var cachedUsers = await cache.GetStringAsync(OfficeUsersCacheKey); + IReadOnlyCollection users; + + if (cachedUsers is not null) + { + var deserializedUsers = JsonSerializer.Deserialize(cachedUsers); + if (deserializedUsers is not null) + { + users = deserializedUsers; + } + else + { + await cache.RemoveAsync(OfficeUsersCacheKey); + users = await LoadAndCacheOfficeUsers(); + } + } + else + { + users = await LoadAndCacheOfficeUsers(); + } + + dispatcher.Dispatch(new SetOfficeUsers(users)); + } + catch (Microsoft.Kiota.Abstractions.ApiException) + { + dispatcher.Dispatch(new SetOfficeUsersLoadFailed()); + } + catch (JsonException) + { + dispatcher.Dispatch(new SetOfficeUsersLoadFailed()); + } + catch (InvalidOperationException) + { + dispatcher.Dispatch(new SetOfficeUsersLoadFailed()); + } + catch (TaskCanceledException) + { + dispatcher.Dispatch(new SetOfficeUsersLoadFailed()); + } + } + + private async Task> LoadAndCacheOfficeUsers() + { + var users = new List(); + await foreach (var graphUser in officeService.GetAllUsersAsync()) + { + if (graphUser.Id is null) + { + continue; + } + + users.Add(new OfficeUser + { + Id = graphUser.Id, + DisplayName = graphUser.DisplayName, + UserPrincipalName = graphUser.UserPrincipalName, + Mail = graphUser.Mail, + Department = graphUser.Department, + JobTitle = graphUser.JobTitle, + ProxyAddresses = graphUser.ProxyAddresses ?? [] + }); + } + + await cache.SetStringAsync( + OfficeUsersCacheKey, + JsonSerializer.Serialize(users), + new DistributedCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromMinutes(10) + }); + + return users; + } + + [EffectMethod] + public async Task HandleRefreshOfficeUsers(RefreshOfficeUsers action, IDispatcher dispatcher) + { + try + { + await cache.RemoveAsync(OfficeUsersCacheKey); + dispatcher.Dispatch(new SetOfficeUsers(await LoadAndCacheOfficeUsers())); + snackbar.Add("Office users refreshed.", Severity.Success); + } + catch (RedisException) + { + snackbar.Add("Office users could not be refreshed because the cache could not be cleared.", Severity.Error); + } + catch (Microsoft.Kiota.Abstractions.ApiException) + { + snackbar.Add("Office users could not be refreshed.", Severity.Error); + } + catch (JsonException) + { + snackbar.Add("Office users could not be refreshed.", Severity.Error); + } + catch (InvalidOperationException) + { + snackbar.Add("Office users could not be refreshed.", Severity.Error); + } + catch (TaskCanceledException) + { + snackbar.Add("Office user refresh timed out.", Severity.Error); + } + } + + [EffectMethod] + public async Task HandleDeleteOfficeUsers(DeleteOfficeUsers action, IDispatcher dispatcher) + { + var batches = action.UserIds + .Distinct() + .Chunk(OfficeDeleteBatchSize) + .ToArray(); + + if (batches.Length == 0) + { + snackbar.Add("No Office users were selected for deletion.", Severity.Warning); + return; + } + + var totalUserCount = batches.Sum(batch => batch.Length); + dispatcher.Dispatch(new OfficeUsersDeletionStarted(totalUserCount)); + + try + { + await cache.RemoveAsync(OfficeUsersCacheKey); + } + catch (RedisException exception) + { + dispatcher.Dispatch(new OfficeUsersDeletionFailed()); + snackbar.Add($"Office deletion did not start because the cache could not be invalidated: {exception.Message}", Severity.Error); + return; + } + + var deletedUserCount = 0; + foreach (var batch in batches) + { + if (!await TryDeleteOfficeUserBatch(batch, deletedUserCount, totalUserCount, dispatcher)) + { + return; + } + + dispatcher.Dispatch(new OfficeUsersDeleted(batch)); + deletedUserCount += batch.Length; + } + + dispatcher.Dispatch(new OfficeUsersDeletionCompleted()); + snackbar.Add($"Deleted {action.UserIds.Distinct().Count()} Office user(s).", Severity.Success); + } + + private async Task TryDeleteOfficeUserBatch( + IReadOnlyCollection userIds, + int deletedUserCount, + int totalUserCount, + IDispatcher dispatcher) + { + try + { + await Task.WhenAll(userIds.Select(userId => officeService.DeleteUserByIdAsync(userId))); + return true; + } + catch (Microsoft.Kiota.Abstractions.ApiException exception) + { + await ReportOfficeDeletionFailure(dispatcher, exception, userIds, deletedUserCount, totalUserCount); + } + catch (InvalidOperationException exception) + { + await ReportOfficeDeletionFailure(dispatcher, exception, userIds, deletedUserCount, totalUserCount); + } + catch (TaskCanceledException exception) + { + await ReportOfficeDeletionFailure(dispatcher, exception, userIds, deletedUserCount, totalUserCount); + } + catch (JsonException exception) + { + await ReportOfficeDeletionFailure(dispatcher, exception, userIds, deletedUserCount, totalUserCount); + } + + return false; + } + + private async Task ReportOfficeDeletionFailure( + IDispatcher dispatcher, + Exception exception, + IReadOnlyCollection failedUserIds, + int deletedUserCount, + int totalUserCount) + { + dispatcher.Dispatch(new OfficeUsersDeletionFailed()); + + var reconciliationMessage = await ReconcileOfficeUsersAfterDeletionFailure(dispatcher, failedUserIds); + snackbar.Add( + $"Office deletion stopped after confirming {deletedUserCount} of {totalUserCount} user(s). Reason: {exception.Message}.{reconciliationMessage}", + Severity.Error); + } + + private async Task ReconcileOfficeUsersAfterDeletionFailure( + IDispatcher dispatcher, + IReadOnlyCollection failedUserIds) + { + try + { + var users = await LoadAndCacheOfficeUsers(); + dispatcher.Dispatch(new SetOfficeUsers(users)); + + var deletedFromFailedRequest = failedUserIds.Count(userId => !users.Any(user => user.Id == userId)); + return deletedFromFailedRequest == 0 + ? " The user list was refreshed from Office." + : $" The user list was refreshed from Office; {deletedFromFailedRequest} user(s) from the failed request were deleted server-side."; + } + catch (Microsoft.Kiota.Abstractions.ApiException exception) + { + return $" The user list could not be refreshed: {exception.Message}"; + } + catch (InvalidOperationException exception) + { + return $" The user list could not be refreshed: {exception.Message}"; + } + catch (TaskCanceledException exception) + { + return $" The user list could not be refreshed: {exception.Message}"; + } + catch (JsonException exception) + { + return $" The user list could not be refreshed: {exception.Message}"; + } + } } diff --git a/Wollmilchsau/Store/AdminStore/AdminReducers.cs b/Wollmilchsau/Store/AdminStore/AdminReducers.cs index 1212072..10ef3d7 100644 --- a/Wollmilchsau/Store/AdminStore/AdminReducers.cs +++ b/Wollmilchsau/Store/AdminStore/AdminReducers.cs @@ -88,4 +88,64 @@ public static class AdminReducers .ToImmutableList() }; } + + [ReducerMethod] + public static AdminState ReduceSetOfficeUsers(AdminState state, SetOfficeUsers action) + { + return state with + { + OfficeUsers = action.Users.ToImmutableDictionary(user => user.Id), + OfficeUsersLoaded = true, + OfficeUsersLoadFailed = false + }; + } + + [ReducerMethod] + public static AdminState ReduceSetOfficeUsersLoadFailed(AdminState state, SetOfficeUsersLoadFailed action) + { + return state with + { + OfficeUsers = [], + OfficeUsersLoaded = true, + OfficeUsersLoadFailed = true + }; + } + + [ReducerMethod] + public static AdminState ReduceOfficeUsersDeletionStarted(AdminState state, OfficeUsersDeletionStarted action) + { + return state with + { + OfficeUsersDeletionInProgress = true, + OfficeUsersDeletedCount = 0, + OfficeUsersDeletionTotalCount = action.TotalUsers + }; + } + + [ReducerMethod] + public static AdminState ReduceOfficeUsersDeleted(AdminState state, OfficeUsersDeleted action) + { + return state with + { + OfficeUsers = state.OfficeUsers.RemoveRange(action.UserIds), + OfficeUsersDeletedCount = state.OfficeUsersDeletedCount + action.UserIds.Count + }; + } + + [ReducerMethod] + public static AdminState ReduceOfficeUsersDeletionFailed(AdminState state, OfficeUsersDeletionFailed action) + { + return state with { OfficeUsersDeletionInProgress = false }; + } + + [ReducerMethod] + public static AdminState ReduceOfficeUsersDeletionCompleted(AdminState state, OfficeUsersDeletionCompleted action) + { + return state with + { + OfficeUsersDeletionInProgress = false, + OfficeUsersDeletedCount = 0, + OfficeUsersDeletionTotalCount = 0 + }; + } } diff --git a/Wollmilchsau/Store/AdminStore/AdminState.cs b/Wollmilchsau/Store/AdminStore/AdminState.cs index 182f926..0f246c1 100644 --- a/Wollmilchsau/Store/AdminStore/AdminState.cs +++ b/Wollmilchsau/Store/AdminStore/AdminState.cs @@ -18,5 +18,10 @@ public sealed record AdminState public ImmutableDictionary LdapUsers { get; init; } = []; public ImmutableList Classrooms { get; init; } = []; public bool LdapUsersLoaded { get; init; } - public ImmutableDictionary MsolUsers { get; init; } = []; + public ImmutableDictionary OfficeUsers { get; init; } = []; + public bool OfficeUsersLoaded { get; init; } + public bool OfficeUsersLoadFailed { get; init; } + public bool OfficeUsersDeletionInProgress { get; init; } + public int OfficeUsersDeletedCount { get; init; } + public int OfficeUsersDeletionTotalCount { get; init; } }