diff --git a/Wollmilchsau/Components/Pages/Home.razor b/Wollmilchsau/Components/Pages/Home.razor index f95c101..cf42da1 100644 --- a/Wollmilchsau/Components/Pages/Home.razor +++ b/Wollmilchsau/Components/Pages/Home.razor @@ -6,16 +6,28 @@ - - - - Umfragen - Hier kannst du an Umfragen teilnehmen. - - - Umfragen - - + + + + + Office Anmeldung + Hier kannst du dich für Office anmelden. + + + Office Anmeldung + + + + + + Umfragen + Hier kannst du an Umfragen teilnehmen. + + + Umfragen + + + diff --git a/Wollmilchsau/Components/Pages/Students/OfficeStudentHome.razor b/Wollmilchsau/Components/Pages/Students/OfficeStudentHome.razor new file mode 100644 index 0000000..99d1194 --- /dev/null +++ b/Wollmilchsau/Components/Pages/Students/OfficeStudentHome.razor @@ -0,0 +1,172 @@ +@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 + +@attribute [Authorize] + + @if (OfficeState.Value?.HasLoaded.GetValueOrDefault(CurrentUser?.Username ?? string.Empty) == true) + { + + + + Dein Office-Account + + + @if (GraphUser is not null) + { + + Dein Username: @GraphUser.UserPrincipalName + Herunterladen unter: https://office.com + @if (OfficeState.Value?.NewPasswords.GetValueOrDefault(CurrentUser?.Username ?? string.Empty) is string newPassword) + { + Dein temporäres Passwort: @newPassword + + Du wirst beim ersten Anmelden aufgefordert, dein Passwort zu ändern. Notiere unbedingt dieses neue Passwort! + + } + else + { + Wenn du dein Passwort nicht mehr kennst, kannst du es über den untenstehenden Button zurücksetzen. + } + + + Passwort zurücksetzen + + } + else + { + + Derzeit existiert anscheinend kein Office-Account unter @CurrentUser?.Username@bs18.de + Sollte hier ein Fehler vorliegen bitte an @ContactEmail schreiben. + Ansonsten kannst Du über den untenstehenden Button einen Account erstellen. + + + Account erstellen + + } + + } + else + { + + + + + + + + + + + + + + + + } + + + + + Um das Office-Paket zu nutzen, bestehen mehrere Möglichkeiten: + 1. Nutzen der Webanwendungen über https://office.com + 2. Installieren der Officeanwendungen auf dem eigenen Gerät. Siehe Anleitungen unten. + + + + Sollte das Office-Paket nicht vorinstalliert sein, so lässt sich Office auch manuell installieren. + Dafür bei MS Office Website anmelden und rechts oben auf "Apps Installieren" clicken. + Die erste Option "Microsoft 365 Apps" ist dann die richtige. + + + + Für MacOS lässt sich das Officepaket direkt aus dem Mac App Store herunterladen: + Microsoft 365 im Mac App Store + + Alternativ lässt sich Office auch manuell installieren. Dafür bei MS Office Website anmelden und rechts oben auf "Apps Installieren" clicken. + Die erste Option "Microsoft 365 Apps" ist dann die richtige. + + + + Um Office auf iPad und iPhone nutzen zu können, musst du jede Anwendung einzeln herunterladen. + + + + Microsoft Word + + + + Microsoft Excel + + + + Microsoft PowerPoint + + + + + + + + Microsoft Word + + + + Microsoft Excel + + + + Microsoft PowerPoint + + + + + + + @code { + [CascadingParameter] private Task? 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) + { + 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)); + } +} \ No newline at end of file diff --git a/Wollmilchsau/Helpers/PasswordGenerator.cs b/Wollmilchsau/Helpers/PasswordGenerator.cs new file mode 100644 index 0000000..a6e3aaf --- /dev/null +++ b/Wollmilchsau/Helpers/PasswordGenerator.cs @@ -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 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 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 chars) + { + for (var n = chars.Length - 1; n > 0; n--) + { + var k = RandomNumberGenerator.GetInt32(n + 1); + (chars[n], chars[k]) = (chars[k], chars[n]); + } + } +} diff --git a/Wollmilchsau/Program.cs b/Wollmilchsau/Program.cs index 8eba3f7..f130ecb 100644 --- a/Wollmilchsau/Program.cs +++ b/Wollmilchsau/Program.cs @@ -13,6 +13,7 @@ using Wollmilchsau.Components; using Wollmilchsau.Exceptions; using Wollmilchsau.Services.Ldap; using Wollmilchsau.Services.Moodle; +using Wollmilchsau.Services.Office; var builder = WebApplication.CreateBuilder(args); @@ -95,6 +96,9 @@ builder.Services.AddOptions() .BindConfiguration("Moodle") .ValidateDataAnnotations() .ValidateOnStart(); + +builder.Services.AddSingleton(); + builder.Services.AddHttpClient((serviceProvider, client) => { var options = serviceProvider.GetRequiredService>().Value; diff --git a/Wollmilchsau/Services/Office/OfficeService.cs b/Wollmilchsau/Services/Office/OfficeService.cs new file mode 100644 index 0000000..91024ea --- /dev/null +++ b/Wollmilchsau/Services/Office/OfficeService.cs @@ -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 GetAllUsersAsync(CancellationToken cancellationToken = default); + Task GetUserByMailAsync(string mailAddress, CancellationToken cancellationToken = default); + Task GetUserByAliasesAsync(string username, CancellationToken cancellationToken = default); + Task DeleteUserByIdAsync(string userId, CancellationToken cancellationToken = default); + Task 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 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 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 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 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 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 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 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; + } + } +} \ No newline at end of file diff --git a/Wollmilchsau/Store/OfficeStore/OfficeActions.cs b/Wollmilchsau/Store/OfficeStore/OfficeActions.cs new file mode 100644 index 0000000..f38f06f --- /dev/null +++ b/Wollmilchsau/Store/OfficeStore/OfficeActions.cs @@ -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); \ No newline at end of file diff --git a/Wollmilchsau/Store/OfficeStore/OfficeEffects.cs b/Wollmilchsau/Store/OfficeStore/OfficeEffects.cs new file mode 100644 index 0000000..75c8735 --- /dev/null +++ b/Wollmilchsau/Store/OfficeStore/OfficeEffects.cs @@ -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 ldapUser = await ldapService.GetUserByUidAsync(action.UserId); + dispatcher.Dispatch(new UpdateCurrentUserInfoWithResultAction(action.UserId, graphUser, /*ldapUser.ValueOrDefault*/ null)); + } + [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)); + } + } +} \ No newline at end of file diff --git a/Wollmilchsau/Store/OfficeStore/OfficeReducers.cs b/Wollmilchsau/Store/OfficeStore/OfficeReducers.cs new file mode 100644 index 0000000..0bd245f --- /dev/null +++ b/Wollmilchsau/Store/OfficeStore/OfficeReducers.cs @@ -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 + }; + } +} \ No newline at end of file diff --git a/Wollmilchsau/Store/OfficeStore/OfficeState.cs b/Wollmilchsau/Store/OfficeStore/OfficeState.cs new file mode 100644 index 0000000..8c8f6f3 --- /dev/null +++ b/Wollmilchsau/Store/OfficeStore/OfficeState.cs @@ -0,0 +1,14 @@ +using Fluxor; +using System.Collections.Immutable; + +namespace Wollmilchsau.Store.OfficeStore; + +[FeatureState] +public sealed record OfficeState +{ + public OfficeState() { } + public ImmutableDictionary GraphUsers { get; init; } = []; + public ImmutableDictionary LdapUsers { get; init; } = []; + public ImmutableDictionary HasLoaded { get; init; } = []; + public ImmutableDictionary NewPasswords { get; init; } = []; +} \ No newline at end of file diff --git a/Wollmilchsau/wwwroot/images/excelqr.png b/Wollmilchsau/wwwroot/images/excelqr.png new file mode 100644 index 0000000..50b0654 Binary files /dev/null and b/Wollmilchsau/wwwroot/images/excelqr.png differ diff --git a/Wollmilchsau/wwwroot/images/googleexcelqr.png b/Wollmilchsau/wwwroot/images/googleexcelqr.png new file mode 100644 index 0000000..b5d0c8b Binary files /dev/null and b/Wollmilchsau/wwwroot/images/googleexcelqr.png differ diff --git a/Wollmilchsau/wwwroot/images/googlepptqr.png b/Wollmilchsau/wwwroot/images/googlepptqr.png new file mode 100644 index 0000000..205156d Binary files /dev/null and b/Wollmilchsau/wwwroot/images/googlepptqr.png differ diff --git a/Wollmilchsau/wwwroot/images/googlewordqr.png b/Wollmilchsau/wwwroot/images/googlewordqr.png new file mode 100644 index 0000000..06cddc0 Binary files /dev/null and b/Wollmilchsau/wwwroot/images/googlewordqr.png differ diff --git a/Wollmilchsau/wwwroot/images/office.png b/Wollmilchsau/wwwroot/images/office.png new file mode 100644 index 0000000..cf73638 Binary files /dev/null and b/Wollmilchsau/wwwroot/images/office.png differ diff --git a/Wollmilchsau/wwwroot/images/powerpointqr.png b/Wollmilchsau/wwwroot/images/powerpointqr.png new file mode 100644 index 0000000..295f4c5 Binary files /dev/null and b/Wollmilchsau/wwwroot/images/powerpointqr.png differ diff --git a/Wollmilchsau/wwwroot/images/wordqr.png b/Wollmilchsau/wwwroot/images/wordqr.png new file mode 100644 index 0000000..bc676b1 Binary files /dev/null and b/Wollmilchsau/wwwroot/images/wordqr.png differ