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