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; }
}