implemented office user administration

This commit is contained in:
Rhywden
2026-08-31 13:28:52 +02:00
parent 9bf75b0e15
commit 019f251474
8 changed files with 535 additions and 20 deletions
+4
View File
@@ -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.
@@ -10,7 +10,7 @@
<PageTitle>User Comparison</PageTitle> <PageTitle>User Comparison</PageTitle>
<style> <style>
.moodle-delete-blocked > td { .user-delete-blocked > td {
background-color: var(--mud-palette-error-lighten, #ffebee) !important; background-color: var(--mud-palette-error-lighten, #ffebee) !important;
} }
</style> </style>
@@ -18,14 +18,19 @@
<MudButton OnClick="DownloadCsvAsync" Disabled="@(!LdapUsers.Any())">Export CSV</MudButton> <MudButton OnClick="DownloadCsvAsync" Disabled="@(!LdapUsers.Any())">Export CSV</MudButton>
<MudButton OnClick="RefreshMoodleUsers">Refresh Moodle users</MudButton> <MudButton OnClick="RefreshMoodleUsers">Refresh Moodle users</MudButton>
<MudButton OnClick="RefreshLdapUsers">Refresh LDAP users</MudButton> <MudButton OnClick="RefreshLdapUsers">Refresh LDAP users</MudButton>
<MudButton OnClick="RefreshOfficeUsers">Refresh Office users</MudButton>
<MudButton Color="Color.Error" OnClick="DeleteSelectedMoodleUsers" <MudButton Color="Color.Error" OnClick="DeleteSelectedMoodleUsers"
Disabled="@(!SelectedUsers.Any() || AdminState.Value.MoodleUsersDeletionInProgress)"> Disabled="@(!SelectedUsers.Any() || AdminState.Value.MoodleUsersDeletionInProgress)">
Delete selected Moodle users Delete selected Moodle users
</MudButton> </MudButton>
<MudButton Color="Color.Error" OnClick="DeleteSelectedOfficeUsers"
Disabled="@(!SelectedUsers.Any() || AdminState.Value.OfficeUsersDeletionInProgress)">
Delete selected Office users
</MudButton>
<MudText Typo="Typo.h1">User Comparison</MudText> <MudText Typo="Typo.h1">User Comparison</MudText>
@if (!AdminState.Value.LdapUsersLoaded || !AdminState.Value.MoodleUsersLoaded) @if (!AdminState.Value.LdapUsersLoaded || !AdminState.Value.MoodleUsersLoaded || !AdminState.Value.OfficeUsersLoaded)
{ {
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined"> <MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
Loading @string.Join(" and ", PendingUserImports)... Loading @string.Join(" and ", PendingUserImports)...
@@ -40,6 +45,13 @@ else
</MudAlert> </MudAlert>
} }
@if (AdminState.Value.OfficeUsersLoadFailed)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
Office users could not be loaded. Users are shown without Office matches.
</MudAlert>
}
@if (AdminState.Value.MoodleUsersDeletionInProgress) @if (AdminState.Value.MoodleUsersDeletionInProgress)
{ {
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined"> <MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
@@ -47,6 +59,13 @@ else
</MudAlert> </MudAlert>
} }
@if (AdminState.Value.OfficeUsersDeletionInProgress)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
Deleting Office users: @AdminState.Value.OfficeUsersDeletedCount of @AdminState.Value.OfficeUsersDeletionTotalCount deleted...
</MudAlert>
}
@if (!CombinedUsers.Any()) @if (!CombinedUsers.Any())
{ {
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined"> <MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
@@ -56,7 +75,32 @@ else
else else
{ {
<MudTable @ref="UserTable" T="CombinedUser" Items="@CombinedUsers" Hover="true" Dense="true" RowsPerPage="25" <MudTable @ref="UserTable" T="CombinedUser" Items="@CombinedUsers" Hover="true" Dense="true" RowsPerPage="25"
MultiSelection="true" @bind-SelectedItems="SelectedUsers" RowClassFunc="GetRowClass" CustomHeader="true"> MultiSelection="true" @bind-SelectedItems="SelectedUsers" RowClassFunc="GetRowClass" CustomHeader="true"
Filter="@FilterFunc">
<ToolBarContent>
<MudSelect T="string?" Label="Classroom" Value="ClassroomFilter" ValueChanged="OnClassroomFilterChanged" Clearable="true">
<MudSelectItem T="string?" Value="@((string?)null)">All classrooms</MudSelectItem>
@foreach (var classroom in AdminState.Value.Classrooms)
{
<MudSelectItem T="string?" Value="@classroom.Name">@classroom.Name</MudSelectItem>
}
</MudSelect>
<MudSelect T="bool?" Label="Exists in LDAP" Value="LdapFilter" ValueChanged="OnLdapFilterChanged" Clearable="true">
<MudSelectItem T="bool?" Value="@((bool?)null)">Any</MudSelectItem>
<MudSelectItem T="bool?" Value="@((bool?)true)">Yes</MudSelectItem>
<MudSelectItem T="bool?" Value="@((bool?)false)">No</MudSelectItem>
</MudSelect>
<MudSelect T="bool?" Label="Exists in Moodle" Value="MoodleFilter" ValueChanged="OnMoodleFilterChanged" Clearable="true">
<MudSelectItem T="bool?" Value="@((bool?)null)">Any</MudSelectItem>
<MudSelectItem T="bool?" Value="@((bool?)true)">Yes</MudSelectItem>
<MudSelectItem T="bool?" Value="@((bool?)false)">No</MudSelectItem>
</MudSelect>
<MudSelect T="bool?" Label="Exists in Office" Value="OfficeFilter" ValueChanged="OnOfficeFilterChanged" Clearable="true">
<MudSelectItem T="bool?" Value="@((bool?)null)">Any</MudSelectItem>
<MudSelectItem T="bool?" Value="@((bool?)true)">Yes</MudSelectItem>
<MudSelectItem T="bool?" Value="@((bool?)false)">No</MudSelectItem>
</MudSelect>
</ToolBarContent>
<HeaderContent> <HeaderContent>
<MudTHeadRow IgnoreCheckbox="true"> <MudTHeadRow IgnoreCheckbox="true">
<MudTh> <MudTh>
@@ -70,6 +114,7 @@ else
<MudTh><MudTableSortLabel SortBy="SortByRole">Role</MudTableSortLabel></MudTh> <MudTh><MudTableSortLabel SortBy="SortByRole">Role</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="SortByLdapExistence">Exists in LDAP</MudTableSortLabel></MudTh> <MudTh><MudTableSortLabel SortBy="SortByLdapExistence">Exists in LDAP</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="SortByMoodleExistence">Exists in Moodle</MudTableSortLabel></MudTh> <MudTh><MudTableSortLabel SortBy="SortByMoodleExistence">Exists in Moodle</MudTableSortLabel></MudTh>
<MudTh><MudTableSortLabel SortBy="SortByOfficeExistence">Exists in Office</MudTableSortLabel></MudTh>
</MudTHeadRow> </MudTHeadRow>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
@@ -80,6 +125,7 @@ else
<MudTd DataLabel="Role">@context.Role</MudTd> <MudTd DataLabel="Role">@context.Role</MudTd>
<MudTd DataLabel="Exists in LDAP">@(context.ExistsInLdap ? "Yes" : "No")</MudTd> <MudTd DataLabel="Exists in LDAP">@(context.ExistsInLdap ? "Yes" : "No")</MudTd>
<MudTd DataLabel="Exists in Moodle">@(context.ExistsInMoodle ? "Yes" : "No")</MudTd> <MudTd DataLabel="Exists in Moodle">@(context.ExistsInMoodle ? "Yes" : "No")</MudTd>
<MudTd DataLabel="Exists in Office">@(context.ExistsInOffice ? "Yes" : "No")</MudTd>
</RowTemplate> </RowTemplate>
<PagerContent> <PagerContent>
<MudTablePager /> <MudTablePager />
@@ -96,11 +142,16 @@ else
private static readonly Func<CombinedUser, object> SortByRole = user => user.Role?.ToString() ?? string.Empty; private static readonly Func<CombinedUser, object> SortByRole = user => user.Role?.ToString() ?? string.Empty;
private static readonly Func<CombinedUser, object> SortByLdapExistence = user => user.ExistsInLdap; private static readonly Func<CombinedUser, object> SortByLdapExistence = user => user.ExistsInLdap;
private static readonly Func<CombinedUser, object> SortByMoodleExistence = user => user.ExistsInMoodle; private static readonly Func<CombinedUser, object> SortByMoodleExistence = user => user.ExistsInMoodle;
private static readonly Func<CombinedUser, object> SortByOfficeExistence = user => user.ExistsInOffice;
private MudTable<CombinedUser>? UserTable { get; set; } private MudTable<CombinedUser>? UserTable { get; set; }
private HashSet<CombinedUser> SelectedUsers { get; set; } = []; private HashSet<CombinedUser> SelectedUsers { get; set; } = [];
private HashSet<CombinedUser> InvalidDeleteSelection { get; } = []; private HashSet<CombinedUser> InvalidDeleteSelection { get; } = [];
private IEnumerable<Wollmilchsau.Models.LdapUser> LdapUsers => AdminState.Value.LdapUsers.Values; private IEnumerable<Wollmilchsau.Models.LdapUser> 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<CombinedUser> CurrentPageUsers private IEnumerable<CombinedUser> CurrentPageUsers
{ {
@@ -144,6 +195,11 @@ else
{ {
yield return "Moodle"; yield return "Moodle";
} }
if (!AdminState.Value.OfficeUsersLoaded)
{
yield return "Office";
}
} }
} }
@@ -158,25 +214,80 @@ else
.GroupBy(moodleUser => moodleUser.Username!, StringComparer.OrdinalIgnoreCase) .GroupBy(moodleUser => moodleUser.Username!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
var officeUsersByUsername = BuildOfficeUsersByUsername(AdminState.Value.OfficeUsers.Values);
var matchedUsernames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
matchedUsernames.UnionWith(ldapUsers.Keys);
matchedUsernames.UnionWith(moodleUsersByUsername.Keys);
return ldapUsers.Values return ldapUsers.Values
.Select(ldapUser => CreateCombinedUser( .Select(ldapUser => CreateCombinedUser(
ldapUser.Username,
ldapUser, ldapUser,
moodleUsersByUsername.GetValueOrDefault(ldapUser.Username))) moodleUsersByUsername.GetValueOrDefault(ldapUser.Username),
officeUsersByUsername.GetValueOrDefault(ldapUser.Username)))
.Concat(moodleUsers .Concat(moodleUsers
.Where(moodleUser => .Where(moodleUser =>
string.IsNullOrWhiteSpace(moodleUser.Username) || string.IsNullOrWhiteSpace(moodleUser.Username) ||
!ldapUsers.ContainsKey(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); .OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase);
} }
} }
private static Dictionary<string, Wollmilchsau.Models.OfficeUser> BuildOfficeUsersByUsername(
IEnumerable<Wollmilchsau.Models.OfficeUser> officeUsers)
{
var result = new Dictionary<string, Wollmilchsau.Models.OfficeUser>(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() protected override void OnInitialized()
{ {
base.OnInitialized(); base.OnInitialized();
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllMoodleUsers()); Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllMoodleUsers());
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers()); Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers());
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms()); Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms());
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllOfficeUsers());
} }
private Task DownloadCsvAsync() private Task DownloadCsvAsync()
@@ -204,6 +315,43 @@ else
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.RefreshLdapUsers()); 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) private void SetCurrentPageSelection(bool? selected)
{ {
var users = CurrentPageUsers.ToArray(); var users = CurrentPageUsers.ToArray();
@@ -247,9 +395,39 @@ else
SelectedUsers = []; 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) 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) private static bool ContainsAdminTerm(CombinedUser user)
@@ -266,25 +444,42 @@ else
} }
private static CombinedUser CreateCombinedUser( private static CombinedUser CreateCombinedUser(
string? username,
Wollmilchsau.Models.LdapUser? ldapUser, Wollmilchsau.Models.LdapUser? ldapUser,
Wollmilchsau.Models.MoodleUser? moodleUser) Wollmilchsau.Models.MoodleUser? moodleUser,
Wollmilchsau.Models.OfficeUser? officeUser)
{ {
return new CombinedUser( return new CombinedUser(
ldapUser?.Username ?? moodleUser?.Username ?? string.Empty, username ?? ldapUser?.Username ?? moodleUser?.Username ?? string.Empty,
moodleUser?.Id, moodleUser?.Id,
ldapUser?.Classroom, officeUser?.Id,
ldapUser?.Classroom ?? officeUser?.JobTitle,
ldapUser?.Surname ?? moodleUser?.Lastname, ldapUser?.Surname ?? moodleUser?.Lastname,
ldapUser?.GivenName ?? moodleUser?.Firstname, ldapUser?.GivenName ?? moodleUser?.Firstname,
moodleUser?.Fullname, moodleUser?.Fullname,
moodleUser?.Auth, moodleUser?.Auth,
ldapUser?.Role, ldapUser?.Role ?? GetOfficeDerivedRole(officeUser),
ldapUser is not null, 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( private sealed record CombinedUser(
string Username, string Username,
int? MoodleUserId, int? MoodleUserId,
string? OfficeUserId,
string? Classroom, string? Classroom,
string? Surname, string? Surname,
string? GivenName, string? GivenName,
@@ -292,5 +487,6 @@ else
string? MoodleAuth, string? MoodleAuth,
Wollmilchsau.Models.LdapUserRole? Role, Wollmilchsau.Models.LdapUserRole? Role,
bool ExistsInLdap, bool ExistsInLdap,
bool ExistsInMoodle); bool ExistsInMoodle,
bool ExistsInOffice);
} }
-5
View File
@@ -1,5 +0,0 @@
namespace Wollmilchsau.Models;
public sealed record MsolUser
{
}
+12
View File
@@ -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<string> ProxyAddresses { get; init; } = [];
}
@@ -17,4 +17,12 @@ public sealed record RefreshLdapUsers();
public sealed record SetLdapUsers(IReadOnlyCollection<LdapUser> Users); public sealed record SetLdapUsers(IReadOnlyCollection<LdapUser> Users);
public sealed record GetAllClassrooms(); public sealed record GetAllClassrooms();
public sealed record SetClassrooms(IReadOnlyCollection<Classroom> Classrooms); public sealed record SetClassrooms(IReadOnlyCollection<Classroom> Classrooms);
public sealed record GetAllMsolUsers(); public sealed record GetAllOfficeUsers();
public sealed record RefreshOfficeUsers();
public sealed record SetOfficeUsers(IReadOnlyCollection<OfficeUser> Users);
public sealed record SetOfficeUsersLoadFailed();
public sealed record DeleteOfficeUsers(IReadOnlyCollection<string> UserIds);
public sealed record OfficeUsersDeletionStarted(int TotalUsers);
public sealed record OfficeUsersDeleted(IReadOnlyCollection<string> UserIds);
public sealed record OfficeUsersDeletionFailed();
public sealed record OfficeUsersDeletionCompleted();
+236 -1
View File
@@ -6,15 +6,18 @@ using StackExchange.Redis;
using Wollmilchsau.Models; using Wollmilchsau.Models;
using Wollmilchsau.Services.Ldap; using Wollmilchsau.Services.Ldap;
using Wollmilchsau.Services.Moodle; using Wollmilchsau.Services.Moodle;
using Wollmilchsau.Services.Office;
namespace Wollmilchsau.Store.AdminStore; 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 MoodleDeleteBatchSize = 5;
private const int OfficeDeleteBatchSize = 5;
private const string MoodleUsersCacheKey = "admin:moodle-users"; private const string MoodleUsersCacheKey = "admin:moodle-users";
private const string LdapUsersCacheKey = "admin:ldap-users"; private const string LdapUsersCacheKey = "admin:ldap-users";
private const string ClassroomsCacheKey = "admin:classrooms"; private const string ClassroomsCacheKey = "admin:classrooms";
private const string OfficeUsersCacheKey = "admin:office-users";
[EffectMethod] [EffectMethod]
public async Task HandleGetAllMoodleUsers(GetAllMoodleUsers action, IDispatcher dispatcher) public async Task HandleGetAllMoodleUsers(GetAllMoodleUsers action, IDispatcher dispatcher)
@@ -359,4 +362,236 @@ public sealed class AdminEffects(IMoodleService moodleService, ILdapService ldap
return loadedClassrooms; return loadedClassrooms;
} }
[EffectMethod]
public async Task HandleGetAllOfficeUsers(GetAllOfficeUsers action, IDispatcher dispatcher)
{
try
{
var cachedUsers = await cache.GetStringAsync(OfficeUsersCacheKey);
IReadOnlyCollection<OfficeUser> users;
if (cachedUsers is not null)
{
var deserializedUsers = JsonSerializer.Deserialize<OfficeUser[]>(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<IReadOnlyCollection<OfficeUser>> LoadAndCacheOfficeUsers()
{
var users = new List<OfficeUser>();
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<bool> TryDeleteOfficeUserBatch(
IReadOnlyCollection<string> 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<string> 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<string> ReconcileOfficeUsersAfterDeletionFailure(
IDispatcher dispatcher,
IReadOnlyCollection<string> 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}";
}
}
} }
@@ -88,4 +88,64 @@ public static class AdminReducers
.ToImmutableList() .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
};
}
} }
+6 -1
View File
@@ -18,5 +18,10 @@ public sealed record AdminState
public ImmutableDictionary<string, LdapUser> LdapUsers { get; init; } = []; public ImmutableDictionary<string, LdapUser> LdapUsers { get; init; } = [];
public ImmutableList<Classroom> Classrooms { get; init; } = []; public ImmutableList<Classroom> Classrooms { get; init; } = [];
public bool LdapUsersLoaded { get; init; } public bool LdapUsersLoaded { get; init; }
public ImmutableDictionary<Guid, MsolUser> MsolUsers { get; init; } = []; public ImmutableDictionary<string, OfficeUser> 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; }
} }