Compare commits
10 Commits
dc6afd090f
...
9bf75b0e15
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bf75b0e15 | |||
| d2db6a8b6c | |||
| 04f7f527be | |||
| 78656d7165 | |||
| cf145c299c | |||
| c071489eec | |||
| a030cc517a | |||
| edba5d8478 | |||
| 81327b6553 | |||
| 59eaa9b8e8 |
@@ -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 {
|
||||||
|
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 Color="Color.Error" OnClick="DeleteSelectedMoodleUsers"
|
||||||
|
Disabled="@(!SelectedUsers.Any() || AdminState.Value.MoodleUsersDeletionInProgress)">
|
||||||
|
Delete selected Moodle users
|
||||||
|
</MudButton>
|
||||||
|
|
||||||
@if (!AdminState.Value.LdapUsersLoaded)
|
<MudText Typo="Typo.h1">User Comparison</MudText>
|
||||||
|
|
||||||
|
@if (!AdminState.Value.LdapUsersLoaded || !AdminState.Value.MoodleUsersLoaded)
|
||||||
{
|
{
|
||||||
<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.MoodleUsersDeletionInProgress)
|
||||||
<th>Role</th>
|
{
|
||||||
</tr>
|
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
|
||||||
</thead>
|
Deleting Moodle users: @AdminState.Value.MoodleUsersDeletedCount of @AdminState.Value.MoodleUsersDeletionTotalCount deleted...
|
||||||
<tbody>
|
</MudAlert>
|
||||||
@foreach (var user in Users)
|
}
|
||||||
{
|
|
||||||
<tr>
|
@if (!CombinedUsers.Any())
|
||||||
<td>@user.Username</td>
|
{
|
||||||
<td>@user.Surname</td>
|
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
|
||||||
<td>@user.Classroom</td>
|
No users found.
|
||||||
<td>@user.GivenName</td>
|
</MudAlert>
|
||||||
<td>@user.Incomplete</td>
|
}
|
||||||
<td>@user.Role</td>
|
else
|
||||||
</tr>
|
{
|
||||||
}
|
<MudTable @ref="UserTable" T="CombinedUser" Items="@CombinedUsers" Hover="true" Dense="true" RowsPerPage="25"
|
||||||
</tbody>
|
MultiSelection="true" @bind-SelectedItems="SelectedUsers" RowClassFunc="GetRowClass" CustomHeader="true">
|
||||||
</table>
|
<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>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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);
|
||||||
}
|
}
|
||||||
@@ -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@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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
namespace Wollmilchsau.Helpers;
|
||||||
|
|
||||||
|
internal static class PasswordGenerator
|
||||||
|
{
|
||||||
|
// Avoid ambiguous: l, I, O, 0, 1 (and also exclude o for extra safety)
|
||||||
|
private const string Lower = "abcdefghijkmnpqrstuvwxyz"; // no l, no o
|
||||||
|
private const string Upper = "ABCDEFGHJKMNPQRSTUVWXYZ"; // no I, no O
|
||||||
|
private const string Digits = "23456789"; // no 0, no 1
|
||||||
|
private const string Specials = "#!?$";
|
||||||
|
|
||||||
|
internal static string Create(
|
||||||
|
int length = 16,
|
||||||
|
int minLower = 1,
|
||||||
|
int minUpper = 1,
|
||||||
|
int minDigits = 1,
|
||||||
|
int minSpecial = 1)
|
||||||
|
{
|
||||||
|
if (length < minLower + minUpper + minDigits + minSpecial)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(length), "Length too short for required character counts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Span<char> buffer = length <= 256 ? stackalloc char[length] : new char[length];
|
||||||
|
var i = 0;
|
||||||
|
|
||||||
|
Fill(buffer, ref i, Lower, minLower);
|
||||||
|
Fill(buffer, ref i, Upper, minUpper);
|
||||||
|
Fill(buffer, ref i, Digits, minDigits);
|
||||||
|
Fill(buffer, ref i, Specials, minSpecial);
|
||||||
|
|
||||||
|
var all = Lower + Upper + Digits + Specials;
|
||||||
|
Fill(buffer, ref i, all, length - i);
|
||||||
|
|
||||||
|
Shuffle(buffer);
|
||||||
|
|
||||||
|
return new string(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Fill(Span<char> target, ref int index, string alphabet, int count)
|
||||||
|
{
|
||||||
|
for (var c = 0; c < count; c++)
|
||||||
|
{
|
||||||
|
target[index++] = alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Shuffle(Span<char> chars)
|
||||||
|
{
|
||||||
|
for (var n = chars.Length - 1; n > 0; n--)
|
||||||
|
{
|
||||||
|
var k = RandomNumberGenerator.GetInt32(n + 1);
|
||||||
|
(chars[n], chars[k]) = (chars[k], chars[n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,4 +2,25 @@
|
|||||||
|
|
||||||
public sealed record MoodleUser
|
public 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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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; } = [];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |