Compare commits

10 Commits

Author SHA1 Message Date
Rhywden 9bf75b0e15 Nuget updates 2026-08-31 08:40:01 +02:00
Rhywden d2db6a8b6c FInalized office signup 2026-08-31 08:39:15 +02:00
Rhywden 04f7f527be Wired in the Office subscription page 2026-08-30 18:10:37 +02:00
Rhywden 78656d7165 Bugfix for AdminView 2026-08-26 06:40:16 +02:00
Rhywden cf145c299c Bugfixes and changes to the Admin Page 2026-08-24 15:23:53 +02:00
Rhywden c071489eec MudTable is now sortable 2026-08-24 13:11:28 +02:00
Rhywden a030cc517a Added loading of Moodle Users and displaying them 2026-08-24 13:05:14 +02:00
Rhywden edba5d8478 Debugging connection issues to ldap/redis/postgres 2026-08-24 09:19:10 +02:00
Rhywden 81327b6553 fixed the student_id 2026-08-23 20:19:06 +02:00
Rhywden 59eaa9b8e8 added ldap support 2026-08-23 19:25:09 +02:00
32 changed files with 1411 additions and 67 deletions
@@ -5,70 +5,292 @@
@inject Fluxor.IDispatcher Dispatcher @inject Fluxor.IDispatcher Dispatcher
@inject Fluxor.IState<Wollmilchsau.Store.AdminStore.AdminState> AdminState @inject Fluxor.IState<Wollmilchsau.Store.AdminStore.AdminState> AdminState
@inject IJSRuntime JsRuntime @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>
.moodle-delete-blocked > td {
<h1>LDAP Users</h1> background-color: var(--mud-palette-error-lighten, #ffebee) !important;
@if (!AdminState.Value.LdapUsersLoaded)
{
<p><em>Loading users...</em></p>
} }
else if (!Users.Any()) </style>
<MudButton OnClick="DownloadCsvAsync" Disabled="@(!LdapUsers.Any())">Export CSV</MudButton>
<MudButton OnClick="RefreshMoodleUsers">Refresh Moodle users</MudButton>
<MudButton OnClick="RefreshLdapUsers">Refresh LDAP users</MudButton>
<MudButton Color="Color.Error" OnClick="DeleteSelectedMoodleUsers"
Disabled="@(!SelectedUsers.Any() || AdminState.Value.MoodleUsersDeletionInProgress)">
Delete selected Moodle users
</MudButton>
<MudText Typo="Typo.h1">User Comparison</MudText>
@if (!AdminState.Value.LdapUsersLoaded || !AdminState.Value.MoodleUsersLoaded)
{ {
<p>No LDAP users found.</p> <MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
Loading @string.Join(" and ", PendingUserImports)...
</MudAlert>
} }
else else
{ {
<table class="table"> @if (AdminState.Value.MoodleUsersLoadFailed)
<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> <MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
<td>@user.Username</td> Moodle users could not be loaded. LDAP users are shown without Moodle matches.
<td>@user.Surname</td> </MudAlert>
<td>@user.Classroom</td> }
<td>@user.GivenName</td>
<td>@user.Incomplete</td> @if (AdminState.Value.MoodleUsersDeletionInProgress)
<td>@user.Role</td> {
</tr> <MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
Deleting Moodle users: @AdminState.Value.MoodleUsersDeletedCount of @AdminState.Value.MoodleUsersDeletionTotalCount 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">
<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>
</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>
</RowTemplate>
<PagerContent>
<MudTablePager />
</PagerContent>
</MudTable>
} }
</tbody>
</table>
} }
@code { @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 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 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";
}
}
}
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);
return ldapUsers.Values
.Select(ldapUser => CreateCombinedUser(
ldapUser,
moodleUsersByUsername.GetValueOrDefault(ldapUser.Username)))
.Concat(moodleUsers
.Where(moodleUser =>
string.IsNullOrWhiteSpace(moodleUser.Username) ||
!ldapUsers.ContainsKey(moodleUser.Username))
.Select(moodleUser => CreateCombinedUser(null, moodleUser)))
.OrderBy(user => user.Username, StringComparer.OrdinalIgnoreCase);
}
}
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.GetAllLdapUsers()); Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers());
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms()); Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms());
} }
private Task DownloadCsvAsync() private Task DownloadCsvAsync()
{ {
var csv = string.Join("\r\n", Users var csv = string.Join("\r\n", LdapUsers
.Where(user => user.Role == Wollmilchsau.Models.LdapUserRole.Student) .Where(user =>
user.Role == Wollmilchsau.Models.LdapUserRole.Student &&
!string.IsNullOrWhiteSpace(user.Classroom))
.Select(user => string.Join(';', .Select(user => string.Join(';',
user.GivenName ?? string.Empty, user.GivenName ?? string.Empty,
user.Surname ?? string.Empty, user.Surname ?? string.Empty,
user.Classroom ?? string.Empty, user.Classroom,
user.Username))); user.Username)));
return JsRuntime.InvokeVoidAsync("downloadTextFile", "ldap-users.csv", csv).AsTask(); 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 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 string GetRowClass(CombinedUser user, int rowIndex)
{
return InvalidDeleteSelection.Contains(user) ? "moodle-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(
Wollmilchsau.Models.LdapUser? ldapUser,
Wollmilchsau.Models.MoodleUser? moodleUser)
{
return new CombinedUser(
ldapUser?.Username ?? moodleUser?.Username ?? string.Empty,
moodleUser?.Id,
ldapUser?.Classroom,
ldapUser?.Surname ?? moodleUser?.Lastname,
ldapUser?.GivenName ?? moodleUser?.Firstname,
moodleUser?.Fullname,
moodleUser?.Auth,
ldapUser?.Role,
ldapUser is not null,
moodleUser is not null);
}
private sealed record CombinedUser(
string Username,
int? MoodleUserId,
string? Classroom,
string? Surname,
string? GivenName,
string? MoodleFullname,
string? MoodleAuth,
Wollmilchsau.Models.LdapUserRole? Role,
bool ExistsInLdap,
bool ExistsInMoodle);
} }
+7 -4
View File
@@ -6,16 +6,19 @@
<AuthorizeView Roles="ROLE_STUDENT,ROLE_TEACHER,ROLE_ADMIN"> <AuthorizeView Roles="ROLE_STUDENT,ROLE_TEACHER,ROLE_ADMIN">
<Authorized> <Authorized>
<MudContainer> <MudContainer>
<MudStack Row="true" Spacing="10">
<MudCard Style="width: 300px;"> <MudCard Style="width: 300px;">
<MudCardMedia Image="images/questionnaire.svg" Height="200" Style="width: 300px;" /> <MudCardMedia Image="images/office.png" Height="200" Style="width: 300px;" />
<MudCardContent> <MudCardContent>
<MudText Typo="Typo.h5">Umfragen</MudText> <MudText Typo="Typo.h5">Office Anmeldung</MudText>
<MudText Typo="Typo.body2">Hier kannst du an Umfragen teilnehmen.</MudText> <MudText Typo="Typo.body2">Hier kannst du dich für Office anmelden.</MudText>
</MudCardContent> </MudCardContent>
<MudCardActions> <MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/students/questionaire">Umfragen</MudButton> <MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/office/userhome">Office Anmeldung</MudButton>
</MudCardActions> </MudCardActions>
</MudCard> </MudCard>
</MudStack>
</MudContainer> </MudContainer>
</Authorized> </Authorized>
</AuthorizeView> </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&#64;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" @page "/students/questionaire"
@using System.Security.Claims
@inherits Fluxor.Blazor.Web.Components.FluxorComponent @inherits Fluxor.Blazor.Web.Components.FluxorComponent
@inject AuthenticationStateProvider AuthenticationStateProvider @inject AuthenticationStateProvider AuthenticationStateProvider
@inject Fluxor.IDispatcher Dispatcher @inject Fluxor.IDispatcher Dispatcher
@@ -78,11 +77,11 @@
var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authenticationState.User; 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));
} }
} }
+5
View File
@@ -2,8 +2,13 @@
# This stage is used when running from VS in fast mode (Default for Debug configuration) # 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 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 USER $APP_UID
WORKDIR /app WORKDIR /app
ENV LDAPTLS_CACERT=/etc/ssl/certs/ca-certificates.crt
EXPOSE 8080 EXPOSE 8080
EXPOSE 8081 EXPOSE 8081
+56
View File
@@ -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]);
}
}
}
+21
View File
@@ -2,4 +2,25 @@
public sealed record MoodleUser 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; }
} }
+16
View File
@@ -12,6 +12,8 @@ using System.Text.Json;
using Wollmilchsau.Components; using Wollmilchsau.Components;
using Wollmilchsau.Exceptions; using Wollmilchsau.Exceptions;
using Wollmilchsau.Services.Ldap; using Wollmilchsau.Services.Ldap;
using Wollmilchsau.Services.Moodle;
using Wollmilchsau.Services.Office;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -90,6 +92,20 @@ builder.Services.AddOptions<LdapOptions>()
.ValidateOnStart(); .ValidateOnStart();
builder.Services.AddScoped<ILdapService, LdapService>(); 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. // Add services to the container.
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(); .AddInteractiveServerComponents();
@@ -6,6 +6,7 @@ public interface ILdapService
{ {
Task<int> GetUserCount(CancellationToken cancellationToken = default); Task<int> GetUserCount(CancellationToken cancellationToken = default);
IAsyncEnumerable<LdapUser> GetAllUsers(CancellationToken cancellationToken = default); IAsyncEnumerable<LdapUser> GetAllUsers(CancellationToken cancellationToken = default);
Task<LdapUser?> GetUser(string uid, CancellationToken cancellationToken = default);
Task<int> GetClassroomCount(CancellationToken cancellationToken = default); Task<int> GetClassroomCount(CancellationToken cancellationToken = default);
IAsyncEnumerable<Classroom> GetAllClassrooms(CancellationToken cancellationToken = default); IAsyncEnumerable<Classroom> GetAllClassrooms(CancellationToken cancellationToken = default);
} }
+42 -3
View File
@@ -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) public Task<int> GetClassroomCount(CancellationToken cancellationToken = default)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@@ -90,9 +114,14 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
var connection = new LdapConnection(new LdapDirectoryIdentifier(ldapUri.Host, ldapUri.Port)) var connection = new LdapConnection(new LdapDirectoryIdentifier(ldapUri.Host, ldapUri.Port))
{ {
AuthType = AuthType.Basic, AuthType = AuthType.Basic,
Timeout = TimeSpan.FromSeconds(30) Timeout = TimeSpan.FromSeconds(30),
SessionOptions =
{
SecureSocketLayer = true
}
}; };
connection.SessionOptions.ProtocolVersion = 3; connection.SessionOptions.ProtocolVersion = 3;
connection.SessionOptions.SecureSocketLayer = string.Equals(ldapUri.Scheme, "ldaps", StringComparison.OrdinalIgnoreCase); connection.SessionOptions.SecureSocketLayer = string.Equals(ldapUri.Scheme, "ldaps", StringComparison.OrdinalIgnoreCase);
connection.Bind(new NetworkCredential(_options.BindDN, _options.BindPassword)); connection.Bind(new NetworkCredential(_options.BindDN, _options.BindPassword));
@@ -110,7 +139,6 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
SearchScope.Subtree, SearchScope.Subtree,
"uid", "uid",
"sn", "sn",
"departmentNumber",
"givenName", "givenName",
"memberOf"); "memberOf");
var response = (SearchResponse)connection.SendRequest(request); 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 username = GetFirstAttributeValue(entry, "uid") ?? throw new InvalidOperationException($"LDAP user entry '{entry.DistinguishedName}' is missing uid.");
var surname = CapitalizeFirstLetter(GetFirstAttributeValue(entry, "sn")); var surname = CapitalizeFirstLetter(GetFirstAttributeValue(entry, "sn"));
var classroom = GetFirstAttributeValue(entry, "departmentNumber"); var classroom = GetClassroomFromMembership(entry);
var givenName = GetFirstAttributeValue(entry, "givenName"); var givenName = GetFirstAttributeValue(entry, "givenName");
var role = DetermineUserRole(entry, studentRoleDn, teacherRoleDn); var role = DetermineUserRole(entry, studentRoleDn, teacherRoleDn);
@@ -139,6 +167,17 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
role); 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) private static LdapUserRole DetermineUserRole(SearchResultEntry entry, string studentRoleDn, string teacherRoleDn)
{ {
var roles = GetAttributeValues(entry, "memberOf"); 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,8 +3,17 @@
namespace Wollmilchsau.Store.AdminStore; namespace Wollmilchsau.Store.AdminStore;
public sealed record GetAllMoodleUsers(); 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 GetAllElementUsers();
public sealed record GetAllLdapUsers(); public sealed record GetAllLdapUsers();
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);
+256 -1
View File
@@ -1,16 +1,247 @@
using System.Text.Json; using System.Text.Json;
using Fluxor; using Fluxor;
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using MudBlazor;
using StackExchange.Redis;
using Wollmilchsau.Models; using Wollmilchsau.Models;
using Wollmilchsau.Services.Ldap; using Wollmilchsau.Services.Ldap;
using Wollmilchsau.Services.Moodle;
namespace Wollmilchsau.Store.AdminStore; namespace Wollmilchsau.Store.AdminStore;
public sealed class AdminEffects(ILdapService ldapService, IDistributedCache cache) public sealed class AdminEffects(IMoodleService moodleService, ILdapService ldapService, IDistributedCache cache, ISnackbar snackbar)
{ {
private const int MoodleDeleteBatchSize = 5;
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";
[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] [EffectMethod]
public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher) public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher)
{ {
@@ -58,6 +289,30 @@ public sealed class AdminEffects(ILdapService ldapService, IDistributedCache cac
return loadedUsers; 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] [EffectMethod]
public async Task HandleGetAllClassrooms(GetAllClassrooms action, IDispatcher dispatcher) public async Task HandleGetAllClassrooms(GetAllClassrooms action, IDispatcher dispatcher)
{ {
@@ -5,6 +5,69 @@ namespace Wollmilchsau.Store.AdminStore;
public static class AdminReducers 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] [ReducerMethod]
public static AdminState ReduceSetLdapUsers(AdminState state, SetLdapUsers action) public static AdminState ReduceSetLdapUsers(AdminState state, SetLdapUsers action)
{ {
+6 -1
View File
@@ -9,7 +9,12 @@ public sealed record AdminState
{ {
public AdminState() { } public AdminState() { }
public ImmutableDictionary<Guid, ElementUser> ElementUsers { get; init; } = []; 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 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; }
@@ -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; } = [];
}
+5 -5
View File
@@ -13,16 +13,16 @@
<PackageReference Include="Azure.Identity" Version="1.21.0" /> <PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Fluxor" Version="6.11.0" /> <PackageReference Include="Fluxor" Version="6.11.0" />
<PackageReference Include="Fluxor.Blazor.Web" Version="6.11.0" /> <PackageReference Include="Fluxor.Blazor.Web" Version="6.11.0" />
<PackageReference Include="Hangfire" Version="1.8.24" /> <PackageReference Include="Hangfire" Version="1.8.25" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.24" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.8.25" />
<PackageReference Include="Hangfire.Redis.StackExchange" Version="1.12.0" /> <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.AspNetCore.Authentication.OpenIdConnect" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" 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.Graph" Version="6.5.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.24.1-preview.1" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.24.1-preview.1" />
<PackageReference Include="MudBlazor" Version="9.8.0" /> <PackageReference Include="MudBlazor" Version="9.9.0" />
<PackageReference Include="StackExchange.Redis" Version="3.1.13" /> <PackageReference Include="StackExchange.Redis" Version="3.1.31" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="10.0.11" /> <PackageReference Include="System.DirectoryServices.Protocols" Version="10.0.11" />
</ItemGroup> </ItemGroup>
+2 -2
View File
@@ -26,8 +26,8 @@
}, },
"ConnectionStrings": { "ConnectionStrings": {
"Marten": "Host=lxc-db3;Port=5432;Database=wollmilchsau_dev;Username=wollmilchsau;Password=rVx0EoLDOnld", "Marten": "Host=lxc-db3.tail859cae.ts.net;Port=5432;Database=wollmilchsau_dev;Username=wollmilchsau;Password=rVx0EoLDOnld",
"RedisCache": "lxc-matrix:6379" "RedisCache": "lxc-matrix.tail859cae.ts.net:6379"
}, },
"OIDC": { "OIDC": {
"Authority": "https://id.bs18.de/realms/master", "Authority": "https://id.bs18.de/realms/master",
+2 -2
View File
@@ -27,8 +27,8 @@
}, },
"ConnectionStrings": { "ConnectionStrings": {
"Marten": "Host=lxc-db3;Port=5432;Database=wollmilchsau;Username=wollmilchsau;Password=rVx0EoLDOnld", "Marten": "Host=lxc-db3.tail859cae.ts.net;Port=5432;Database=wollmilchsau;Username=wollmilchsau;Password=rVx0EoLDOnld",
"RedisCache": "lxc-matrix:6379" "RedisCache": "lxc-matrix.tail859cae.ts.net:6379"
}, },
"OIDC": { "OIDC": {
"Authority": "https://id.bs18.de/realms/master", "Authority": "https://id.bs18.de/realms/master",
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB