Also now loading the classrooms in adminstate
This commit is contained in:
@@ -54,6 +54,7 @@ else
|
||||
{
|
||||
base.OnInitialized();
|
||||
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers());
|
||||
Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms());
|
||||
}
|
||||
|
||||
private Task DownloadCsvAsync()
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Wollmilchsau.Models;
|
||||
|
||||
public sealed record Classroom(string Name);
|
||||
@@ -4,5 +4,8 @@ namespace Wollmilchsau.Services.Ldap;
|
||||
|
||||
public interface ILdapService
|
||||
{
|
||||
Task<int> GetUserCount(CancellationToken cancellationToken = default);
|
||||
IAsyncEnumerable<LdapUser> GetAllUsers(CancellationToken cancellationToken = default);
|
||||
Task<int> GetClassroomCount(CancellationToken cancellationToken = default);
|
||||
IAsyncEnumerable<Classroom> GetAllClassrooms(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -27,4 +27,10 @@ public sealed class LdapOptions
|
||||
|
||||
[Required]
|
||||
public string TeacherRole { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string GroupBase { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string KlasseGroupFilter { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,21 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
|
||||
{
|
||||
private readonly LdapOptions _options = options.Value;
|
||||
|
||||
public Task<int> GetUserCount(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
using var connection = CreateConnection();
|
||||
var request = new SearchRequest(
|
||||
BuildDistinguishedName(_options.UserBase),
|
||||
GetSupportedRolesFilter(),
|
||||
SearchScope.Subtree,
|
||||
"1.1");
|
||||
var response = (SearchResponse)connection.SendRequest(request);
|
||||
|
||||
return Task.FromResult(response.Entries.Count);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<LdapUser> GetAllUsers(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -24,6 +39,45 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> GetClassroomCount(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
using var connection = CreateConnection();
|
||||
var request = new SearchRequest(
|
||||
BuildDistinguishedName(_options.GroupBase),
|
||||
_options.KlasseGroupFilter,
|
||||
SearchScope.Subtree,
|
||||
"1.1");
|
||||
var response = (SearchResponse)connection.SendRequest(request);
|
||||
|
||||
return Task.FromResult(response.Entries.Count);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Classroom> GetAllClassrooms(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
using var connection = CreateConnection();
|
||||
var request = new SearchRequest(
|
||||
BuildDistinguishedName(_options.GroupBase),
|
||||
_options.KlasseGroupFilter,
|
||||
SearchScope.Subtree,
|
||||
"cn");
|
||||
var response = (SearchResponse)connection.SendRequest(request);
|
||||
|
||||
foreach (var entry in response.Entries.Cast<SearchResultEntry>())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var commonName = GetFirstAttributeValue(entry, "cn")
|
||||
?? throw new InvalidOperationException($"LDAP classroom entry '{entry.DistinguishedName}' is missing cn.");
|
||||
yield return new Classroom(GetClassroomName(commonName));
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
private LdapConnection CreateConnection()
|
||||
{
|
||||
var ldapUri = new Uri(_options.LdapUri, UriKind.Absolute);
|
||||
@@ -52,7 +106,7 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
|
||||
var teacherRoleDn = BuildDistinguishedName(_options.TeacherRole, _options.RoleBase);
|
||||
var request = new SearchRequest(
|
||||
BuildDistinguishedName(_options.UserBase),
|
||||
$"(|(memberOf={EscapeFilterValue(studentRoleDn)})(memberOf={EscapeFilterValue(teacherRoleDn)}))",
|
||||
GetSupportedRolesFilter(studentRoleDn, teacherRoleDn),
|
||||
SearchScope.Subtree,
|
||||
"uid",
|
||||
"sn",
|
||||
@@ -102,6 +156,30 @@ public sealed class LdapService(IOptions<LdapOptions> options) : ILdapService
|
||||
throw new InvalidOperationException($"LDAP user entry '{entry.DistinguishedName}' is not a member of a supported role.");
|
||||
}
|
||||
|
||||
private string GetSupportedRolesFilter()
|
||||
{
|
||||
var studentRoleDn = BuildDistinguishedName(_options.StudentsRole, _options.RoleBase);
|
||||
var teacherRoleDn = BuildDistinguishedName(_options.TeacherRole, _options.RoleBase);
|
||||
|
||||
return GetSupportedRolesFilter(studentRoleDn, teacherRoleDn);
|
||||
}
|
||||
|
||||
private static string GetSupportedRolesFilter(string studentRoleDn, string teacherRoleDn)
|
||||
{
|
||||
return $"(|(memberOf={EscapeFilterValue(studentRoleDn)})(memberOf={EscapeFilterValue(teacherRoleDn)}))";
|
||||
}
|
||||
|
||||
private static string GetClassroomName(string commonName)
|
||||
{
|
||||
const string classroomPrefix = "klasse.";
|
||||
if (!commonName.StartsWith(classroomPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP classroom cn '{commonName}' does not start with '{classroomPrefix}'.");
|
||||
}
|
||||
|
||||
return commonName[classroomPrefix.Length..];
|
||||
}
|
||||
|
||||
private string BuildDistinguishedName(params string[] relativeNames)
|
||||
{
|
||||
return string.Join(',', relativeNames.Append(_options.BaseDN));
|
||||
|
||||
@@ -6,4 +6,6 @@ public sealed record GetAllMoodleUsers();
|
||||
public sealed record GetAllElementUsers();
|
||||
public sealed record GetAllLdapUsers();
|
||||
public sealed record SetLdapUsers(IReadOnlyCollection<LdapUser> Users);
|
||||
public sealed record GetAllClassrooms();
|
||||
public sealed record SetClassrooms(IReadOnlyCollection<Classroom> Classrooms);
|
||||
public sealed record GetAllMsolUsers();
|
||||
|
||||
@@ -9,36 +9,99 @@ namespace Wollmilchsau.Store.AdminStore;
|
||||
public sealed class AdminEffects(ILdapService ldapService, IDistributedCache cache)
|
||||
{
|
||||
private const string LdapUsersCacheKey = "admin:ldap-users";
|
||||
private const string ClassroomsCacheKey = "admin:classrooms";
|
||||
|
||||
[EffectMethod]
|
||||
public async Task HandleGetAllLdapUsers(GetAllLdapUsers action, IDispatcher dispatcher)
|
||||
{
|
||||
var ldapUserCount = await ldapService.GetUserCount();
|
||||
var cachedUsers = await cache.GetStringAsync(LdapUsersCacheKey);
|
||||
IReadOnlyCollection<LdapUser> users;
|
||||
|
||||
if (cachedUsers is not null)
|
||||
{
|
||||
users = JsonSerializer.Deserialize<LdapUser[]>(cachedUsers)
|
||||
?? throw new InvalidOperationException("The cached LDAP users could not be deserialized.");
|
||||
var deserializedUsers = JsonSerializer.Deserialize<LdapUser[]>(cachedUsers);
|
||||
if (deserializedUsers is not null && deserializedUsers.Length == ldapUserCount)
|
||||
{
|
||||
users = deserializedUsers;
|
||||
}
|
||||
else
|
||||
{
|
||||
var loadedUsers = new List<LdapUser>();
|
||||
await cache.RemoveAsync(LdapUsersCacheKey);
|
||||
users = await LoadAndCacheLdapUsers(ldapUserCount);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
users = await LoadAndCacheLdapUsers(ldapUserCount);
|
||||
}
|
||||
|
||||
dispatcher.Dispatch(new SetLdapUsers(users));
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<LdapUser>> LoadAndCacheLdapUsers(int ldapUserCount)
|
||||
{
|
||||
var loadedUsers = new List<LdapUser>(ldapUserCount);
|
||||
await foreach (var user in ldapService.GetAllUsers())
|
||||
{
|
||||
loadedUsers.Add(user);
|
||||
}
|
||||
|
||||
users = loadedUsers;
|
||||
await cache.SetStringAsync(
|
||||
LdapUsersCacheKey,
|
||||
JsonSerializer.Serialize(users),
|
||||
JsonSerializer.Serialize(loadedUsers),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = TimeSpan.FromMinutes(10)
|
||||
});
|
||||
|
||||
return loadedUsers;
|
||||
}
|
||||
|
||||
dispatcher.Dispatch(new SetLdapUsers(users));
|
||||
[EffectMethod]
|
||||
public async Task HandleGetAllClassrooms(GetAllClassrooms action, IDispatcher dispatcher)
|
||||
{
|
||||
var ldapClassroomCount = await ldapService.GetClassroomCount();
|
||||
var cachedClassrooms = await cache.GetStringAsync(ClassroomsCacheKey);
|
||||
IReadOnlyCollection<Classroom> classrooms;
|
||||
|
||||
if (cachedClassrooms is not null)
|
||||
{
|
||||
var deserializedClassrooms = JsonSerializer.Deserialize<Classroom[]>(cachedClassrooms);
|
||||
if (deserializedClassrooms is not null && deserializedClassrooms.Length == ldapClassroomCount)
|
||||
{
|
||||
classrooms = deserializedClassrooms;
|
||||
}
|
||||
else
|
||||
{
|
||||
await cache.RemoveAsync(ClassroomsCacheKey);
|
||||
classrooms = await LoadAndCacheClassrooms(ldapClassroomCount);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
classrooms = await LoadAndCacheClassrooms(ldapClassroomCount);
|
||||
}
|
||||
|
||||
dispatcher.Dispatch(new SetClassrooms(classrooms));
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyCollection<Classroom>> LoadAndCacheClassrooms(int ldapClassroomCount)
|
||||
{
|
||||
var loadedClassrooms = new List<Classroom>(ldapClassroomCount);
|
||||
await foreach (var classroom in ldapService.GetAllClassrooms())
|
||||
{
|
||||
loadedClassrooms.Add(classroom);
|
||||
}
|
||||
|
||||
await cache.SetStringAsync(
|
||||
ClassroomsCacheKey,
|
||||
JsonSerializer.Serialize(loadedClassrooms),
|
||||
new DistributedCacheEntryOptions
|
||||
{
|
||||
SlidingExpiration = TimeSpan.FromMinutes(10)
|
||||
});
|
||||
|
||||
return loadedClassrooms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,4 +14,15 @@ public static class AdminReducers
|
||||
LdapUsersLoaded = true
|
||||
};
|
||||
}
|
||||
|
||||
[ReducerMethod]
|
||||
public static AdminState ReduceSetClassrooms(AdminState state, SetClassrooms action)
|
||||
{
|
||||
return state with
|
||||
{
|
||||
Classrooms = action.Classrooms
|
||||
.OrderBy(classroom => classroom.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToImmutableList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ public sealed record AdminState
|
||||
public ImmutableDictionary<Guid, ElementUser> ElementUsers { get; init; } = [];
|
||||
public ImmutableDictionary<Guid, MoodleUser> MoodleUsers { get; init; } = [];
|
||||
public ImmutableDictionary<string, LdapUser> LdapUsers { get; init; } = [];
|
||||
public ImmutableList<Classroom> Classrooms { get; init; } = [];
|
||||
public bool LdapUsersLoaded { get; init; }
|
||||
public ImmutableDictionary<Guid, MsolUser> MsolUsers { get; init; } = [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user