Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 019f251474 | |||
| 9bf75b0e15 | |||
| d2db6a8b6c | |||
| 04f7f527be | |||
| 78656d7165 | |||
| cf145c299c | |||
| c071489eec | |||
| a030cc517a | |||
| edba5d8478 | |||
| 81327b6553 | |||
| 59eaa9b8e8 |
@@ -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.
|
||||
@@ -5,70 +5,488 @@
|
||||
@inject Fluxor.IDispatcher Dispatcher
|
||||
@inject Fluxor.IState<Wollmilchsau.Store.AdminStore.AdminState> AdminState
|
||||
@inject IJSRuntime JsRuntime
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>LDAP Users</PageTitle>
|
||||
<PageTitle>User Comparison</PageTitle>
|
||||
|
||||
<button class="btn btn-primary" @onclick="DownloadCsvAsync" disabled="@(!Users.Any())">Export CSV</button>
|
||||
<style>
|
||||
.user-delete-blocked > td {
|
||||
background-color: var(--mud-palette-error-lighten, #ffebee) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<h1>LDAP Users</h1>
|
||||
<MudButton OnClick="DownloadCsvAsync" Disabled="@(!LdapUsers.Any())">Export CSV</MudButton>
|
||||
<MudButton OnClick="RefreshMoodleUsers">Refresh Moodle users</MudButton>
|
||||
<MudButton OnClick="RefreshLdapUsers">Refresh LDAP users</MudButton>
|
||||
<MudButton OnClick="RefreshOfficeUsers">Refresh Office users</MudButton>
|
||||
<MudButton Color="Color.Error" OnClick="DeleteSelectedMoodleUsers"
|
||||
Disabled="@(!SelectedUsers.Any() || AdminState.Value.MoodleUsersDeletionInProgress)">
|
||||
Delete selected Moodle users
|
||||
</MudButton>
|
||||
<MudButton Color="Color.Error" OnClick="DeleteSelectedOfficeUsers"
|
||||
Disabled="@(!SelectedUsers.Any() || AdminState.Value.OfficeUsersDeletionInProgress)">
|
||||
Delete selected Office users
|
||||
</MudButton>
|
||||
|
||||
@if (!AdminState.Value.LdapUsersLoaded)
|
||||
<MudText Typo="Typo.h1">User Comparison</MudText>
|
||||
|
||||
@if (!AdminState.Value.LdapUsersLoaded || !AdminState.Value.MoodleUsersLoaded || !AdminState.Value.OfficeUsersLoaded)
|
||||
{
|
||||
<p><em>Loading users...</em></p>
|
||||
}
|
||||
else if (!Users.Any())
|
||||
{
|
||||
<p>No LDAP users found.</p>
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
|
||||
Loading @string.Join(" and ", PendingUserImports)...
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Surname</th>
|
||||
<th>Classroom</th>
|
||||
<th>Given name</th>
|
||||
<th>Incomplete</th>
|
||||
<th>Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var user in Users)
|
||||
{
|
||||
<tr>
|
||||
<td>@user.Username</td>
|
||||
<td>@user.Surname</td>
|
||||
<td>@user.Classroom</td>
|
||||
<td>@user.GivenName</td>
|
||||
<td>@user.Incomplete</td>
|
||||
<td>@user.Role</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@if (AdminState.Value.MoodleUsersLoadFailed)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
|
||||
Moodle users could not be loaded. LDAP users are shown without Moodle matches.
|
||||
</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)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
|
||||
Deleting Moodle users: @AdminState.Value.MoodleUsersDeletedCount of @AdminState.Value.MoodleUsersDeletionTotalCount deleted...
|
||||
</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())
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
|
||||
No users found.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable @ref="UserTable" T="CombinedUser" Items="@CombinedUsers" Hover="true" Dense="true" RowsPerPage="25"
|
||||
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>
|
||||
<MudTHeadRow IgnoreCheckbox="true">
|
||||
<MudTh>
|
||||
<MudCheckBox T="bool?" Value="CurrentPageSelectionState" ValueChanged="SetCurrentPageSelection"
|
||||
aria-label="Select visible users" />
|
||||
</MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="SortByUsername">Username</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="SortByClassroom">Classroom</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="SortBySurname">Surname</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="SortByGivenName">Given name</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="SortByRole">Role</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="SortByLdapExistence">Exists in LDAP</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="SortByMoodleExistence">Exists in Moodle</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="SortByOfficeExistence">Exists in Office</MudTableSortLabel></MudTh>
|
||||
</MudTHeadRow>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Username">@context.Username</MudTd>
|
||||
<MudTd DataLabel="Classroom">@context.Classroom</MudTd>
|
||||
<MudTd DataLabel="Surname">@context.Surname</MudTd>
|
||||
<MudTd DataLabel="Given name">@context.GivenName</MudTd>
|
||||
<MudTd DataLabel="Role">@context.Role</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 Office">@(context.ExistsInOffice ? "Yes" : "No")</MudTd>
|
||||
</RowTemplate>
|
||||
<PagerContent>
|
||||
<MudTablePager />
|
||||
</PagerContent>
|
||||
</MudTable>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private IEnumerable<Wollmilchsau.Models.LdapUser> Users => AdminState.Value.LdapUsers.Values;
|
||||
private static readonly Func<CombinedUser, object> SortByUsername = user => user.Username;
|
||||
private static readonly Func<CombinedUser, object> SortByClassroom = user => user.Classroom ?? string.Empty;
|
||||
private static readonly Func<CombinedUser, object> SortBySurname = user => user.Surname ?? string.Empty;
|
||||
private static readonly Func<CombinedUser, object> SortByGivenName = user => user.GivenName ?? 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> SortByMoodleExistence = user => user.ExistsInMoodle;
|
||||
private static readonly Func<CombinedUser, object> SortByOfficeExistence = user => user.ExistsInOffice;
|
||||
|
||||
private MudTable<CombinedUser>? UserTable { get; set; }
|
||||
private HashSet<CombinedUser> SelectedUsers { get; set; } = [];
|
||||
private HashSet<CombinedUser> InvalidDeleteSelection { get; } = [];
|
||||
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
|
||||
{
|
||||
get
|
||||
{
|
||||
if (UserTable is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return UserTable.FilteredItems
|
||||
.Skip(UserTable.CurrentPage * UserTable.RowsPerPage)
|
||||
.Take(UserTable.RowsPerPage);
|
||||
}
|
||||
}
|
||||
|
||||
private bool? CurrentPageSelectionState
|
||||
{
|
||||
get
|
||||
{
|
||||
var users = CurrentPageUsers.ToArray();
|
||||
if (users.Length == 0 || !users.Any(SelectedUsers.Contains))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return users.All(SelectedUsers.Contains) ? true : null;
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<string> PendingUserImports
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!AdminState.Value.LdapUsersLoaded)
|
||||
{
|
||||
yield return "LDAP";
|
||||
}
|
||||
|
||||
if (!AdminState.Value.MoodleUsersLoaded)
|
||||
{
|
||||
yield return "Moodle";
|
||||
}
|
||||
|
||||
if (!AdminState.Value.OfficeUsersLoaded)
|
||||
{
|
||||
yield return "Office";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<CombinedUser> CombinedUsers
|
||||
{
|
||||
get
|
||||
{
|
||||
var ldapUsers = AdminState.Value.LdapUsers;
|
||||
var moodleUsers = AdminState.Value.MoodleUsers.Values.ToArray();
|
||||
var moodleUsersByUsername = moodleUsers
|
||||
.Where(moodleUser => !string.IsNullOrWhiteSpace(moodleUser.Username))
|
||||
.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<string>(StringComparer.OrdinalIgnoreCase);
|
||||
matchedUsernames.UnionWith(ldapUsers.Keys);
|
||||
matchedUsernames.UnionWith(moodleUsersByUsername.Keys);
|
||||
|
||||
return ldapUsers.Values
|
||||
.Select(ldapUser => CreateCombinedUser(
|
||||
ldapUser.Username,
|
||||
ldapUser,
|
||||
moodleUsersByUsername.GetValueOrDefault(ldapUser.Username),
|
||||
officeUsersByUsername.GetValueOrDefault(ldapUser.Username)))
|
||||
.Concat(moodleUsers
|
||||
.Where(moodleUser =>
|
||||
string.IsNullOrWhiteSpace(moodleUser.Username) ||
|
||||
!ldapUsers.ContainsKey(moodleUser.Username))
|
||||
.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<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()
|
||||
{
|
||||
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()
|
||||
{
|
||||
var csv = string.Join("\r\n", Users
|
||||
.Where(user => user.Role == Wollmilchsau.Models.LdapUserRole.Student)
|
||||
var csv = string.Join("\r\n", LdapUsers
|
||||
.Where(user =>
|
||||
user.Role == Wollmilchsau.Models.LdapUserRole.Student &&
|
||||
!string.IsNullOrWhiteSpace(user.Classroom))
|
||||
.Select(user => string.Join(';',
|
||||
user.GivenName ?? string.Empty,
|
||||
user.Surname ?? string.Empty,
|
||||
user.Classroom ?? string.Empty,
|
||||
user.Classroom,
|
||||
user.Username)));
|
||||
|
||||
return JsRuntime.InvokeVoidAsync("downloadTextFile", "ldap-users.csv", csv).AsTask();
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshMoodleUsers()
|
||||
{
|
||||
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.RefreshMoodleUsers());
|
||||
}
|
||||
|
||||
private void 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)
|
||||
{
|
||||
var users = CurrentPageUsers.ToArray();
|
||||
if (selected == true)
|
||||
{
|
||||
SelectedUsers.UnionWith(users);
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectedUsers.ExceptWith(users);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteSelectedMoodleUsers()
|
||||
{
|
||||
InvalidDeleteSelection.Clear();
|
||||
|
||||
var protectedUsers = SelectedUsers
|
||||
.Where(user => user.ExistsInLdap || ContainsAdminTerm(user) || IsManualMoodleUser(user))
|
||||
.ToArray();
|
||||
if (protectedUsers.Length > 0)
|
||||
{
|
||||
InvalidDeleteSelection.UnionWith(protectedUsers);
|
||||
Snackbar.Add("No users were deleted because the selection contains LDAP users, manual Moodle users, or names containing 'admin'.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
var moodleUserIds = SelectedUsers
|
||||
.Where(user => user.ExistsInMoodle && user.MoodleUserId is not null)
|
||||
.Select(user => user.MoodleUserId!.Value)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
if (moodleUserIds.Length == 0)
|
||||
{
|
||||
Snackbar.Add("No Moodle users were selected for deletion.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.DeleteMoodleUsers(moodleUserIds));
|
||||
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) ? "user-delete-blocked" : string.Empty;
|
||||
}
|
||||
|
||||
private static bool ContainsAdminTerm(CombinedUser user)
|
||||
{
|
||||
return user.Username.Contains("admin", StringComparison.OrdinalIgnoreCase) ||
|
||||
user.Surname?.Contains("admin", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
user.GivenName?.Contains("admin", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
user.MoodleFullname?.Contains("admin", StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
private static bool IsManualMoodleUser(CombinedUser user)
|
||||
{
|
||||
return string.Equals(user.MoodleAuth, "manual", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static CombinedUser CreateCombinedUser(
|
||||
string? username,
|
||||
Wollmilchsau.Models.LdapUser? ldapUser,
|
||||
Wollmilchsau.Models.MoodleUser? moodleUser,
|
||||
Wollmilchsau.Models.OfficeUser? officeUser)
|
||||
{
|
||||
return new CombinedUser(
|
||||
username ?? ldapUser?.Username ?? moodleUser?.Username ?? string.Empty,
|
||||
moodleUser?.Id,
|
||||
officeUser?.Id,
|
||||
ldapUser?.Classroom ?? officeUser?.JobTitle,
|
||||
ldapUser?.Surname ?? moodleUser?.Lastname,
|
||||
ldapUser?.GivenName ?? moodleUser?.Firstname,
|
||||
moodleUser?.Fullname,
|
||||
moodleUser?.Auth,
|
||||
ldapUser?.Role ?? GetOfficeDerivedRole(officeUser),
|
||||
ldapUser 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,
|
||||
string? MoodleFullname,
|
||||
string? MoodleAuth,
|
||||
Wollmilchsau.Models.LdapUserRole? Role,
|
||||
bool ExistsInLdap,
|
||||
bool ExistsInMoodle,
|
||||
bool ExistsInOffice);
|
||||
}
|
||||
|
||||
@@ -6,16 +6,19 @@
|
||||
<AuthorizeView Roles="ROLE_STUDENT,ROLE_TEACHER,ROLE_ADMIN">
|
||||
<Authorized>
|
||||
<MudContainer>
|
||||
<MudCard Style="width: 300px;">
|
||||
<MudCardMedia Image="images/questionnaire.svg" Height="200" Style="width: 300px;" />
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h5">Umfragen</MudText>
|
||||
<MudText Typo="Typo.body2">Hier kannst du an Umfragen teilnehmen.</MudText>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/students/questionaire">Umfragen</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
<MudStack Row="true" Spacing="10">
|
||||
<MudCard Style="width: 300px;">
|
||||
<MudCardMedia Image="images/office.png" Height="200" Style="width: 300px;" />
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h5">Office Anmeldung</MudText>
|
||||
<MudText Typo="Typo.body2">Hier kannst du dich für Office anmelden.</MudText>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/office/userhome">Office Anmeldung</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
@page "/office/userhome"
|
||||
@using Fluxor
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Wollmilchsau.Store.OfficeStore
|
||||
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
|
||||
|
||||
@inject IDispatcher Dispatcher
|
||||
@inject IState<OfficeState> OfficeState
|
||||
|
||||
@attribute [Authorize]
|
||||
<MudPaper Class="d-flex justify-center" Elevation="0">
|
||||
@if (OfficeState.Value?.HasLoaded.GetValueOrDefault(CurrentUser?.Username ?? string.Empty) == true)
|
||||
{
|
||||
<MudCard Style="width: 800px;">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Dein Office-Account</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
@if (GraphUser is not null)
|
||||
{
|
||||
<MudCardContent>
|
||||
<MudText>Dein Username: @GraphUser.UserPrincipalName</MudText>
|
||||
<MudText>Herunterladen unter: <MudLink Href="https://office.com">https://office.com</MudLink></MudText>
|
||||
@if (OfficeState.Value?.NewPasswords.GetValueOrDefault(CurrentUser?.Username ?? string.Empty) is string newPassword)
|
||||
{
|
||||
<MudText>Dein temporäres Passwort: <b>@newPassword</b></MudText>
|
||||
<MudAlert Severity="Severity.Warning" Elevation="0" Variant="Variant.Filled">
|
||||
Du wirst beim ersten Anmelden aufgefordert, dein Passwort zu ändern. Notiere unbedingt dieses neue Passwort!
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText>Wenn du dein Passwort nicht mehr kennst, kannst du es über den untenstehenden Button zurücksetzen.</MudText>
|
||||
}
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ResetPassword">Passwort zurücksetzen</MudButton>
|
||||
</MudCardActions>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudCardContent>
|
||||
<MudText>Derzeit existiert anscheinend kein Office-Account unter @CurrentUser?.Username@bs18.de</MudText>
|
||||
<MudText>Sollte hier ein Fehler vorliegen bitte an <MudLink Href="@MailtoContactEmail">@ContactEmail</MudLink> schreiben.</MudText>
|
||||
<MudText>Ansonsten kannst Du über den untenstehenden Button einen Account erstellen.</MudText>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="CreateAccount">Account erstellen</MudButton>
|
||||
</MudCardActions>
|
||||
}
|
||||
</MudCard>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudCard Style="max-width: 800px;">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudSkeleton Width="200px" Height="30px" />
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSkeleton Width="100%" Height="20px" Class="mb-2" />
|
||||
<MudSkeleton Width="100%" Height="20px" Class="mb-2" />
|
||||
<MudSkeleton Width="100%" Height="20px" Class="mb-2" />
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudSkeleton Width="150px" Height="36px" />
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
}
|
||||
</MudPaper>
|
||||
<MudPaper Class="d-flex justify-center" Elevation="0">
|
||||
<MudExpansionPanels MultiExpansion="true" Style="width: 800px;">
|
||||
<MudExpansionPanel Text="Informationen" Expanded>
|
||||
<MudText>Um das Office-Paket zu nutzen, bestehen mehrere Möglichkeiten:</MudText>
|
||||
<MudText>1. Nutzen der Webanwendungen über <MudLink Href="https://office.com" Target="_blank">https://office.com</MudLink></MudText>
|
||||
<MudText>2. Installieren der Officeanwendungen auf dem eigenen Gerät. Siehe Anleitungen unten.</MudText>
|
||||
</MudExpansionPanel>
|
||||
<MudExpansionPanel Text="Windows">
|
||||
<MudText>
|
||||
Sollte das Office-Paket nicht vorinstalliert sein, so lässt sich Office auch manuell installieren.
|
||||
Dafür bei <MudLink Href="https://m365.cloud.microsoft/apps">MS Office Website</MudLink> anmelden und rechts oben auf "Apps Installieren" clicken.
|
||||
Die erste Option "Microsoft 365 Apps" ist dann die richtige.
|
||||
</MudText>
|
||||
</MudExpansionPanel>
|
||||
<MudExpansionPanel Text="MacOS">
|
||||
<MudText>Für MacOS lässt sich das Officepaket direkt aus dem Mac App Store herunterladen:</MudText>
|
||||
<MudLink Href="https://apps.apple.com/de/app-bundle/microsoft-365/id1450038993?mt=12" Target="_blank">Microsoft 365 im Mac App Store</MudLink>
|
||||
<MudText>
|
||||
Alternativ lässt sich Office auch manuell installieren. Dafür bei <MudLink Href="https://m365.cloud.microsoft/apps">MS Office Website</MudLink> anmelden und rechts oben auf "Apps Installieren" clicken.
|
||||
Die erste Option "Microsoft 365 Apps" ist dann die richtige.
|
||||
</MudText>
|
||||
</MudExpansionPanel>
|
||||
<MudExpansionPanel Text="iPhone & iPad">
|
||||
<MudText>Um Office auf iPad und iPhone nutzen zu können, musst du jede Anwendung einzeln herunterladen.</MudText>
|
||||
<MudStack>
|
||||
<MudStack Row Class="d-flex align-center">
|
||||
<MudImage Src="images/wordqr.png" Width="200" />
|
||||
<MudLink Href="https://apps.apple.com/de/app/microsoft-word/id586447913" Target="_blank">Microsoft Word</MudLink>
|
||||
</MudStack>
|
||||
<MudStack Row Class="d-flex align-center">
|
||||
<MudImage Src="images/excelqr.png" Width="200" />
|
||||
<MudLink Href="https://apps.apple.com/de/app/microsoft-excel/id586683407" Target="_blank">Microsoft Excel</MudLink>
|
||||
</MudStack>
|
||||
<MudStack Row Class="d-flex align-center">
|
||||
<MudImage Src="images/powerpointqr.png" Width="200" />
|
||||
<MudLink Href="https://apps.apple.com/de/app/microsoft-powerpoint/id586449534" Target="_blank">Microsoft PowerPoint</MudLink>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudExpansionPanel>
|
||||
<MudExpansionPanel Text="Android / GooglePlayStore">
|
||||
<MudStack>
|
||||
<MudStack Row Class="d-flex align-center">
|
||||
<MudImage Src="images/googlewordqr.png" Width="200" />
|
||||
<MudLink Href="https://play.google.com/store/apps/details?id=com.microsoft.office.word&hl=de" Target="_blank">Microsoft Word</MudLink>
|
||||
</MudStack>
|
||||
<MudStack Row Class="d-flex align-center">
|
||||
<MudImage Src="images/googleexcelqr.png" Width="200" />
|
||||
<MudLink Href="https://play.google.com/store/apps/details?id=com.microsoft.office.excel&hl=de" Target="_blank">Microsoft Excel</MudLink>
|
||||
</MudStack>
|
||||
<MudStack Row Class="d-flex align-center">
|
||||
<MudImage Src="images/googlepptqr.png" Width="200" />
|
||||
<MudLink Href="https://play.google.com/store/apps/details?id=com.microsoft.office.powerpoint&hl=de" Target="_blank">Microsoft PowerPoint</MudLink>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
|
||||
AuthenticationState? _authenticationState;
|
||||
|
||||
Microsoft.Graph.Models.User? GraphUser => OfficeState.Value.GraphUsers.GetValueOrDefault(CurrentUser?.Username ?? string.Empty);
|
||||
|
||||
Models.LdapUser? CurrentUser;
|
||||
|
||||
string? CurrentUserName;
|
||||
string ContactEmail = "johannes.hass@bs18.de";
|
||||
string MailtoContactEmail => $"mailto:{ContactEmail}";
|
||||
|
||||
override protected async Task OnInitializedAsync()
|
||||
{
|
||||
if (AuthenticationStateTask is not null)
|
||||
{
|
||||
_authenticationState = await AuthenticationStateTask;
|
||||
CurrentUserName = _authenticationState.User.FindFirst("preferred_username")?.Value;
|
||||
CurrentUser = new Models.LdapUser(
|
||||
CurrentUserName ?? string.Empty,
|
||||
_authenticationState.User.FindFirst("family_name")?.Value ?? string.Empty,
|
||||
_authenticationState.User.FindFirst("classroom")?.Value ?? string.Empty,
|
||||
_authenticationState.User.FindFirst("given_name")?.Value ?? string.Empty,
|
||||
false,
|
||||
_authenticationState.User.IsInRole("ROLE_TEACHER") ? Models.LdapUserRole.Teacher : Models.LdapUserRole.Student
|
||||
);
|
||||
Dispatcher.Dispatch(new GetCurrentUserInfoAction(CurrentUserName));
|
||||
}
|
||||
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
private void CreateAccount(MouseEventArgs args)
|
||||
{
|
||||
CurrentUser = OfficeState.Value.LdapUsers.GetValueOrDefault(CurrentUserName ?? string.Empty);
|
||||
if (CurrentUser is null) return;
|
||||
Dispatcher.Dispatch(new CreateNewAccountAction(CurrentUser));
|
||||
}
|
||||
private void ResetPassword(MouseEventArgs args)
|
||||
{
|
||||
if (CurrentUserName is null || GraphUser is null || GraphUser.UserPrincipalName is null) return;
|
||||
Dispatcher.Dispatch(new SetNewPasswordInGraphAction(CurrentUserName, GraphUser.UserPrincipalName));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
@page "/students/questionaire"
|
||||
@using System.Security.Claims
|
||||
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject Fluxor.IDispatcher Dispatcher
|
||||
@@ -78,11 +77,11 @@
|
||||
|
||||
var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
var user = authenticationState.User;
|
||||
var studentId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user.FindFirst("sub")?.Value ?? user.Identity?.Name;
|
||||
var preferredUsername = user.FindFirst("preferred_username")?.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(studentId))
|
||||
if (!string.IsNullOrWhiteSpace(preferredUsername))
|
||||
{
|
||||
Dispatcher.Dispatch(new Wollmilchsau.Store.QuestionnaireStore.LoadQuestionnaire(studentId, user.FindFirst("classroom")?.Value));
|
||||
Dispatcher.Dispatch(new Wollmilchsau.Store.QuestionnaireStore.LoadQuestionnaire(preferredUsername, user.FindFirst("classroom")?.Value));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
# This stage is used when running from VS in fast mode (Default for Debug configuration)
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends ca-certificates libldap2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
USER $APP_UID
|
||||
WORKDIR /app
|
||||
ENV LDAPTLS_CACERT=/etc/ssl/certs/ca-certificates.crt
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Security.Cryptography;
|
||||
namespace Wollmilchsau.Helpers;
|
||||
|
||||
internal static class PasswordGenerator
|
||||
{
|
||||
// Avoid ambiguous: l, I, O, 0, 1 (and also exclude o for extra safety)
|
||||
private const string Lower = "abcdefghijkmnpqrstuvwxyz"; // no l, no o
|
||||
private const string Upper = "ABCDEFGHJKMNPQRSTUVWXYZ"; // no I, no O
|
||||
private const string Digits = "23456789"; // no 0, no 1
|
||||
private const string Specials = "#!?$";
|
||||
|
||||
internal static string Create(
|
||||
int length = 16,
|
||||
int minLower = 1,
|
||||
int minUpper = 1,
|
||||
int minDigits = 1,
|
||||
int minSpecial = 1)
|
||||
{
|
||||
if (length < minLower + minUpper + minDigits + minSpecial)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(length), "Length too short for required character counts.");
|
||||
}
|
||||
|
||||
Span<char> buffer = length <= 256 ? stackalloc char[length] : new char[length];
|
||||
var i = 0;
|
||||
|
||||
Fill(buffer, ref i, Lower, minLower);
|
||||
Fill(buffer, ref i, Upper, minUpper);
|
||||
Fill(buffer, ref i, Digits, minDigits);
|
||||
Fill(buffer, ref i, Specials, minSpecial);
|
||||
|
||||
var all = Lower + Upper + Digits + Specials;
|
||||
Fill(buffer, ref i, all, length - i);
|
||||
|
||||
Shuffle(buffer);
|
||||
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
private static void Fill(Span<char> target, ref int index, string alphabet, int count)
|
||||
{
|
||||
for (var c = 0; c < count; c++)
|
||||
{
|
||||
target[index++] = alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)];
|
||||
}
|
||||
}
|
||||
|
||||
private static void Shuffle(Span<char> chars)
|
||||
{
|
||||
for (var n = chars.Length - 1; n > 0; n--)
|
||||
{
|
||||
var k = RandomNumberGenerator.GetInt32(n + 1);
|
||||
(chars[n], chars[k]) = (chars[k], chars[n]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,25 @@
|
||||
|
||||
public sealed record MoodleUser
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string? Username { get; init; }
|
||||
public string? Firstname { get; init; }
|
||||
public string? Lastname { get; init; }
|
||||
public string? Fullname { get; init; }
|
||||
public string? Initials { get; init; }
|
||||
public string? Department { get; init; }
|
||||
public long Firstaccess { get; init; }
|
||||
public long Lastaccess { get; init; }
|
||||
public string? Auth { get; init; }
|
||||
public bool Suspended { get; init; }
|
||||
public bool Confirmed { get; init; }
|
||||
public string? Lang { get; init; }
|
||||
public string? Theme { get; init; }
|
||||
public string? Timezone { get; init; }
|
||||
public int Mailformat { get; init; }
|
||||
public int Trackforums { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public int Descriptionformat { get; init; }
|
||||
public string? Profileimageurlsmall { get; init; }
|
||||
public string? Profileimageurl { get; init; }
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -12,6 +12,8 @@ using System.Text.Json;
|
||||
using Wollmilchsau.Components;
|
||||
using Wollmilchsau.Exceptions;
|
||||
using Wollmilchsau.Services.Ldap;
|
||||
using Wollmilchsau.Services.Moodle;
|
||||
using Wollmilchsau.Services.Office;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -90,6 +92,20 @@ builder.Services.AddOptions<LdapOptions>()
|
||||
.ValidateOnStart();
|
||||
builder.Services.AddScoped<ILdapService, LdapService>();
|
||||
|
||||
builder.Services.AddOptions<MoodleOptions>()
|
||||
.BindConfiguration("Moodle")
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton<IOfficeService, OfficeService>();
|
||||
|
||||
builder.Services.AddHttpClient<IMoodleService, MoodleService>((serviceProvider, client) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<MoodleOptions>>().Value;
|
||||
client.BaseAddress = new Uri(options.BaseUrl, UriKind.Absolute);
|
||||
client.Timeout = TimeSpan.FromMinutes(2);
|
||||
});
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
@@ -6,6 +6,7 @@ public interface ILdapService
|
||||
{
|
||||
Task<int> GetUserCount(CancellationToken cancellationToken = default);
|
||||
IAsyncEnumerable<LdapUser> GetAllUsers(CancellationToken cancellationToken = default);
|
||||
Task<LdapUser?> GetUser(string uid, CancellationToken cancellationToken = default);
|
||||
Task<int> GetClassroomCount(CancellationToken cancellationToken = default);
|
||||
IAsyncEnumerable<Classroom> GetAllClassrooms(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,30 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
|
||||
}
|
||||
}
|
||||
|
||||
public Task<LdapUser?> GetUser(string uid, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(uid);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
using var connection = CreateConnection();
|
||||
var studentRoleDn = BuildDistinguishedName(_options.StudentsRole, _options.RoleBase);
|
||||
var teacherRoleDn = BuildDistinguishedName(_options.TeacherRole, _options.RoleBase);
|
||||
var request = new SearchRequest(
|
||||
BuildDistinguishedName(_options.UserBase),
|
||||
$"(&(uid={EscapeFilterValue(uid)}){GetSupportedRolesFilter(studentRoleDn, teacherRoleDn)})",
|
||||
SearchScope.Subtree,
|
||||
"uid",
|
||||
"sn",
|
||||
"givenName",
|
||||
"memberOf");
|
||||
var response = (SearchResponse)connection.SendRequest(request);
|
||||
|
||||
return Task.FromResult(response.Entries
|
||||
.Cast<SearchResultEntry>()
|
||||
.Select(entry => CreateUser(entry, studentRoleDn, teacherRoleDn))
|
||||
.SingleOrDefault());
|
||||
}
|
||||
|
||||
public Task<int> GetClassroomCount(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -90,9 +114,14 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
|
||||
var connection = new LdapConnection(new LdapDirectoryIdentifier(ldapUri.Host, ldapUri.Port))
|
||||
{
|
||||
AuthType = AuthType.Basic,
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
SessionOptions =
|
||||
{
|
||||
SecureSocketLayer = true
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
connection.SessionOptions.ProtocolVersion = 3;
|
||||
connection.SessionOptions.SecureSocketLayer = string.Equals(ldapUri.Scheme, "ldaps", StringComparison.OrdinalIgnoreCase);
|
||||
connection.Bind(new NetworkCredential(_options.BindDN, _options.BindPassword));
|
||||
@@ -110,7 +139,6 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
|
||||
SearchScope.Subtree,
|
||||
"uid",
|
||||
"sn",
|
||||
"departmentNumber",
|
||||
"givenName",
|
||||
"memberOf");
|
||||
var response = (SearchResponse)connection.SendRequest(request);
|
||||
@@ -124,7 +152,7 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
|
||||
{
|
||||
var username = GetFirstAttributeValue(entry, "uid") ?? throw new InvalidOperationException($"LDAP user entry '{entry.DistinguishedName}' is missing uid.");
|
||||
var surname = CapitalizeFirstLetter(GetFirstAttributeValue(entry, "sn"));
|
||||
var classroom = GetFirstAttributeValue(entry, "departmentNumber");
|
||||
var classroom = GetClassroomFromMembership(entry);
|
||||
var givenName = GetFirstAttributeValue(entry, "givenName");
|
||||
var role = DetermineUserRole(entry, studentRoleDn, teacherRoleDn);
|
||||
|
||||
@@ -139,6 +167,17 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
|
||||
role);
|
||||
}
|
||||
|
||||
private static string? GetClassroomFromMembership(SearchResultEntry entry)
|
||||
{
|
||||
const string classroomGroupPrefix = "CN=klasse.";
|
||||
|
||||
return GetAttributeValues(entry, "memberOf")
|
||||
.Select(groupDn => groupDn.Split(',', 2)[0])
|
||||
.Where(commonName => commonName.StartsWith(classroomGroupPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(commonName => commonName[classroomGroupPrefix.Length..].ToUpperInvariant())
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static LdapUserRole DetermineUserRole(SearchResultEntry entry, string studentRoleDn, string teacherRoleDn)
|
||||
{
|
||||
var roles = GetAttributeValues(entry, "memberOf");
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using Wollmilchsau.Models;
|
||||
|
||||
namespace Wollmilchsau.Services.Moodle;
|
||||
|
||||
public interface IMoodleService
|
||||
{
|
||||
Task<IReadOnlyCollection<MoodleUser>> GetAllUsers(CancellationToken cancellationToken = default);
|
||||
Task DeleteUsers(IReadOnlyCollection<int> userIds, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Wollmilchsau.Services.Moodle;
|
||||
|
||||
public sealed class MoodleOptions
|
||||
{
|
||||
[Required]
|
||||
[Url]
|
||||
public string BaseUrl { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string WsToken { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.AspNetCore.Http.Extensions;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Wollmilchsau.Models;
|
||||
|
||||
namespace Wollmilchsau.Services.Moodle;
|
||||
|
||||
public sealed class MoodleService(HttpClient httpClient, IOptions<MoodleOptions> options) : IMoodleService
|
||||
{
|
||||
private readonly MoodleOptions _options = options.Value;
|
||||
|
||||
public async Task<IReadOnlyCollection<MoodleUser>> GetAllUsers(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var requestUri = QueryHelpers.AddQueryString(
|
||||
string.Empty,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["wstoken"] = _options.WsToken,
|
||||
["wsfunction"] = "core_user_get_users",
|
||||
["moodlewsrestformat"] = "json",
|
||||
["criteria[0][key]"] = "lastname",
|
||||
["criteria[0][value]"] = "%"
|
||||
});
|
||||
|
||||
using var response = await httpClient.GetAsync(requestUri, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<MoodleUserSearchResponse>(cancellationToken);
|
||||
if (result is null)
|
||||
{
|
||||
throw new InvalidOperationException("Moodle returned an empty user search response.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(result.Exception))
|
||||
{
|
||||
throw new InvalidOperationException($"Moodle user search failed with {result.ErrorCode ?? "an unspecified error"}: {result.Message ?? result.Exception}.");
|
||||
}
|
||||
|
||||
return result.Users ?? throw new InvalidOperationException("Moodle user search response did not contain users.");
|
||||
}
|
||||
|
||||
public async Task DeleteUsers(IReadOnlyCollection<int> userIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (userIds.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one Moodle user ID is required.", nameof(userIds));
|
||||
}
|
||||
|
||||
var query = new QueryBuilder
|
||||
{
|
||||
{ "wstoken", _options.WsToken },
|
||||
{ "wsfunction", "core_user_delete_users" },
|
||||
{ "moodlewsrestformat", "json" }
|
||||
};
|
||||
|
||||
foreach (var (userId, index) in userIds.Distinct().Select((userId, index) => (userId, index)))
|
||||
{
|
||||
query.Add($"userids[{index}]", userId.ToString());
|
||||
}
|
||||
|
||||
using var response = await httpClient.GetAsync(query.ToQueryString().Value, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<MoodleDeletionResponse>(cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(result?.Exception))
|
||||
{
|
||||
throw new InvalidOperationException($"Moodle user deletion failed with {result.ErrorCode ?? "an unspecified error"}: {result.Message ?? result.Exception}.");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record MoodleUserSearchResponse(
|
||||
IReadOnlyCollection<MoodleUser>? Users,
|
||||
string? Exception,
|
||||
string? ErrorCode,
|
||||
string? Message);
|
||||
|
||||
private sealed record MoodleDeletionResponse(
|
||||
string? Exception,
|
||||
string? ErrorCode,
|
||||
string? Message);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Kiota.Abstractions;
|
||||
using Microsoft.Graph;
|
||||
using Microsoft.Graph.Models;
|
||||
|
||||
namespace Wollmilchsau.Services.Office;
|
||||
|
||||
public interface IOfficeService
|
||||
{
|
||||
IAsyncEnumerable<User> GetAllUsersAsync(CancellationToken cancellationToken = default);
|
||||
Task<User?> GetUserByMailAsync(string mailAddress, CancellationToken cancellationToken = default);
|
||||
Task<User?> GetUserByAliasesAsync(string username, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteUserByIdAsync(string userId, CancellationToken cancellationToken = default);
|
||||
Task<User> CreateUserAsync(
|
||||
string displayName,
|
||||
string userPrincipalName,
|
||||
string mailNickname,
|
||||
string initialPassword,
|
||||
string givenName,
|
||||
string surname,
|
||||
string? department = null,
|
||||
string? jobTitle = null,
|
||||
string usageLocation = "DE",
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<bool> ChangeUserPasswordAsync(
|
||||
string userId,
|
||||
string newPassword,
|
||||
bool forceChangePasswordNextSignIn = true,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class OfficeService : IOfficeService
|
||||
{
|
||||
private static readonly string[] Scopes = ["https://graph.microsoft.com/.default"];
|
||||
private GraphServiceClient _client { get; set; }
|
||||
public OfficeService(IConfiguration configuration)
|
||||
{
|
||||
var tenantId = configuration["MSGraph:TenantId"]!;
|
||||
var clientId = configuration["MSGraph:ClientId"]!;
|
||||
var clientSecret = configuration["MSGraph:ClientSecret"]!;
|
||||
|
||||
TokenCredential credential = new ClientSecretCredential(
|
||||
tenantId: tenantId,
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret);
|
||||
|
||||
_client = new GraphServiceClient(credential, Scopes);
|
||||
}
|
||||
public async IAsyncEnumerable<User> GetAllUsersAsync([System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var page = await _client.Users.GetAsync(requestConfiguration =>
|
||||
{
|
||||
requestConfiguration.QueryParameters.Select = ["id", "displayName", "userPrincipalName", "mail", "department", "jobTitle", "proxyAddresses"];
|
||||
requestConfiguration.QueryParameters.Top = 999;
|
||||
}, cancellationToken);
|
||||
|
||||
while (page?.Value is { Count: > 0 } value)
|
||||
{
|
||||
foreach (var user in value)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
yield return user;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(page.OdataNextLink))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
page = await _client.Users
|
||||
.WithUrl(page.OdataNextLink)
|
||||
.GetAsync(cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<User?> GetUserByMailAsync(string mailAddress, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mailAddress))
|
||||
{
|
||||
throw new ArgumentException("Mail address must be provided.", nameof(mailAddress));
|
||||
}
|
||||
|
||||
// Use advanced query capabilities for filtering.
|
||||
// Note: Graph user filtering is case-insensitive for many string properties, but callers should not rely on it.
|
||||
var response = await _client.Users.GetAsync(requestConfiguration =>
|
||||
{
|
||||
requestConfiguration.Headers.Add("ConsistencyLevel", "eventual");
|
||||
requestConfiguration.QueryParameters.Count = true;
|
||||
requestConfiguration.QueryParameters.Top = 1;
|
||||
requestConfiguration.QueryParameters.Select = [
|
||||
"id",
|
||||
"displayName",
|
||||
"userPrincipalName",
|
||||
"mail",
|
||||
"department",
|
||||
"jobTitle",
|
||||
"proxyAddresses"
|
||||
];
|
||||
requestConfiguration.QueryParameters.Filter = $"mail eq '{mailAddress.Replace("'", "''")}'";
|
||||
}, cancellationToken);
|
||||
|
||||
return response?.Value?.FirstOrDefault();
|
||||
}
|
||||
public async Task<User?> GetUserByAliasesAsync(string userid, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userid))
|
||||
{
|
||||
throw new ArgumentException("Mail address must be provided.", nameof(userid));
|
||||
}
|
||||
|
||||
// Use advanced query capabilities for filtering.
|
||||
// Note: Graph user filtering is case-insensitive for many string properties, but callers should not rely on it.
|
||||
var response = await _client.Users.GetAsync(requestConfiguration =>
|
||||
{
|
||||
requestConfiguration.Headers.Add("ConsistencyLevel", "eventual");
|
||||
requestConfiguration.QueryParameters.Count = true;
|
||||
requestConfiguration.QueryParameters.Top = 1;
|
||||
requestConfiguration.QueryParameters.Select = [
|
||||
"id",
|
||||
"displayName",
|
||||
"userPrincipalName",
|
||||
"mail",
|
||||
"department",
|
||||
"jobTitle",
|
||||
"proxyAddresses"
|
||||
];
|
||||
requestConfiguration.QueryParameters.Filter = $"proxyAddresses/any(x:startswith(x,'smtp:{userid}@')) or proxyAddresses/any(x:startswith(x,'SMTP:{userid}@'))";
|
||||
}, cancellationToken);
|
||||
|
||||
return response?.Value?.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteUserByIdAsync(string userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
throw new ArgumentException("User id must be provided.", nameof(userId));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _client.Users[userId].DeleteAsync(cancellationToken: cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (ApiException ex) when (ex.ResponseStatusCode == (int)System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public async Task<User> CreateUserAsync(
|
||||
string displayName,
|
||||
string userPrincipalName,
|
||||
string mailNickname,
|
||||
string initialPassword,
|
||||
string givenName,
|
||||
string surname,
|
||||
string? department = null,
|
||||
string? jobTitle = null,
|
||||
string usageLocation = "DE",
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
throw new ArgumentException("Display name must be provided.", nameof(displayName));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userPrincipalName))
|
||||
{
|
||||
throw new ArgumentException("User principal name must be provided.", nameof(userPrincipalName));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(mailNickname))
|
||||
{
|
||||
throw new ArgumentException("Mail nickname must be provided.", nameof(mailNickname));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(initialPassword))
|
||||
{
|
||||
throw new ArgumentException("Initial password must be provided.", nameof(initialPassword));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(usageLocation))
|
||||
{
|
||||
throw new ArgumentException("Usage location must be provided.", nameof(usageLocation));
|
||||
}
|
||||
|
||||
var user = new User
|
||||
{
|
||||
AccountEnabled = true,
|
||||
DisplayName = displayName,
|
||||
Department = department,
|
||||
JobTitle = jobTitle,
|
||||
MailNickname = mailNickname,
|
||||
UserPrincipalName = userPrincipalName,
|
||||
UsageLocation = usageLocation,
|
||||
GivenName = givenName,
|
||||
Surname = surname,
|
||||
PasswordProfile = new PasswordProfile
|
||||
{
|
||||
Password = initialPassword,
|
||||
ForceChangePasswordNextSignIn = true
|
||||
}
|
||||
};
|
||||
|
||||
return await _client.Users.PostAsync(user, cancellationToken: cancellationToken)
|
||||
?? throw new InvalidOperationException("Microsoft Graph returned no user after creation.");
|
||||
}
|
||||
|
||||
public async Task<bool> ChangeUserPasswordAsync(
|
||||
string userId,
|
||||
string newPassword,
|
||||
bool forceChangePasswordNextSignIn = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
throw new ArgumentException("User id must be provided.", nameof(userId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newPassword))
|
||||
{
|
||||
throw new ArgumentException("New password must be provided.", nameof(newPassword));
|
||||
}
|
||||
|
||||
var userUpdate = new User
|
||||
{
|
||||
PasswordProfile = new PasswordProfile
|
||||
{
|
||||
Password = newPassword,
|
||||
ForceChangePasswordNextSignIn = forceChangePasswordNextSignIn
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _client.Users[userId].PatchAsync(userUpdate, cancellationToken: cancellationToken);
|
||||
return true;
|
||||
}
|
||||
catch (ApiException ex) when (ex.ResponseStatusCode == (int)System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,26 @@
|
||||
namespace Wollmilchsau.Store.AdminStore;
|
||||
|
||||
public sealed record GetAllMoodleUsers();
|
||||
public sealed record RefreshMoodleUsers();
|
||||
public sealed record SetMoodleUsers(IReadOnlyCollection<MoodleUser> Users);
|
||||
public sealed record SetMoodleUsersLoadFailed();
|
||||
public sealed record DeleteMoodleUsers(IReadOnlyCollection<int> UserIds);
|
||||
public sealed record MoodleUsersDeletionStarted(int TotalUsers);
|
||||
public sealed record MoodleUsersDeleted(IReadOnlyCollection<int> UserIds);
|
||||
public sealed record MoodleUsersDeletionFailed();
|
||||
public sealed record MoodleUsersDeletionCompleted();
|
||||
public sealed record GetAllElementUsers();
|
||||
public sealed record GetAllLdapUsers();
|
||||
public sealed record RefreshLdapUsers();
|
||||
public sealed record SetLdapUsers(IReadOnlyCollection<LdapUser> Users);
|
||||
public sealed record GetAllClassrooms();
|
||||
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();
|
||||
|
||||
@@ -1,15 +1,249 @@
|
||||
using System.Text.Json;
|
||||
using Fluxor;
|
||||
using Microsoft.Extensions.Caching.Distributed;
|
||||
using MudBlazor;
|
||||
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(ILdapService ldapService, IDistributedCache cache)
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cachedUsers = await cache.GetStringAsync(MoodleUsersCacheKey);
|
||||
IReadOnlyCollection<MoodleUser> users;
|
||||
|
||||
if (cachedUsers is not null)
|
||||
{
|
||||
var deserializedUsers = JsonSerializer.Deserialize<MoodleUser[]>(cachedUsers);
|
||||
if (deserializedUsers is not null)
|
||||
{
|
||||
users = deserializedUsers;
|
||||
}
|
||||
else
|
||||
{
|
||||
await cache.RemoveAsync(MoodleUsersCacheKey);
|
||||
users = await LoadAndCacheMoodleUsers();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
users = await LoadAndCacheMoodleUsers();
|
||||
}
|
||||
|
||||
dispatcher.Dispatch(new SetMoodleUsers(users));
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
dispatcher.Dispatch(new SetMoodleUsersLoadFailed());
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
dispatcher.Dispatch(new SetMoodleUsersLoadFailed());
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
dispatcher.Dispatch(new SetMoodleUsersLoadFailed());
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
dispatcher.Dispatch(new SetMoodleUsersLoadFailed());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<MoodleUser>> LoadAndCacheMoodleUsers()
|
||||
{
|
||||
var users = await moodleService.GetAllUsers();
|
||||
|
||||
await cache.SetStringAsync(
|
||||
MoodleUsersCacheKey,
|
||||
JsonSerializer.Serialize(users),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = TimeSpan.FromMinutes(10)
|
||||
});
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
[EffectMethod]
|
||||
public async Task HandleRefreshMoodleUsers(RefreshMoodleUsers action, IDispatcher dispatcher)
|
||||
{
|
||||
try
|
||||
{
|
||||
await cache.RemoveAsync(MoodleUsersCacheKey);
|
||||
dispatcher.Dispatch(new SetMoodleUsers(await LoadAndCacheMoodleUsers()));
|
||||
snackbar.Add("Moodle users refreshed.", Severity.Success);
|
||||
}
|
||||
catch (RedisException)
|
||||
{
|
||||
snackbar.Add("Moodle users could not be refreshed because the cache could not be cleared.", Severity.Error);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
snackbar.Add("Moodle users could not be refreshed.", Severity.Error);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
snackbar.Add("Moodle users could not be refreshed.", Severity.Error);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
snackbar.Add("Moodle users could not be refreshed.", Severity.Error);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
snackbar.Add("Moodle user refresh timed out.", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
[EffectMethod]
|
||||
public async Task HandleDeleteMoodleUsers(DeleteMoodleUsers action, IDispatcher dispatcher)
|
||||
{
|
||||
var batches = action.UserIds
|
||||
.Distinct()
|
||||
.Chunk(MoodleDeleteBatchSize)
|
||||
.ToArray();
|
||||
|
||||
if (batches.Length == 0)
|
||||
{
|
||||
snackbar.Add("No Moodle users were selected for deletion.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var totalUserCount = batches.Sum(batch => batch.Length);
|
||||
dispatcher.Dispatch(new MoodleUsersDeletionStarted(totalUserCount));
|
||||
|
||||
try
|
||||
{
|
||||
await cache.RemoveAsync(MoodleUsersCacheKey);
|
||||
}
|
||||
catch (RedisException exception)
|
||||
{
|
||||
dispatcher.Dispatch(new MoodleUsersDeletionFailed());
|
||||
snackbar.Add($"Moodle 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 TryDeleteMoodleUserBatch(batch, deletedUserCount, totalUserCount, dispatcher))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcher.Dispatch(new MoodleUsersDeleted(batch));
|
||||
deletedUserCount += batch.Length;
|
||||
}
|
||||
|
||||
dispatcher.Dispatch(new MoodleUsersDeletionCompleted());
|
||||
snackbar.Add($"Deleted {action.UserIds.Distinct().Count()} Moodle user(s).", Severity.Success);
|
||||
}
|
||||
|
||||
private async Task<bool> TryDeleteMoodleUserBatch(
|
||||
IReadOnlyCollection<int> userIds,
|
||||
int deletedUserCount,
|
||||
int totalUserCount,
|
||||
IDispatcher dispatcher)
|
||||
{
|
||||
try
|
||||
{
|
||||
await moodleService.DeleteUsers(userIds);
|
||||
return true;
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
await ReportMoodleDeletionFailure(dispatcher, exception, userIds, deletedUserCount, totalUserCount);
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
await ReportMoodleDeletionFailure(dispatcher, exception, userIds, deletedUserCount, totalUserCount);
|
||||
}
|
||||
catch (TaskCanceledException exception)
|
||||
{
|
||||
await ReportMoodleDeletionFailure(dispatcher, exception, userIds, deletedUserCount, totalUserCount);
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
await ReportMoodleDeletionFailure(dispatcher, exception, userIds, deletedUserCount, totalUserCount);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task ReportMoodleDeletionFailure(
|
||||
IDispatcher dispatcher,
|
||||
Exception exception,
|
||||
IReadOnlyCollection<int> failedUserIds,
|
||||
int deletedUserCount,
|
||||
int totalUserCount)
|
||||
{
|
||||
dispatcher.Dispatch(new MoodleUsersDeletionFailed());
|
||||
|
||||
var reconciliationMessage = await ReconcileMoodleUsersAfterDeletionFailure(dispatcher, failedUserIds);
|
||||
snackbar.Add(
|
||||
$"Moodle deletion stopped after confirming {deletedUserCount} of {totalUserCount} user(s). Reason: {exception.Message}.{reconciliationMessage}",
|
||||
Severity.Error);
|
||||
}
|
||||
|
||||
private async Task<string> ReconcileMoodleUsersAfterDeletionFailure(
|
||||
IDispatcher dispatcher,
|
||||
IReadOnlyCollection<int> failedUserIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
var users = await moodleService.GetAllUsers();
|
||||
dispatcher.Dispatch(new SetMoodleUsers(users));
|
||||
|
||||
try
|
||||
{
|
||||
await cache.SetStringAsync(
|
||||
MoodleUsersCacheKey,
|
||||
JsonSerializer.Serialize(users),
|
||||
new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(10) });
|
||||
}
|
||||
catch (RedisException exception)
|
||||
{
|
||||
return $" The user list was refreshed from Moodle, but Redis could not be updated: {exception.Message}";
|
||||
}
|
||||
|
||||
var deletedFromFailedRequest = failedUserIds.Count(userId => !users.Any(user => user.Id == userId));
|
||||
return deletedFromFailedRequest == 0
|
||||
? " The user list was refreshed from Moodle."
|
||||
: $" The user list was refreshed from Moodle; {deletedFromFailedRequest} user(s) from the failed request were deleted server-side.";
|
||||
}
|
||||
catch (HttpRequestException 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}";
|
||||
}
|
||||
}
|
||||
|
||||
[EffectMethod]
|
||||
public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher)
|
||||
@@ -58,6 +292,30 @@ public sealed class AdminEffects(ILdapService ldapService, IDistributedCache cac
|
||||
return loadedUsers;
|
||||
}
|
||||
|
||||
[EffectMethod]
|
||||
public async Task HandleRefreshLdapUsers(RefreshLdapUsers action, IDispatcher dispatcher)
|
||||
{
|
||||
try
|
||||
{
|
||||
await cache.RemoveAsync(LdapUsersCacheKey);
|
||||
var users = await LoadAndCacheLdapUsers(await ldapService.GetUserCount());
|
||||
dispatcher.Dispatch(new SetLdapUsers(users));
|
||||
snackbar.Add("LDAP users refreshed.", Severity.Success);
|
||||
}
|
||||
catch (RedisException)
|
||||
{
|
||||
snackbar.Add("LDAP users could not be refreshed because the cache could not be cleared.", Severity.Error);
|
||||
}
|
||||
catch (System.DirectoryServices.Protocols.LdapException)
|
||||
{
|
||||
snackbar.Add("LDAP users could not be refreshed.", Severity.Error);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
snackbar.Add("LDAP users could not be refreshed.", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
[EffectMethod]
|
||||
public async Task HandleGetAllClassrooms(GetAllClassrooms action, IDispatcher dispatcher)
|
||||
{
|
||||
@@ -104,4 +362,236 @@ public sealed class AdminEffects(ILdapService ldapService, IDistributedCache cac
|
||||
|
||||
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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,69 @@ namespace Wollmilchsau.Store.AdminStore;
|
||||
|
||||
public static class AdminReducers
|
||||
{
|
||||
[ReducerMethod]
|
||||
public static AdminState ReduceSetMoodleUsers(AdminState state, SetMoodleUsers action)
|
||||
{
|
||||
return state with
|
||||
{
|
||||
MoodleUsers = action.Users.ToImmutableDictionary(user => user.Id),
|
||||
MoodleUsersLoaded = true,
|
||||
MoodleUsersLoadFailed = false,
|
||||
MoodleUsersDeletionInProgress = false,
|
||||
MoodleUsersDeletedCount = 0,
|
||||
MoodleUsersDeletionTotalCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
[ReducerMethod]
|
||||
public static AdminState ReduceSetMoodleUsersLoadFailed(AdminState state, SetMoodleUsersLoadFailed action)
|
||||
{
|
||||
return state with
|
||||
{
|
||||
MoodleUsers = [],
|
||||
MoodleUsersLoaded = true,
|
||||
MoodleUsersLoadFailed = true
|
||||
};
|
||||
}
|
||||
|
||||
[ReducerMethod]
|
||||
public static AdminState ReduceMoodleUsersDeletionStarted(AdminState state, MoodleUsersDeletionStarted action)
|
||||
{
|
||||
return state with
|
||||
{
|
||||
MoodleUsersDeletionInProgress = true,
|
||||
MoodleUsersDeletedCount = 0,
|
||||
MoodleUsersDeletionTotalCount = action.TotalUsers
|
||||
};
|
||||
}
|
||||
|
||||
[ReducerMethod]
|
||||
public static AdminState ReduceMoodleUsersDeleted(AdminState state, MoodleUsersDeleted action)
|
||||
{
|
||||
return state with
|
||||
{
|
||||
MoodleUsers = state.MoodleUsers.RemoveRange(action.UserIds),
|
||||
MoodleUsersDeletedCount = state.MoodleUsersDeletedCount + action.UserIds.Count
|
||||
};
|
||||
}
|
||||
|
||||
[ReducerMethod]
|
||||
public static AdminState ReduceMoodleUsersDeletionFailed(AdminState state, MoodleUsersDeletionFailed action)
|
||||
{
|
||||
return state with { MoodleUsersDeletionInProgress = false };
|
||||
}
|
||||
|
||||
[ReducerMethod]
|
||||
public static AdminState ReduceMoodleUsersDeletionCompleted(AdminState state, MoodleUsersDeletionCompleted action)
|
||||
{
|
||||
return state with
|
||||
{
|
||||
MoodleUsersDeletionInProgress = false,
|
||||
MoodleUsersDeletedCount = 0,
|
||||
MoodleUsersDeletionTotalCount = 0
|
||||
};
|
||||
}
|
||||
|
||||
[ReducerMethod]
|
||||
public static AdminState ReduceSetLdapUsers(AdminState state, SetLdapUsers action)
|
||||
{
|
||||
@@ -25,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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,19 @@ public sealed record AdminState
|
||||
{
|
||||
public AdminState() { }
|
||||
public ImmutableDictionary<Guid, ElementUser> ElementUsers { get; init; } = [];
|
||||
public ImmutableDictionary<Guid, MoodleUser> MoodleUsers { get; init; } = [];
|
||||
public ImmutableDictionary<int, MoodleUser> MoodleUsers { get; init; } = [];
|
||||
public bool MoodleUsersLoaded { get; init; }
|
||||
public bool MoodleUsersLoadFailed { get; init; }
|
||||
public bool MoodleUsersDeletionInProgress { get; init; }
|
||||
public int MoodleUsersDeletedCount { get; init; }
|
||||
public int MoodleUsersDeletionTotalCount { get; init; }
|
||||
public ImmutableDictionary<string, LdapUser> LdapUsers { get; init; } = [];
|
||||
public ImmutableList<Classroom> Classrooms { 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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using Microsoft.Graph.Models;
|
||||
|
||||
namespace Wollmilchsau.Store.OfficeStore;
|
||||
|
||||
public sealed record GetCurrentUserInfoAction(string? UserId);
|
||||
public sealed record UpdateCurrentUserInfoWithResultAction(string UserId, User? GraphUser, Models.LdapUser? LdapUser);
|
||||
public sealed record CreateNewAccountAction(Models.LdapUser NewUser);
|
||||
public sealed record SetNewlyCreatedGraphUserAction(string UserId, User GraphUser, string Password);
|
||||
public sealed record SetNewPasswordInGraphAction(string UserId, string UserPrincipalName);
|
||||
public sealed record SetNewPasswordInStoreAction(string UserId, string NewPassword);
|
||||
@@ -0,0 +1,51 @@
|
||||
using Fluxor;
|
||||
using Wollmilchsau.Helpers;
|
||||
using Wollmilchsau.Services;
|
||||
using Wollmilchsau.Services.Ldap;
|
||||
using Wollmilchsau.Services.Office;
|
||||
|
||||
namespace Wollmilchsau.Store.OfficeStore;
|
||||
|
||||
public sealed class OfficeEffects(IOfficeService officeService, ILdapService ldapService)
|
||||
{
|
||||
[EffectMethod]
|
||||
public async Task HandleGetCurrentUserInfoAction(GetCurrentUserInfoAction action, IDispatcher dispatcher)
|
||||
{
|
||||
if (action.UserId is null) return;
|
||||
var graphUser = await officeService.GetUserByAliasesAsync(action.UserId);
|
||||
var ldaUser = await ldapService.GetUser(action.UserId);
|
||||
dispatcher.Dispatch(new UpdateCurrentUserInfoWithResultAction(action.UserId, graphUser, ldaUser));
|
||||
}
|
||||
[EffectMethod]
|
||||
public async Task HandleCreateNewAccountAction(CreateNewAccountAction action, IDispatcher dispatcher)
|
||||
{
|
||||
var initialPassword = PasswordGenerator.Create(length: 10);
|
||||
var createdUser = await officeService.CreateUserAsync(
|
||||
displayName: $"{action.NewUser.GivenName} {action.NewUser.Surname}",
|
||||
userPrincipalName: $"{action.NewUser.Username}@bs18.de",
|
||||
mailNickname: action.NewUser.Username,
|
||||
initialPassword: initialPassword,
|
||||
givenName: action.NewUser.GivenName ?? string.Empty,
|
||||
surname: action.NewUser.Surname ?? string.Empty,
|
||||
department: action.NewUser.Role == Models.LdapUserRole.Teacher ? "Lehrer" : "Schüler",
|
||||
jobTitle: action.NewUser.Classroom ?? "n/a"
|
||||
);
|
||||
|
||||
dispatcher.Dispatch(new SetNewlyCreatedGraphUserAction(
|
||||
action.NewUser.Username,
|
||||
createdUser,
|
||||
initialPassword));
|
||||
}
|
||||
[EffectMethod]
|
||||
public async Task HandleSetNewPasswordInGraphAction(SetNewPasswordInGraphAction action, IDispatcher dispatcher)
|
||||
{
|
||||
var initialPassword = PasswordGenerator.Create(length: 10);
|
||||
var success = await officeService.ChangeUserPasswordAsync(
|
||||
userId: action.UserPrincipalName, newPassword: initialPassword);
|
||||
|
||||
if (success)
|
||||
{
|
||||
dispatcher.Dispatch(new SetNewPasswordInStoreAction(action.UserId, initialPassword));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Fluxor;
|
||||
|
||||
namespace Wollmilchsau.Store.OfficeStore;
|
||||
|
||||
public static class OfficeReducers
|
||||
{
|
||||
[ReducerMethod]
|
||||
public static OfficeState ReduceUpdateCurrentUserInfoWithResultAction(OfficeState state, UpdateCurrentUserInfoWithResultAction action)
|
||||
{
|
||||
var ldapUsers = state.LdapUsers;
|
||||
var graphUsers = state.GraphUsers;
|
||||
var hasLoaded = state.HasLoaded;
|
||||
if (action.LdapUser is not null)
|
||||
{
|
||||
ldapUsers = ldapUsers.SetItem(action.UserId, action.LdapUser);
|
||||
}
|
||||
if (action.GraphUser is not null)
|
||||
{
|
||||
graphUsers = graphUsers.SetItem(action.UserId, action.GraphUser);
|
||||
}
|
||||
return state with
|
||||
{
|
||||
LdapUsers = ldapUsers,
|
||||
GraphUsers = graphUsers,
|
||||
HasLoaded = hasLoaded.SetItem(action.UserId, true)
|
||||
};
|
||||
}
|
||||
[ReducerMethod]
|
||||
public static OfficeState ReduceSetNewPasswordInStoreAction(OfficeState state, SetNewPasswordInStoreAction action)
|
||||
{
|
||||
var newPasswords = state.NewPasswords;
|
||||
newPasswords = newPasswords.SetItem(action.UserId, action.NewPassword);
|
||||
return state with
|
||||
{
|
||||
NewPasswords = newPasswords
|
||||
};
|
||||
}
|
||||
[ReducerMethod]
|
||||
public static OfficeState ReduceSetNewlyCreatedGraphUserAction(OfficeState state, SetNewlyCreatedGraphUserAction action)
|
||||
{
|
||||
var graphUsers = state.GraphUsers;
|
||||
graphUsers = graphUsers.SetItem(action.UserId, action.GraphUser);
|
||||
var newPasswords = state.NewPasswords;
|
||||
newPasswords = newPasswords.SetItem(action.UserId, action.Password);
|
||||
return state with
|
||||
{
|
||||
GraphUsers = graphUsers,
|
||||
NewPasswords = newPasswords
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Fluxor;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace Wollmilchsau.Store.OfficeStore;
|
||||
|
||||
[FeatureState]
|
||||
public sealed record OfficeState
|
||||
{
|
||||
public OfficeState() { }
|
||||
public ImmutableDictionary<string, Microsoft.Graph.Models.User> GraphUsers { get; init; } = [];
|
||||
public ImmutableDictionary<string, Models.LdapUser> LdapUsers { get; init; } = [];
|
||||
public ImmutableDictionary<string, bool> HasLoaded { get; init; } = [];
|
||||
public ImmutableDictionary<string, string> NewPasswords { get; init; } = [];
|
||||
}
|
||||
@@ -13,16 +13,16 @@
|
||||
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageReference Include="Fluxor" Version="6.11.0" />
|
||||
<PackageReference Include="Fluxor.Blazor.Web" Version="6.11.0" />
|
||||
<PackageReference Include="Hangfire" Version="1.8.24" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.24" />
|
||||
<PackageReference Include="Hangfire" Version="1.8.25" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.25" />
|
||||
<PackageReference Include="Hangfire.Redis.StackExchange" Version="1.12.0" />
|
||||
<PackageReference Include="Marten.AspNetCore" Version="9.28.0" />
|
||||
<PackageReference Include="Marten.AspNetCore" Version="9.30.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.Graph" Version="6.5.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.24.1-preview.1" />
|
||||
<PackageReference Include="MudBlazor" Version="9.8.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="3.1.13" />
|
||||
<PackageReference Include="MudBlazor" Version="9.9.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="3.1.31" />
|
||||
<PackageReference Include="System.DirectoryServices.Protocols" Version="10.0.11" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Marten": "Host=lxc-db3;Port=5432;Database=wollmilchsau_dev;Username=wollmilchsau;Password=rVx0EoLDOnld",
|
||||
"RedisCache": "lxc-matrix:6379"
|
||||
"Marten": "Host=lxc-db3.tail859cae.ts.net;Port=5432;Database=wollmilchsau_dev;Username=wollmilchsau;Password=rVx0EoLDOnld",
|
||||
"RedisCache": "lxc-matrix.tail859cae.ts.net:6379"
|
||||
},
|
||||
"OIDC": {
|
||||
"Authority": "https://id.bs18.de/realms/master",
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"Marten": "Host=lxc-db3;Port=5432;Database=wollmilchsau;Username=wollmilchsau;Password=rVx0EoLDOnld",
|
||||
"RedisCache": "lxc-matrix:6379"
|
||||
"Marten": "Host=lxc-db3.tail859cae.ts.net;Port=5432;Database=wollmilchsau;Username=wollmilchsau;Password=rVx0EoLDOnld",
|
||||
"RedisCache": "lxc-matrix.tail859cae.ts.net:6379"
|
||||
},
|
||||
"OIDC": {
|
||||
"Authority": "https://id.bs18.de/realms/master",
|
||||
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |