Compare commits

...

11 Commits

Author SHA1 Message Date
Rhywden 019f251474 implemented office user administration 2026-08-31 13:28:52 +02:00
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
35 changed files with 1933 additions and 74 deletions
+4
View File
@@ -0,0 +1,4 @@
# Copilot Instructions
## Project Guidelines
- In the Wollmilchsau project, Fluxor state features (e.g., AdminState/AdminActions/AdminEffects/AdminReducers) follow a pattern: cache external service data (Moodle/LDAP/Office users) via IDistributedCache with 10-minute sliding expiration, expose Loaded/LoadFailed flags on state, and combine data sources via username-based outer joins in the Razor page.
@@ -5,70 +5,488 @@
@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>
.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> <MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
} Loading @string.Join(" and ", PendingUserImports)...
else if (!Users.Any()) </MudAlert>
{
<p>No LDAP users found.</p>
} }
else else
{ {
<table class="table"> @if (AdminState.Value.MoodleUsersLoadFailed)
<thead> {
<tr> <MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
<th>Username</th> Moodle users could not be loaded. LDAP users are shown without Moodle matches.
<th>Surname</th> </MudAlert>
<th>Classroom</th> }
<th>Given name</th>
<th>Incomplete</th> @if (AdminState.Value.OfficeUsersLoadFailed)
<th>Role</th> {
</tr> <MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
</thead> Office users could not be loaded. Users are shown without Office matches.
<tbody> </MudAlert>
@foreach (var user in Users) }
{
<tr> @if (AdminState.Value.MoodleUsersDeletionInProgress)
<td>@user.Username</td> {
<td>@user.Surname</td> <MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
<td>@user.Classroom</td> Deleting Moodle users: @AdminState.Value.MoodleUsersDeletedCount of @AdminState.Value.MoodleUsersDeletionTotalCount deleted...
<td>@user.GivenName</td> </MudAlert>
<td>@user.Incomplete</td> }
<td>@user.Role</td>
</tr> @if (AdminState.Value.OfficeUsersDeletionInProgress)
} {
</tbody> <MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
</table> 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 { @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() 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());
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllOfficeUsers());
} }
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 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);
} }
+13 -10
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>
<MudCard Style="width: 300px;"> <MudStack Row="true" Spacing="10">
<MudCardMedia Image="images/questionnaire.svg" Height="200" Style="width: 300px;" /> <MudCard Style="width: 300px;">
<MudCardContent> <MudCardMedia Image="images/office.png" Height="200" Style="width: 300px;" />
<MudText Typo="Typo.h5">Umfragen</MudText> <MudCardContent>
<MudText Typo="Typo.body2">Hier kannst du an Umfragen teilnehmen.</MudText> <MudText Typo="Typo.h5">Office Anmeldung</MudText>
</MudCardContent> <MudText Typo="Typo.body2">Hier kannst du dich für Office anmelden.</MudText>
<MudCardActions> </MudCardContent>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/students/questionaire">Umfragen</MudButton> <MudCardActions>
</MudCardActions> <MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/office/userhome">Office Anmeldung</MudButton>
</MudCard> </MudCardActions>
</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; }
} }
-5
View File
@@ -1,5 +0,0 @@
namespace Wollmilchsau.Models;
public sealed record MsolUser
{
}
+12
View File
@@ -0,0 +1,12 @@
namespace Wollmilchsau.Models;
public sealed record OfficeUser
{
public required string Id { get; init; }
public string? DisplayName { get; init; }
public string? UserPrincipalName { get; init; }
public string? Mail { get; init; }
public string? Department { get; init; }
public string? JobTitle { get; init; }
public IReadOnlyList<string> ProxyAddresses { get; init; } = [];
}
+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;
}
}
}
+18 -1
View File
@@ -3,9 +3,26 @@
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);
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();
+491 -1
View File
@@ -1,15 +1,249 @@
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;
using Wollmilchsau.Services.Office;
namespace Wollmilchsau.Store.AdminStore; 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 LdapUsersCacheKey = "admin:ldap-users";
private const string ClassroomsCacheKey = "admin:classrooms"; 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] [EffectMethod]
public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher) public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher)
@@ -58,6 +292,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)
{ {
@@ -104,4 +362,236 @@ public sealed class AdminEffects(ILdapService ldapService, IDistributedCache cac
return loadedClassrooms; return loadedClassrooms;
} }
[EffectMethod]
public async Task HandleGetAllOfficeUsers(GetAllOfficeUsers action, IDispatcher dispatcher)
{
try
{
var cachedUsers = await cache.GetStringAsync(OfficeUsersCacheKey);
IReadOnlyCollection<OfficeUser> users;
if (cachedUsers is not null)
{
var deserializedUsers = JsonSerializer.Deserialize<OfficeUser[]>(cachedUsers);
if (deserializedUsers is not null)
{
users = deserializedUsers;
}
else
{
await cache.RemoveAsync(OfficeUsersCacheKey);
users = await LoadAndCacheOfficeUsers();
}
}
else
{
users = await LoadAndCacheOfficeUsers();
}
dispatcher.Dispatch(new SetOfficeUsers(users));
}
catch (Microsoft.Kiota.Abstractions.ApiException)
{
dispatcher.Dispatch(new SetOfficeUsersLoadFailed());
}
catch (JsonException)
{
dispatcher.Dispatch(new SetOfficeUsersLoadFailed());
}
catch (InvalidOperationException)
{
dispatcher.Dispatch(new SetOfficeUsersLoadFailed());
}
catch (TaskCanceledException)
{
dispatcher.Dispatch(new SetOfficeUsersLoadFailed());
}
}
private async Task<IReadOnlyCollection<OfficeUser>> LoadAndCacheOfficeUsers()
{
var users = new List<OfficeUser>();
await foreach (var graphUser in officeService.GetAllUsersAsync())
{
if (graphUser.Id is null)
{
continue;
}
users.Add(new OfficeUser
{
Id = graphUser.Id,
DisplayName = graphUser.DisplayName,
UserPrincipalName = graphUser.UserPrincipalName,
Mail = graphUser.Mail,
Department = graphUser.Department,
JobTitle = graphUser.JobTitle,
ProxyAddresses = graphUser.ProxyAddresses ?? []
});
}
await cache.SetStringAsync(
OfficeUsersCacheKey,
JsonSerializer.Serialize(users),
new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromMinutes(10)
});
return users;
}
[EffectMethod]
public async Task HandleRefreshOfficeUsers(RefreshOfficeUsers action, IDispatcher dispatcher)
{
try
{
await cache.RemoveAsync(OfficeUsersCacheKey);
dispatcher.Dispatch(new SetOfficeUsers(await LoadAndCacheOfficeUsers()));
snackbar.Add("Office users refreshed.", Severity.Success);
}
catch (RedisException)
{
snackbar.Add("Office users could not be refreshed because the cache could not be cleared.", Severity.Error);
}
catch (Microsoft.Kiota.Abstractions.ApiException)
{
snackbar.Add("Office users could not be refreshed.", Severity.Error);
}
catch (JsonException)
{
snackbar.Add("Office users could not be refreshed.", Severity.Error);
}
catch (InvalidOperationException)
{
snackbar.Add("Office users could not be refreshed.", Severity.Error);
}
catch (TaskCanceledException)
{
snackbar.Add("Office user refresh timed out.", Severity.Error);
}
}
[EffectMethod]
public async Task HandleDeleteOfficeUsers(DeleteOfficeUsers action, IDispatcher dispatcher)
{
var batches = action.UserIds
.Distinct()
.Chunk(OfficeDeleteBatchSize)
.ToArray();
if (batches.Length == 0)
{
snackbar.Add("No Office users were selected for deletion.", Severity.Warning);
return;
}
var totalUserCount = batches.Sum(batch => batch.Length);
dispatcher.Dispatch(new OfficeUsersDeletionStarted(totalUserCount));
try
{
await cache.RemoveAsync(OfficeUsersCacheKey);
}
catch (RedisException exception)
{
dispatcher.Dispatch(new OfficeUsersDeletionFailed());
snackbar.Add($"Office deletion did not start because the cache could not be invalidated: {exception.Message}", Severity.Error);
return;
}
var deletedUserCount = 0;
foreach (var batch in batches)
{
if (!await TryDeleteOfficeUserBatch(batch, deletedUserCount, totalUserCount, dispatcher))
{
return;
}
dispatcher.Dispatch(new OfficeUsersDeleted(batch));
deletedUserCount += batch.Length;
}
dispatcher.Dispatch(new OfficeUsersDeletionCompleted());
snackbar.Add($"Deleted {action.UserIds.Distinct().Count()} Office user(s).", Severity.Success);
}
private async Task<bool> TryDeleteOfficeUserBatch(
IReadOnlyCollection<string> userIds,
int deletedUserCount,
int totalUserCount,
IDispatcher dispatcher)
{
try
{
await Task.WhenAll(userIds.Select(userId => officeService.DeleteUserByIdAsync(userId)));
return true;
}
catch (Microsoft.Kiota.Abstractions.ApiException exception)
{
await ReportOfficeDeletionFailure(dispatcher, exception, userIds, deletedUserCount, totalUserCount);
}
catch (InvalidOperationException exception)
{
await ReportOfficeDeletionFailure(dispatcher, exception, userIds, deletedUserCount, totalUserCount);
}
catch (TaskCanceledException exception)
{
await ReportOfficeDeletionFailure(dispatcher, exception, userIds, deletedUserCount, totalUserCount);
}
catch (JsonException exception)
{
await ReportOfficeDeletionFailure(dispatcher, exception, userIds, deletedUserCount, totalUserCount);
}
return false;
}
private async Task ReportOfficeDeletionFailure(
IDispatcher dispatcher,
Exception exception,
IReadOnlyCollection<string> failedUserIds,
int deletedUserCount,
int totalUserCount)
{
dispatcher.Dispatch(new OfficeUsersDeletionFailed());
var reconciliationMessage = await ReconcileOfficeUsersAfterDeletionFailure(dispatcher, failedUserIds);
snackbar.Add(
$"Office deletion stopped after confirming {deletedUserCount} of {totalUserCount} user(s). Reason: {exception.Message}.{reconciliationMessage}",
Severity.Error);
}
private async Task<string> ReconcileOfficeUsersAfterDeletionFailure(
IDispatcher dispatcher,
IReadOnlyCollection<string> failedUserIds)
{
try
{
var users = await LoadAndCacheOfficeUsers();
dispatcher.Dispatch(new SetOfficeUsers(users));
var deletedFromFailedRequest = failedUserIds.Count(userId => !users.Any(user => user.Id == userId));
return deletedFromFailedRequest == 0
? " The user list was refreshed from Office."
: $" The user list was refreshed from Office; {deletedFromFailedRequest} user(s) from the failed request were deleted server-side.";
}
catch (Microsoft.Kiota.Abstractions.ApiException exception)
{
return $" The user list could not be refreshed: {exception.Message}";
}
catch (InvalidOperationException exception)
{
return $" The user list could not be refreshed: {exception.Message}";
}
catch (TaskCanceledException exception)
{
return $" The user list could not be refreshed: {exception.Message}";
}
catch (JsonException exception)
{
return $" The user list could not be refreshed: {exception.Message}";
}
}
} }
@@ -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)
{ {
@@ -25,4 +88,64 @@ public static class AdminReducers
.ToImmutableList() .ToImmutableList()
}; };
} }
[ReducerMethod]
public static AdminState ReduceSetOfficeUsers(AdminState state, SetOfficeUsers action)
{
return state with
{
OfficeUsers = action.Users.ToImmutableDictionary(user => user.Id),
OfficeUsersLoaded = true,
OfficeUsersLoadFailed = false
};
}
[ReducerMethod]
public static AdminState ReduceSetOfficeUsersLoadFailed(AdminState state, SetOfficeUsersLoadFailed action)
{
return state with
{
OfficeUsers = [],
OfficeUsersLoaded = true,
OfficeUsersLoadFailed = true
};
}
[ReducerMethod]
public static AdminState ReduceOfficeUsersDeletionStarted(AdminState state, OfficeUsersDeletionStarted action)
{
return state with
{
OfficeUsersDeletionInProgress = true,
OfficeUsersDeletedCount = 0,
OfficeUsersDeletionTotalCount = action.TotalUsers
};
}
[ReducerMethod]
public static AdminState ReduceOfficeUsersDeleted(AdminState state, OfficeUsersDeleted action)
{
return state with
{
OfficeUsers = state.OfficeUsers.RemoveRange(action.UserIds),
OfficeUsersDeletedCount = state.OfficeUsersDeletedCount + action.UserIds.Count
};
}
[ReducerMethod]
public static AdminState ReduceOfficeUsersDeletionFailed(AdminState state, OfficeUsersDeletionFailed action)
{
return state with { OfficeUsersDeletionInProgress = false };
}
[ReducerMethod]
public static AdminState ReduceOfficeUsersDeletionCompleted(AdminState state, OfficeUsersDeletionCompleted action)
{
return state with
{
OfficeUsersDeletionInProgress = false,
OfficeUsersDeletedCount = 0,
OfficeUsersDeletionTotalCount = 0
};
}
} }
+12 -2
View File
@@ -9,9 +9,19 @@ 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; }
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; } = [];
}
+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