Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 019f251474 |
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
namespace Wollmilchsau.Models;
|
|
||||||
|
|
||||||
public sealed record MsolUser
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user