Compare commits

..

48 Commits

Author SHA1 Message Date
a.beging@eas-solutions.de
78135a9f6d Enhance AuditService: add logging for audit insert operations
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 2m21s
2026-05-06 15:15:09 +02:00
a.beging@eas-solutions.de
231e29f877 Fix target framework and package version in test project
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 2m6s
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 11:27:14 +02:00
a.beging@eas-solutions.de
17a0be20b3 Add UserServiceTests: implement unit tests for user management functionalities
Some checks failed
Build And Push Dev Docker Image / docker (push) Failing after 28s
2026-04-30 11:20:12 +02:00
a.beging@eas-solutions.de
2f4823ed09 Enhance CI workflows: add test execution step in Docker image build process 2026-04-30 11:14:26 +02:00
a.beging@eas-solutions.de
1759e8a2d4 Enhance MailService: refactor constructor to accept a custom SMTP client factory and add unit tests for SendEmailAsync method 2026-04-30 11:14:15 +02:00
a.beging@eas-solutions.de
865797d3f8 Add unit tests for LocalStorageService: implement tests for GetItem, SetItem, and RemoveItem methods
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m57s
2026-04-30 10:33:35 +02:00
a.beging@eas-solutions.de
cefa47a176 Enhance user management: prevent deletion of the last admin user and restrict admin type changes for the last admin account
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m47s
Co-authored-by: Copilot <copilot@github.com>
2026-04-30 09:53:05 +02:00
a.beging@eas-solutions.de
c4d7fd6ed5 Enhance layout styles: update stylesheet links to use asp-append-version for better cache management
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m40s
2026-04-30 07:21:43 +02:00
a.beging@eas-solutions.de
f4f04e4a42 Enhance interaction handling: add confirmation dialog for deleting verification images and ensure OnSuccess callback is invoked after adding interactions
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m42s
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 16:17:11 +02:00
a.beging@eas-solutions.de
6807f2b6e6 Enhance audit logging: add new audit types for password reset and prospect image actions, and update related services to log these events
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 15:57:30 +02:00
a.beging@eas-solutions.de
0dd0c1bf4c Enhance AuditView and NavMenu: restrict access for non-admin users in InitializeDataAsync and OnReadData methods, and refactor NavMenu structure for better readability
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 15:54:23 +02:00
a.beging@eas-solutions.de
87f26f9367 Refactor Audit service and view: implement GetCount and LoadPage methods, update OnReadData for improved data handling
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 15:35:47 +02:00
troogs
c0c18f2ddd Refactor FsBase component: remove unused private fields and streamline OnInitializedAsync method
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m52s
2026-04-26 11:07:02 +02:00
troogs
b0866754e0 Add initial password setup functionality: implement email sending for new account password setup and update user interface to include password setup button
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 11:00:43 +02:00
troogs
870930914e Enhance user authentication and management: add unverified user check, update error messages, and improve user interface for better usability
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m52s
2026-04-26 10:28:31 +02:00
troogs
54effa67ac Remove Verified property from User entity and update related migration
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 10:25:16 +02:00
a.beging@eas-solutions.de
48ad7dda87 Enhance ProspectSortControl: add styling to TextEdit component for improved visibility
Some checks failed
Build And Push Dev Docker Image / docker (push) Failing after 3h0m4s
2026-04-24 15:14:45 +02:00
a.beging@eas-solutions.de
d09926a8b4 Enhance VerificationSettingsDialog: add success message for link copy action and improve button styling 2026-04-24 14:29:18 +02:00
a.beging@eas-solutions.de
954d57b7a6 Refactor UploadVerification page: improve layout, loading indicators, and user instructions for better clarity and usability 2026-04-24 13:45:03 +02:00
a.beging@eas-solutions.de
781da32796 Refactor NavMenu: update layout and styling for improved user experience
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m31s
2026-04-23 15:52:27 +02:00
a.beging@eas-solutions.de
b1ed916da4 Refactor Users page: update Autocomplete selection mode and fix user service methods to use OldItem for adding and updating users 2026-04-23 15:44:27 +02:00
a.beging@eas-solutions.de
94a2dbf801 Remove unused NavigationManager injection from VerificationSettingsDialog and clean up ProspectSortControl by removing unnecessary service injections 2026-04-23 15:20:39 +02:00
a.beging@eas-solutions.de
dc9276e3e9 Initialize properties with default values in Interaction, Prospect, and User classes 2026-04-23 15:19:04 +02:00
a.beging@eas-solutions.de
aba2007481 Fix class name casing for migration classes to follow naming conventions 2026-04-23 15:12:16 +02:00
a.beging@eas-solutions.de
cf4b73735b Update package references: upgrade AspNetCore.SassCompiler to 1.81.1 and MailKit to 4.16.0 2026-04-23 14:22:48 +02:00
a.beging@eas-solutions.de
cb3a2ae042 Enhance layout styles: update alignment classes for improved responsiveness and add utility classes for alignment 2026-04-23 14:09:41 +02:00
a.beging@eas-solutions.de
8ad6a143de Enhance AuthService: add application settings dependency and improve password reset email content 2026-04-23 09:49:41 +02:00
a.beging@eas-solutions.de
46d5bcd00d Refactor page titles and headings for consistency across Profile, Prospects, ProspectsDone, and ProspectsVerify pages 2026-04-23 09:49:29 +02:00
a.beging@eas-solutions.de
545f59e059 Enhance ProspectContainer: restrict interaction visibility to Ambassador users and update button icon styling 2026-04-23 09:37:52 +02:00
a.beging@eas-solutions.de
cad9617451 Update VerificationSettingsDialog: restrict image viewing button to admin and ambassador users, and enhance delete confirmation message 2026-04-23 09:17:40 +02:00
a.beging@eas-solutions.de
04084b4bf7 Refactor box-shadow styles for warning and deleted states in ProspectContainer 2026-04-23 09:12:27 +02:00
a.beging@eas-solutions.de
69516b2701 Add web app manifest and icons for Foodsharing onboarding 2026-04-23 07:52:16 +02:00
a.beging@eas-solutions.de
def8702489 Enhance ForgotPassword, Login, and ResetPassword pages: add error message display and adjust heading styles 2026-04-23 07:38:08 +02:00
a.beging@eas-solutions.de
8262c4979b Update styles for DefaultLayout and MainLayout: change background color and remove sidebar gradient
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m53s
2026-04-22 13:36:43 +02:00
a.beging@eas-solutions.de
ad9e2ae8c1 Set background color for main container and adjust styles for login and password pages 2026-04-22 13:29:25 +02:00
a.beging@eas-solutions.de
4d4648b187 Add compilation configuration for SCSS to CSS: define source and target paths 2026-04-22 13:29:06 +02:00
a.beging@eas-solutions.de
f04dba72fd Add padding to badge close button: enhance styling for better usability
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m31s
2026-04-22 07:14:11 +02:00
a.beging@eas-solutions.de
03872e8bba Add IdCheckPossible filter and UI toggle: enhance ProspectFilter and ProspectSortControl for IdCheck functionality 2026-04-22 07:08:33 +02:00
troogs
4330b53824 Enhance InteractionRow and ProspectContainer: refactor layout to use grid, improve styling, and add interaction handling
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m30s
2026-04-21 22:24:58 +02:00
troogs
7660e8ce81 Refactor ProspectContainer styles: improve SCSS structure for better readability and maintainability 2026-04-21 21:19:29 +02:00
troogs
9da0bf3a43 Add ProspectContainer styles: create SCSS file for component styling and update .gitignore 2026-04-21 21:17:37 +02:00
troogs
9983a58ba9 Add Sass compiler configuration and enable scoped CSS generation 2026-04-21 21:13:53 +02:00
troogs
3db943d652 Update Badge component in ProspectContainer: add click event and icon for verification prompt 2026-04-21 20:52:19 +02:00
a.beging@eas-solutions.de
40f0213a73 Refactor ProspectGrid styles: remove custom width variables and simplify grid layout for improved clarity
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m23s
2026-04-21 10:55:26 +02:00
a.beging@eas-solutions.de
5a4d4a7a04 Refactor ProspectGrid styles: update card width variables and adjust grid layout for improved responsiveness
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m26s
2026-04-21 08:59:06 +02:00
a.beging@eas-solutions.de
c9d46be196 Update package reference and clean up breakpoint syntax in SCSS 2026-04-21 08:00:30 +02:00
troogs
19c22e6ae8 Refactor ProspectContainer and ProspectGrid: adjust layout styles and add grid class for improved responsiveness
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m32s
2026-04-21 05:48:55 +02:00
troogs
d1852f28c8 Remove target for adding generated CSS files to web assets
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m29s
2026-04-20 22:11:51 +02:00
74 changed files with 1709 additions and 504 deletions

View File

@@ -23,6 +23,9 @@ jobs:
with: with:
dotnet-version: "9.0.x" dotnet-version: "9.0.x"
- name: Run tests
run: dotnet test
- name: Publish server project - name: Publish server project
run: dotnet publish ./FoodsharingSiegen.Server/FoodsharingSiegen.Server.csproj -c Release -o ./Publish/Server run: dotnet publish ./FoodsharingSiegen.Server/FoodsharingSiegen.Server.csproj -c Release -o ./Publish/Server

View File

@@ -23,6 +23,9 @@ jobs:
with: with:
dotnet-version: "9.0.x" dotnet-version: "9.0.x"
- name: Run tests
run: dotnet test
- name: Publish server project - name: Publish server project
run: dotnet publish ./FoodsharingSiegen.Server/FoodsharingSiegen.Server.csproj -c Release -o ./Publish/Server run: dotnet publish ./FoodsharingSiegen.Server/FoodsharingSiegen.Server.csproj -c Release -o ./Publish/Server

5
.gitignore vendored
View File

@@ -15,4 +15,7 @@ FoodsharingSiegen.Server/Shared/DefaultLayout.razor.css
FoodsharingSiegen.Server/Shared/NavMenu.razor.css FoodsharingSiegen.Server/Shared/NavMenu.razor.css
FoodsharingSiegen.Server/wwwroot/css/site.css FoodsharingSiegen.Server/wwwroot/css/site.css
FoodsharingSiegen.Server/wwwroot/css/site.min.css FoodsharingSiegen.Server/wwwroot/css/site.min.css
FoodsharingSiegen.Server/wwwroot/css/site.css.map FoodsharingSiegen.Server/wwwroot/css/site.css.map
FoodsharingSiegen.Server/Controls/ProspectContainer.razor.css
FoodsharingSiegen.Server/Controls/ProspectContainer.razor.css.map
FoodsharingSiegen.Server/Shared/DefaultLayout.razor.css.map

View File

@@ -59,7 +59,7 @@ namespace FoodsharingSiegen.Contracts.Entity
/// <summary> /// <summary>
/// Gets or sets the value of the prospect (ab) /// Gets or sets the value of the prospect (ab)
/// </summary> /// </summary>
public Prospect Prospect { get; set; } public Prospect Prospect { get; set; } = null!;
/// <summary> /// <summary>
/// Gets or sets the value of the prospect id (ab) /// Gets or sets the value of the prospect id (ab)
@@ -74,7 +74,7 @@ namespace FoodsharingSiegen.Contracts.Entity
/// <summary> /// <summary>
/// Gets or sets the value of the user (ab) /// Gets or sets the value of the user (ab)
/// </summary> /// </summary>
public User User { get; set; } public User User { get; set; } = null!;
/// <summary> /// <summary>
/// Gets or sets the value of the user id (ab) /// Gets or sets the value of the user id (ab)

View File

@@ -36,7 +36,7 @@ namespace FoodsharingSiegen.Contracts.Entity
/// <summary> /// <summary>
/// Gets or sets the value of the interactions (ab) /// Gets or sets the value of the interactions (ab)
/// </summary> /// </summary>
public IList<Interaction> Interactions { get; set; } public IList<Interaction> Interactions { get; set; } = new List<Interaction>();
/// <summary> /// <summary>
/// Gets or sets the value of the memo (ab) /// Gets or sets the value of the memo (ab)
@@ -51,7 +51,7 @@ namespace FoodsharingSiegen.Contracts.Entity
/// <summary> /// <summary>
/// Gets or sets the value of the name (ab) /// Gets or sets the value of the name (ab)
/// </summary> /// </summary>
public string Name { get; set; } public string Name { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the state of the record within the system. /// Gets or sets the state of the record within the system.

View File

@@ -20,7 +20,7 @@ namespace FoodsharingSiegen.Contracts.Entity
/// <summary> /// <summary>
/// Gets or sets the value of the encrypted password (ab) /// Gets or sets the value of the encrypted password (ab)
/// </summary> /// </summary>
public string EncryptedPassword { get; set; } public string EncryptedPassword { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the value of the force logout (ab) /// Gets or sets the value of the force logout (ab)
@@ -30,7 +30,7 @@ namespace FoodsharingSiegen.Contracts.Entity
/// <summary> /// <summary>
/// Gets or sets the value of the groups (ab) /// Gets or sets the value of the groups (ab)
/// </summary> /// </summary>
public string Groups { get; set; } public string Groups { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the value of the groups list (ab) /// Gets or sets the value of the groups list (ab)
@@ -57,12 +57,12 @@ namespace FoodsharingSiegen.Contracts.Entity
/// <summary> /// <summary>
/// Gets or sets the value of the interactions (ab) /// Gets or sets the value of the interactions (ab)
/// </summary> /// </summary>
public IList<Interaction> Interactions { get; set; } public IList<Interaction> Interactions { get; set; } = new List<Interaction>();
/// <summary> /// <summary>
/// Gets or sets the value of the mail (ab) /// Gets or sets the value of the mail (ab)
/// </summary> /// </summary>
public string Mail { get; set; } public string Mail { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the value of the memo (ab) /// Gets or sets the value of the memo (ab)
@@ -72,7 +72,7 @@ namespace FoodsharingSiegen.Contracts.Entity
/// <summary> /// <summary>
/// Gets or sets the value of the name (ab) /// Gets or sets the value of the name (ab)
/// </summary> /// </summary>
public string Name { get; set; } public string Name { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the value of the network (ab) /// Gets or sets the value of the network (ab)
@@ -110,11 +110,6 @@ namespace FoodsharingSiegen.Contracts.Entity
/// </summary> /// </summary>
public UserType Type { get; set; } public UserType Type { get; set; }
/// <summary>
/// Gets or sets the value of the verified (ab)
/// </summary>
public bool Verified { get; set; }
#endregion #endregion
#region Public Method Clone #region Public Method Clone

View File

@@ -62,7 +62,32 @@ namespace FoodsharingSiegen.Contracts.Enums
/// <summary> /// <summary>
/// The remove interaction audit type /// The remove interaction audit type
/// </summary> /// </summary>
RemoveInteraction = 100 RemoveInteraction = 100,
/// <summary>
/// The delete prospect images audit type
/// </summary>
DeleteProspectImages = 110,
/// <summary>
/// The view prospect images audit type
/// </summary>
ViewProspectImages = 120,
/// <summary>
/// The upload prospect image audit type
/// </summary>
UploadProspectImage = 130,
/// <summary>
/// The request password reset audit type
/// </summary>
RequestPasswordReset = 140,
/// <summary>
/// The change own password audit type
/// </summary>
ChangeOwnPassword = 150
#endregion Prospects #endregion Prospects
} }

View File

@@ -7,6 +7,8 @@
public bool WithoutStepInBriefing { get; set; } public bool WithoutStepInBriefing { get; set; }
public bool WithoutIdCheck { get; set; } public bool WithoutIdCheck { get; set; }
public bool IdCheckPossible { get; set; }
public bool NoActivity { get; set; } public bool NoActivity { get; set; }

View File

@@ -2,11 +2,13 @@ using FoodsharingSiegen.Contracts;
using FoodsharingSiegen.Contracts.Entity; using FoodsharingSiegen.Contracts.Entity;
using FoodsharingSiegen.Contracts.Enums; using FoodsharingSiegen.Contracts.Enums;
using FoodsharingSiegen.Contracts.Helper; using FoodsharingSiegen.Contracts.Helper;
using FoodsharingSiegen.Contracts.Model;
using FoodsharingSiegen.Server.Data; using FoodsharingSiegen.Server.Data;
using FoodsharingSiegen.Server.Service; using FoodsharingSiegen.Server.Service;
using FoodsharingSiegen.Shared.Helper; using FoodsharingSiegen.Shared.Helper;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace FoodsharingSiegen.Server.Auth namespace FoodsharingSiegen.Server.Auth
{ {
@@ -55,6 +57,11 @@ namespace FoodsharingSiegen.Server.Auth
/// </summary> /// </summary>
private readonly IMailService _mailService; private readonly IMailService _mailService;
/// <summary>
/// The application settings
/// </summary>
private readonly AppSettings _appSettings;
#endregion #endregion
#region Setup/Teardown #region Setup/Teardown
@@ -70,12 +77,14 @@ namespace FoodsharingSiegen.Server.Auth
FsContext context, FsContext context,
LocalStorageService localStorageService, LocalStorageService localStorageService,
AuthenticationStateProvider authenticationStateProvider, AuthenticationStateProvider authenticationStateProvider,
IMailService mailService) IMailService mailService,
IOptions<AppSettings> appSettings)
{ {
Context = context; Context = context;
_localStorageService = localStorageService; _localStorageService = localStorageService;
_authenticationStateProvider = authenticationStateProvider; _authenticationStateProvider = authenticationStateProvider;
_mailService = mailService; _mailService = mailService;
_appSettings = appSettings.Value;
} }
#endregion #endregion
@@ -133,6 +142,12 @@ namespace FoodsharingSiegen.Server.Auth
if (_user != null) if (_user != null)
{ {
if (_user.Type == UserType.Unverified)
{
_user = null;
return new OperationResult(new Exception("Anmeldung nicht möglich."));
}
var serializedToken = AuthHelper.CreateToken(_user); var serializedToken = AuthHelper.CreateToken(_user);
await _localStorageService.SetItem(StorageKeys.TokenKey, serializedToken); await _localStorageService.SetItem(StorageKeys.TokenKey, serializedToken);
@@ -147,7 +162,7 @@ namespace FoodsharingSiegen.Server.Auth
return new OperationResult(); return new OperationResult();
} }
return new OperationResult(new Exception("Benutzername oder Passwort falsch")); return new OperationResult(new Exception("E-Mail-Adresse oder Passwort ist ungültig."));
} }
#endregion #endregion
@@ -197,6 +212,34 @@ namespace FoodsharingSiegen.Server.Auth
#region Password Recovery #region Password Recovery
public async Task InitiateInitialPasswordSetup(string email, string baseUri)
{
if (string.IsNullOrWhiteSpace(email)) return;
var user = await Context.Users!.FirstOrDefaultAsync(x => x.Mail.ToLower() == email.ToLower());
if (user == null) return; // Do not leak existence
var resetToken = Guid.NewGuid().ToString("N");
user.ResetToken = resetToken;
user.ResetTokenExpiry = DateTime.UtcNow.AddDays(7);
await Context.SaveChangesAsync();
var resetLink = $"{baseUri.TrimEnd('/')}/reset-password/{resetToken}";
var mailBody = $"""
Hallo {user.Name},<br>
<br>
für dich wurde ein neues Konto bei {_appSettings.Terms.Title} erstellt. <br>
<br>
Um dein Passwort festzulegen, klicke bitte auf den folgenden Link (dieser ist 7 Tage gültig):<br>
<a href='{resetLink}'>{resetLink}</a><br>
<br>
Viele Grüße<br>Dein Team {_appSettings.Terms.Title}
""";
await _mailService.SendEmailAsync(user.Mail, "Passwort festlegen", mailBody);
}
public async Task InitiatePasswordReset(string email, string baseUri) public async Task InitiatePasswordReset(string email, string baseUri)
{ {
if (string.IsNullOrWhiteSpace(email)) return; if (string.IsNullOrWhiteSpace(email)) return;
@@ -208,10 +251,28 @@ namespace FoodsharingSiegen.Server.Auth
user.ResetToken = resetToken; user.ResetToken = resetToken;
user.ResetTokenExpiry = DateTime.UtcNow.AddMinutes(30); user.ResetTokenExpiry = DateTime.UtcNow.AddMinutes(30);
Context.Audits?.Add(new Audit
{
Created = DateTime.Now,
Type = AuditType.RequestPasswordReset,
UserID = user.Id,
Data1 = user.Mail
});
await Context.SaveChangesAsync(); await Context.SaveChangesAsync();
var resetLink = $"{baseUri.TrimEnd('/')}/reset-password/{resetToken}"; var resetLink = $"{baseUri.TrimEnd('/')}/reset-password/{resetToken}";
var mailBody = $"Hallo {user.Name},<br><br>Um dein Passwort zurückzusetzen, klicke bitte auf den folgenden Link (dieser ist 30 Minuten gültig):<br><a href='{resetLink}'>{resetLink}</a><br><br>Viele Grüße<br>Dein Foodsharing Team"; var mailBody = $"""
Hallo {user.Name},<br>
<br>
für dein Konto wurde eine Anfrage zum Zurücksetzen des Passworts gestellt. <br>
Wenn du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren und dein Passwort bleibt unverändert.<br>
<br>
Um dein Passwort zurückzusetzen, klicke bitte auf den folgenden Link (dieser ist 30 Minuten gültig):<br>
<a href='{resetLink}'>{resetLink}</a><br>
<br>
Viele Grüße<br>Dein Team {_appSettings.Terms.Title}
""";
await _mailService.SendEmailAsync(user.Mail, "Passwort zurücksetzen", mailBody); await _mailService.SendEmailAsync(user.Mail, "Passwort zurücksetzen", mailBody);
} }

View File

@@ -71,12 +71,6 @@ namespace FoodsharingSiegen.Server.BaseClasses
#endregion #endregion
#region Private Fields
private bool _dataInitialized;
#endregion
#region Override OnInitializedAsync #region Override OnInitializedAsync
/// <summary> /// <summary>
@@ -85,30 +79,12 @@ namespace FoodsharingSiegen.Server.BaseClasses
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await AuthService.Initialize(); await AuthService.Initialize();
await InitializeDataAsync();
await base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
#endregion #endregion
#region Override SetParametersAsync
/// <inheritdoc />
public override async Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
if (!_dataInitialized)
{
_dataInitialized = true;
await InitializeDataAsync();
}
// Da die Parameter bereits gesetzt wurden, kann die Basisklasse am Ende aufgerufen werden.
await base.SetParametersAsync(ParameterView.Empty);
}
#endregion
#region Protected Method InitializeDataAsync #region Protected Method InitializeDataAsync
/// <summary> /// <summary>

View File

@@ -5,52 +5,63 @@
@inherits FsBase @inherits FsBase
@{ @{
var colorClass = "";
if(Done) colorClass = "interaction--color-done";
var rowClass = ""; var rowClass = "";
if (Done) rowClass += " done"; if (Done) rowClass += " done";
if (NotNeeded) rowClass += " notneeded"; if (NotNeeded) rowClass += " notneeded";
if (Alert) rowClass += " fs-alert"; if (Alert) rowClass += " fs-alert";
} }
<tr class="@rowClass" style="border-top: 5px solid transparent;"> @if(!AllowInteraction || Prospect is {Complete: true })
<th class="text-center align-top pr-2"> {
<i class="@IconClass"></i> <Button Size="Size.Small" Disabled="true">
</th> <i class="@ButtonIconClass"></i>
<th class="pr-2 align-top" style="white-space: nowrap;">@Type.Translate(AppSettings):</th> </Button>
<td class="align-top d-flex flex-column"> }
@if (Interactions.Count > 0) else
{ {
foreach (var interaction in Interactions) @if(Interactions.Count == 0 || Multiple)
{
if (Multiple) ButtonIconClass = "fa-solid fa-plus";
<Button Size="Size.Small" Clicked="@(async () => { if (AddClick != null) await AddClick(Type); })"><i class="@ButtonIconClass" style="color: #64ae24;"></i></Button>
} else {
<Button Size="Size.Small" Clicked="@(async () => { await RemoveFirstAsync(Type); })"><i class="fa-solid fa-times" style="color: rgb(153, 0, 0);"></i></Button>
}
}
<div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis">
<div class="@colorClass" style="display: inline-block;"><i class="@IconClass"></i> @Type.Translate(AppSettings)</div>
@if(Interactions.Count > 0)
{
<span title="@Interactions.First().User.Memo"> (@Interactions.First().User.Name</span> <span>@Interactions.First().Date.ToShortDateString())</span>
}
</div>
@if(Multiple && Interactions.Count > 0)
{
@foreach (var interaction in Interactions)
{
<div class="d-flex justify-content-end">
@if ((Prospect is not { Complete: true } || interaction.Type == InteractionType.Complete) && AllowInteraction)
{ {
<div style="padding-bottom: .5rem;"> <a style="display: inline-block;"" href=""><i class="fa-solid fa-square-xmark" @onclick="async () => { if (RemoveClick != null) await RemoveClick.Invoke(interaction.Id); }" @onclick:preventDefault></i></a>
<div style="white-space: nowrap;"> } else {
<span title="@interaction.User.Memo">@interaction.User.Name</span> (@interaction.Date.ToShortDateString()) <span>&bull;</span>
@if ((Prospect is not { Complete: true } || interaction.Type == InteractionType.Complete) && AllowInteraction)
{
<span>&nbsp;<a href=""><i class="fa-solid fa-square-xmark" @onclick="async () => { if (RemoveClick != null) await RemoveClick.Invoke(interaction.Id); }" @onclick:preventDefault></i></a></span>
}
</div>
<div>
@FeedbackBuilder(interaction)
</div>
<div>
@if (!string.IsNullOrWhiteSpace(interaction.FeedbackInfo))
{
<span>(<i>@interaction.FeedbackInfo</i>)</span>
}
</div>
</div>
} }
} </div>
<div>
@if (Prospect is not {Complete: true } && (Interactions.Count == 0 || Multiple) && AllowInteraction) <div>
{ <span title="@interaction.User.Memo">@interaction.User.Name (@interaction.Date.ToShortDateString())</span>
if (Multiple) ButtonIconClass = "fa-solid fa-plus"; @if (!string.IsNullOrWhiteSpace(interaction.FeedbackInfo))
<div class="m-auto"> {
<Button Size="Size.Small" Clicked="@(async () => { if (AddClick != null) await AddClick(Type); })"><i class="@ButtonIconClass" style="color: #64ae24;"></i></Button> <span>(<i>@interaction.FeedbackInfo</i>)</span>
}
</div> </div>
} @FeedbackBuilder(interaction)
</td> </div>
</tr> }
}

View File

@@ -94,6 +94,15 @@ namespace FoodsharingSiegen.Server.Controls
#endregion #endregion
private async Task RemoveFirstAsync(InteractionType type)
{
if (Prospect != null && RemoveClick != null)
{
var interaction = Interactions.FirstOrDefault(x => x.Type == type);
if (interaction != null) await RemoveClick(interaction.Id);
}
}
private MarkupString FeedbackBuilder(Interaction interaction) private MarkupString FeedbackBuilder(Interaction interaction)
{ {
var infoList = new List<string>(); var infoList = new List<string>();

View File

@@ -51,8 +51,7 @@
</Alert> </Alert>
} }
<table class="flex-column" style="width: 100%;"> <div class="interaction-grid mb-3">
<InteractionRow <InteractionRow
Prospect="Prospect" Prospect="Prospect"
Type="InteractionType.Welcome" Type="InteractionType.Welcome"
@@ -63,12 +62,6 @@
IconClass="fa-solid fa-handshake-simple"> IconClass="fa-solid fa-handshake-simple">
</InteractionRow> </InteractionRow>
<tr>
<td colspan="3">
<hr style="margin: 10px 0;">
</td>
</tr>
@if (!AppSettings.DisableStepIn) @if (!AppSettings.DisableStepIn)
{ {
<InteractionRow <InteractionRow
@@ -94,12 +87,6 @@
IconClass="fa-solid fa-basket-shopping"> IconClass="fa-solid fa-basket-shopping">
</InteractionRow> </InteractionRow>
<tr>
<td colspan="3">
<hr style="margin: 10px 0;">
</td>
</tr>
<InteractionRow <InteractionRow
Prospect="Prospect" Prospect="Prospect"
Type="InteractionType.ReleasedForVerification" Type="InteractionType.ReleasedForVerification"
@@ -145,14 +132,14 @@
IconClass="fa-solid fa-user-check"> IconClass="fa-solid fa-user-check">
</InteractionRow> </InteractionRow>
} }
</div>
</table>
<div class="flex-grow-1"></div> <div class="flex-grow-1"></div>
@if(Prospect?.Images?.Count > 0) @if(Prospect?.Images?.Count > 0)
{<div class="text-center mt-3"> {
<Badge Color="Color.Info" Style="margin-bottom: 0.5rem;">Perso-Check möglich</Badge></div> <div class="text-center mt-3">
<Badge Color="Color.Info" Style="margin-bottom: 0.5rem; cursor: pointer;" @onclick="OpenVerificationDialogAsync"><i class="fa-solid fa-address-card"></i>-Prüfung möglich</Badge></div>
} }
<div class="text-center d-flex justify-content-center gap-2 mt-1"> <div class="text-center d-flex justify-content-center gap-2 mt-1">
@@ -167,7 +154,7 @@
</Button> </Button>
@if(StateFilter > ProspectStateFilter.OnBoarding) @if(StateFilter > ProspectStateFilter.OnBoarding && CurrentUser.IsInGroup(UserGroup.Ambassador))
{ {
@if(Prospect?.Complete != true) @if(Prospect?.Complete != true)
{ {
@@ -186,7 +173,12 @@
title="Fertigstellen rückgängig" title="Fertigstellen rückgängig"
Clicked="@(() => AddInteraction(InteractionType.Complete))" Clicked="@(() => AddInteraction(InteractionType.Complete))"
Visibility="@(CurrentUser.IsInGroup(UserGroup.Ambassador) ? Visibility.Default : Visibility.Invisible)" Visibility="@(CurrentUser.IsInGroup(UserGroup.Ambassador) ? Visibility.Default : Visibility.Invisible)"
><i class="fa-solid fa-flag"></i>
>
<span class="fa-stack" style="vertical-align: top;">
<i class="fa-solid fa-slash fa-xl"></i>
<i class="fa-solid fa-flag fa-stack-1x"></i>
</span>
</Button> </Button>
} }
} }

View File

@@ -43,7 +43,20 @@ namespace FoodsharingSiegen.Server.Controls
{ {
var headerText = $"{type.Translate(AppSettings)} für {Prospect.Name} eintragen"; var headerText = $"{type.Translate(AppSettings)} für {Prospect.Name} eintragen";
await InteractionDialog.ShowAsync(ModalService, new(type, Prospect.Id, headerText, OnDataChanged)); Func<Task> onSuccess = async () =>
{
if (type == InteractionType.IdCheck && Prospect.Images != null && Prospect.Images.Count > 0)
{
await ConfirmDialog.ShowAsync(ModalService, "Personalausweisbilder löschen?", $"Möchtest du die Personalausweisbilder von {Prospect.Name} löschen? Diese werden für die weitere Bearbeitung nicht mehr benötigt und enthalten persönliche Daten.", async () =>
{
var result = await ProspectService.DeleteVerificationImagesAsync(Prospect.Id);
await OnDataChanged();
});
}
await OnDataChanged();
};
await InteractionDialog.ShowAsync(ModalService, new(type, Prospect.Id, headerText, onSuccess));
} }
} }

View File

@@ -1,57 +0,0 @@
::deep a,
::deep a.invert:hover{
color: #64ae24;
}
::deep a.invert,
::deep a:hover {
color: #533a20;
}
.green {
color: #64ae24;
}
.pc-main {
display: flex;
flex-direction: column;
flex-basis: 0;
flex-grow: 1;
max-width: 480px;
border-radius: 12px;
margin: 15px;
padding: 1rem 1rem 0 1rem;
}
@media (max-width: 576px) {
.pc-main {
margin-left: 0;
margin-right: 0;
padding: .5rem .5rem 0 .5rem;
}
}
.pc-main.warning {
-webkit-box-shadow: 0 0 9px 4px rgba(214,100,23,0.87);
-moz-box-shadow: 0 0 9px 4px rgba(214,100,23,0.87);
box-shadow: 0 0 9px 4px rgba(214,100,23,0.87);
}
.pc-main.deleted {
-webkit-box-shadow: 0 0 9px 4px rgb(214 23 23 / 87%);
-moz-box-shadow: 0 0 9px 4px rgb(214 23 23 / 87%);
box-shadow: 0 0 9px 4px rgb(214 23 23 / 87%);
}
.complete {
background: #76ff003b;
}
i.link {
cursor: pointer; color: #64ae24;
}
i.link:hover {
color: #000;
}

View File

@@ -0,0 +1,69 @@
::deep a {
color: #64ae24;
display: block;
&.invert,
&:hover {
color: #533a20;
}
&.invert:hover {
color: #64ae24;
}
}
.green {
color: #64ae24;
}
.pc-main {
background-color: #FFF;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
max-width: none;
border-radius: 12px;
margin: 0;
padding: 1rem 1rem 0 1rem;
@media (max-width: 576px) {
padding: .5rem .5rem 0 .5rem;
}
&.warning {
box-shadow: rgb(255 145 0 / 36%) 0px 8px 10px 1px, rgb(255 145 0 / 44%) 0px 3px 14px 2px, rgb(255 145 0 / 49%) 0px 5px 5px -3px !important;
}
&.deleted {
box-shadow: rgba(255, 0, 0, 0.36) 0px 8px 10px 1px, rgba(255, 0, 0, 0.44) 0px 3px 14px 2px, rgba(255, 0, 0, 0.49) 0px 5px 5px -3px !important;
}
}
.complete {
background: #76ff003b;
}
i.link {
cursor: pointer;
color: #64ae24;
&:hover {
color: #000;
}
}
.interaction-grid {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: .5rem;
::deep .interaction {
&--color {
&-done{
color: #64ae24;
}
}
}
}

View File

@@ -7,12 +7,14 @@
[Parameter] public Func<Task>? OnDataChanged { get; set; } [Parameter] public Func<Task>? OnDataChanged { get; set; }
[Parameter] public string GridClass { get; set; } = string.Empty;
} }
<h6>@(Prospects?.Count ?? 0) Ergebnisse</h6> <h6>@(Prospects?.Count ?? 0) Ergebnisse</h6>
@if (Prospects?.Any() == true) @if (Prospects?.Any() == true)
{ {
<div class="row m-0"> <div class="prospect-grid @GridClass">
<Repeater Items="@Prospects"> <Repeater Items="@Prospects">
<ProspectContainer <ProspectContainer
Prospect="context" Prospect="context"

View File

@@ -0,0 +1,8 @@
.prospect-grid {
display: grid;
gap: 1rem;
width: 100%;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
justify-content: center;
align-items: stretch;
}

View File

@@ -10,7 +10,7 @@
<i class="fa-solid fa-sort"></i> <i class="fa-solid fa-sort"></i>
</Button> </Button>
<Button Color="Color.Primary" <Button Color="Color.Info"
Width="Width.Px(50)" Width="Width.Px(50)"
Height="Height.Px(50)" Height="Height.Px(50)"
title="Filtern" title="Filtern"
@@ -20,7 +20,7 @@
</Button> </Button>
<div style="flex-grow: 1;" class="mt-3"> <div style="flex-grow: 1;" class="mt-3">
<TextEdit Text="@Filter.Text" TextChanged="TextChangedAsync" Placeholder="Suchen..." Debounce="true" DebounceInterval="200" /> <TextEdit Text="@Filter.Text" TextChanged="TextChangedAsync" Placeholder="Suchen..." Debounce="true" DebounceInterval="200" Style="border: 1px solid #64ae24; background: #fff;" Class="pl-2" />
</div> </div>
<div class="badge-row mt-1 mb-3"> <div class="badge-row mt-1 mb-3">
@@ -49,4 +49,8 @@
{ {
<Badge class="mr-1 mb-1" Color="Color.Info" Closable="true" CloseClicked="@EventCallback.Factory.Create(this, () => DisableFilterAsync(nameof(Filter.DeletedOnly)))">Gelöschte</Badge> <Badge class="mr-1 mb-1" Color="Color.Info" Closable="true" CloseClicked="@EventCallback.Factory.Create(this, () => DisableFilterAsync(nameof(Filter.DeletedOnly)))">Gelöschte</Badge>
} }
@if(Filter.IdCheckPossible)
{
<Badge class="mr-1 mb-1" Color="Color.Info" Closable="true" CloseClicked="@EventCallback.Factory.Create(this, () => DisableFilterAsync(nameof(Filter.IdCheckPossible)))">Perso-Prüfung möglich</Badge>
}
</div> </div>

View File

@@ -10,11 +10,7 @@ using Microsoft.AspNetCore.Components;
namespace FoodsharingSiegen.Server.Controls; namespace FoodsharingSiegen.Server.Controls;
public partial class ProspectSortControl public partial class ProspectSortControl
{ {
[Inject] private IModalService ModalService { get; set; } = null!;
[Inject] private LocalStorageService LocalStorageService { get; set; } = null!;
[Parameter] [Parameter]
public ProspectSortOption CurrentSort { get; set; } = ProspectSortOption.NameAscending; public ProspectSortOption CurrentSort { get; set; } = ProspectSortOption.NameAscending;
@@ -78,6 +74,7 @@ public partial class ProspectSortControl
case nameof(Filter.RecentActivity): Filter.RecentActivity = false; break; case nameof(Filter.RecentActivity): Filter.RecentActivity = false; break;
case nameof(Filter.NoActivity): Filter.NoActivity = false; break; case nameof(Filter.NoActivity): Filter.NoActivity = false; break;
case nameof(Filter.DeletedOnly): Filter.DeletedOnly = false; break; case nameof(Filter.DeletedOnly): Filter.DeletedOnly = false; break;
case nameof(Filter.IdCheckPossible): Filter.IdCheckPossible = false; break;
} }
await FilterChanged.InvokeAsync(Filter); await FilterChanged.InvokeAsync(Filter);
} }

View File

@@ -37,6 +37,16 @@ namespace FoodsharingSiegen.Server.Data
return $"hat dem Neuling {audit.Data1} folgendes hinzugefügt: {audit.Data2}"; return $"hat dem Neuling {audit.Data1} folgendes hinzugefügt: {audit.Data2}";
case AuditType.RemoveInteraction: case AuditType.RemoveInteraction:
return $"hat eine Interaktion bei {audit.Data1} gelöscht."; return $"hat eine Interaktion bei {audit.Data1} gelöscht.";
case AuditType.DeleteProspectImages:
return $"hat die Bilder von {audit.Data1} gelöscht.";
case AuditType.ViewProspectImages:
return $"hat die Bilder von {audit.Data1} angesehen.";
case AuditType.UploadProspectImage:
return $"hat ein Bild für {audit.Data1} hochgeladen.";
case AuditType.RequestPasswordReset:
return $"hat ein Passwort-Reset für {audit.Data1} angefordert.";
case AuditType.ChangeOwnPassword:
return $"hat das eigene Passwort geändert.";
case AuditType.None: case AuditType.None:
default: default:
return $"{audit.Data1}, {audit.Data2}"; return $"{audit.Data1}, {audit.Data2}";

View File

@@ -47,7 +47,10 @@ namespace FoodsharingSiegen.Server.Data
/// <param name="options">The options (ab)</param> /// <param name="options">The options (ab)</param>
public FsContext(DbContextOptions<FsContext> options) : base(options) public FsContext(DbContextOptions<FsContext> options) : base(options)
{ {
Database.Migrate(); if (Database.IsRelational())
{
Database.Migrate();
}
} }
#endregion #endregion

View File

@@ -44,7 +44,11 @@ namespace FoodsharingSiegen.Server.Data.Service
Data1 = data1, Data1 = data1,
Data2 = data2 Data2 = data2
}; };
Console.WriteLine();
Console.WriteLine(DateTime.Now.ToString() + " " + CurrentUser?.Name + " " + AuditHelper.CreateText(audit));
Console.WriteLine();
Context.Audits?.Add(audit); Context.Audits?.Add(audit);
var saveR = await Context.SaveChangesAsync(); var saveR = await Context.SaveChangesAsync();
@@ -61,29 +65,56 @@ namespace FoodsharingSiegen.Server.Data.Service
#endregion #endregion
#region Public Method Load #region Public Method GetCount
/// <summary> /// <summary>
/// Loads the count (a. beging, 23.05.2022) /// Gets the total count (ab)
/// </summary> /// </summary>
/// <param name="count">The count</param>
/// <param name="type">The type</param> /// <param name="type">The type</param>
/// <returns>A task containing an operation result of list audit</returns> /// <returns>A task containing an operation result of count</returns>
public async Task<OperationResult<List<Audit>>> Load(int count, AuditType? type = null) public async Task<OperationResult<int>> GetCount(AuditType? type = null)
{ {
try try
{ {
await Task.CompletedTask; var query = Context.Audits?.AsQueryable();
var query = Context.Audits?.Include(x => x.User).OrderByDescending(x => x.Created).AsQueryable();
if (count > 0)
query = query?.Take(count);
if (type != null) if (type != null)
query = query?.Where(x => x.Type == type); query = query?.Where(x => x.Type == type);
var mat = query?.ToList(); if (query == null) return new(0);
var count = await query.CountAsync();
return new(count);
}
catch (Exception e)
{
return new(e);
}
}
#endregion
#region Public Method LoadPage
/// <summary>
/// Loads the page of audits (ab)
/// </summary>
/// <param name="skip">The skip count</param>
/// <param name="take">The take count</param>
/// <param name="type">The type</param>
/// <returns>A task containing an operation result of list audit</returns>
public async Task<OperationResult<List<Audit>>> LoadPage(int skip, int take, AuditType? type = null)
{
try
{
var query = Context.Audits?.Include(x => x.User).OrderByDescending(x => x.Created).AsQueryable();
if (type != null)
query = query?.Where(x => x.Type == type);
query = query?.Skip(skip).Take(take);
var mat = await query!.ToListAsync();
if (mat != null) return new(mat); if (mat != null) return new(mat);

View File

@@ -290,6 +290,8 @@ namespace FoodsharingSiegen.Server.Data.Service
await Context.ProspectImages!.AddAsync(image); await Context.ProspectImages!.AddAsync(image);
await Context.SaveChangesAsync(); await Context.SaveChangesAsync();
await AuditService.Insert(AuditType.UploadProspectImage, prospect.Name);
return new(); return new();
} }
catch (Exception e) catch (Exception e)
@@ -308,6 +310,16 @@ namespace FoodsharingSiegen.Server.Data.Service
.OrderBy(x => x.Created) .OrderBy(x => x.Created)
.ToListAsync(); .ToListAsync();
var prospectName = await Context.Prospects!
.Where(x => x.Id == prospectId)
.Select(x => x.Name)
.FirstOrDefaultAsync();
if (!string.IsNullOrEmpty(prospectName))
{
await AuditService.Insert(AuditType.ViewProspectImages, prospectName);
}
return new(images); return new(images);
} }
catch (Exception e) catch (Exception e)
@@ -329,6 +341,7 @@ namespace FoodsharingSiegen.Server.Data.Service
if (prospect != null) if (prospect != null)
{ {
prospect.VerificationToken = null; // Clear token when images are deleted prospect.VerificationToken = null; // Clear token when images are deleted
await AuditService.Insert(AuditType.DeleteProspectImages, prospect.Name);
} }
await Context.SaveChangesAsync(); await Context.SaveChangesAsync();

View File

@@ -105,6 +105,13 @@ namespace FoodsharingSiegen.Server.Data.Service
var user = await Context.Users!.Include(x => x.Interactions).FirstOrDefaultAsync(x => x.Id == userId); var user = await Context.Users!.Include(x => x.Interactions).FirstOrDefaultAsync(x => x.Id == userId);
if (user == null) return new(new Exception("User not found")); if (user == null) return new(new Exception("User not found"));
if (user.Type == UserType.Admin)
{
var adminCount = await Context.Users!.CountAsync(x => x.Type == UserType.Admin && x.Id != userId);
if (adminCount == 0)
return new(new Exception("Der letzte Administrator kann nicht gelöscht werden."));
}
// Interaktionen vom aktuellen Nutzer übernehmen // Interaktionen vom aktuellen Nutzer übernehmen
if(CurrentUser?.Id != null) if(CurrentUser?.Id != null)
foreach (var userInteraction in user.Interactions) foreach (var userInteraction in user.Interactions)
@@ -151,8 +158,14 @@ namespace FoodsharingSiegen.Server.Data.Service
if (saveR < 1) return new(new Exception("Fehler beim Speichern")); if (saveR < 1) return new(new Exception("Fehler beim Speichern"));
var auditData = CurrentUser?.Id == user.Id ? "sich selbst" : user.Mail; if (CurrentUser?.Id == user.Id)
await AuditService.Insert(AuditType.SetUserPassword, auditData); {
await AuditService.Insert(AuditType.ChangeOwnPassword);
}
else
{
await AuditService.Insert(AuditType.SetUserPassword, user.Mail);
}
return new(); return new();
} }
@@ -178,8 +191,14 @@ namespace FoodsharingSiegen.Server.Data.Service
var entityUser = await Context.Users!.FirstOrDefaultAsync(x => x.Id == user.Id); var entityUser = await Context.Users!.FirstOrDefaultAsync(x => x.Id == user.Id);
if (entityUser == null) return new(new Exception("User not found")); if (entityUser == null) return new(new Exception("User not found"));
if (entityUser.Type == UserType.Admin && user.Type != UserType.Admin)
{
var adminCount = await Context.Users!.CountAsync(x => x.Type == UserType.Admin && x.Id != user.Id);
if (adminCount == 0)
return new(new Exception("Der Typ des letzten Administrators kann nicht geändert werden."));
}
if (entityUser.Mail != user.Mail || if (entityUser.Mail != user.Mail ||
entityUser.Verified != user.Verified ||
entityUser.Type != user.Type || entityUser.Type != user.Type ||
entityUser.Groups != user.Groups) entityUser.Groups != user.Groups)
entityUser.ForceLogout = true; entityUser.ForceLogout = true;
@@ -188,7 +207,6 @@ namespace FoodsharingSiegen.Server.Data.Service
entityUser.Mail = user.Mail; entityUser.Mail = user.Mail;
entityUser.Name = user.Name; entityUser.Name = user.Name;
entityUser.Type = user.Type; entityUser.Type = user.Type;
entityUser.Verified = user.Verified;
entityUser.Groups = user.Groups; entityUser.Groups = user.Groups;
entityUser.Network = user.Network; entityUser.Network = user.Network;

View File

@@ -127,9 +127,10 @@ namespace FoodsharingSiegen.Server.Dialogs
Interaction.UserID = CurrentUser.Id; Interaction.UserID = CurrentUser.Id;
var addR = await ProspectService.AddInteraction(Interaction); var addR = await ProspectService.AddInteraction(Interaction);
if (addR.Success && OnSuccess != null) await OnSuccess.Invoke();
await ModalService.Hide(); await ModalService.Hide();
if (addR.Success && OnSuccess != null) await OnSuccess.Invoke();
} }
#endregion #endregion

View File

@@ -20,6 +20,15 @@
</div> </div>
} }
@if(new[] { ProspectStateFilter.OnBoarding, ProspectStateFilter.Verification }.Contains(StateFilter))
{
<div style="margin-left: 1rem;">
<Switch TValue="bool" Checked="ModalFilter.IdCheckPossible" CheckedChanged="(v) => { ModalFilter.IdCheckPossible = v; StateHasChanged(); }" Color="Color.Primary">
Perso-Prüfung möglich
</Switch>
</div>
}
@if (new[] { ProspectStateFilter.All, ProspectStateFilter.OnBoarding, ProspectStateFilter.Verification }.Contains(StateFilter)) @if (new[] { ProspectStateFilter.All, ProspectStateFilter.OnBoarding, ProspectStateFilter.Verification }.Contains(StateFilter))
{ {
<div style="margin-left: 1rem;"> <div style="margin-left: 1rem;">

View File

@@ -24,7 +24,8 @@ namespace FoodsharingSiegen.Server.Dialogs
WithoutIdCheck = CurrentFilter.WithoutIdCheck, WithoutIdCheck = CurrentFilter.WithoutIdCheck,
NoActivity = CurrentFilter.NoActivity, NoActivity = CurrentFilter.NoActivity,
RecentActivity = CurrentFilter.RecentActivity, RecentActivity = CurrentFilter.RecentActivity,
DeletedOnly = CurrentFilter.DeletedOnly DeletedOnly = CurrentFilter.DeletedOnly,
IdCheckPossible = CurrentFilter.IdCheckPossible
}; };
base.OnInitialized(); base.OnInitialized();
} }

View File

@@ -1,4 +1,5 @@
@using Blazorise @using Blazorise
@using FoodsharingSiegen.Contracts.Enums
@inherits FsBase @inherits FsBase
<div class="mt-1 mb-3"> <div class="mt-1 mb-3">
@@ -9,8 +10,12 @@
<div class="border p-3 rounded"> <div class="border p-3 rounded">
<p class="mb-2 text-muted">Kopiere diesen Link und teile ihn mit <strong>@Prospect?.Name</strong>:</p> <p class="mb-2 text-muted">Kopiere diesen Link und teile ihn mit <strong>@Prospect?.Name</strong>:</p>
<div> <div>
<input type="text" class="form-control" value="@LinkUrl" readonly /><br /> <input type="text" class="form-control" value="@LinkUrl" readonly />
<Button Color="Color.Secondary" Clicked="CopyLink" Style="width: 100%;"> @if(CopySuccess)
{
<div class="alert alert-success py-2 mt-2 mb-0">Link wurde in die Zwischenablage kopiert!</div>
}
<Button Color="Color.Secondary" Clicked="CopyLink" Style="width: 100%;" Class="mt-2">
<i class="fa-solid fa-copy mr-2"></i>Link kopieren <i class="fa-solid fa-copy mr-2"></i>Link kopieren
</Button> </Button>
</div> </div>
@@ -22,9 +27,13 @@
<i class="fa-solid fa-link me-2"></i> Upload-Link erstellen / anzeigen <i class="fa-solid fa-link me-2"></i> Upload-Link erstellen / anzeigen
</Button> </Button>
<Button Color="Color.Success" Clicked="@ViewImagesAsync" Disabled="@(ImageCount == 0)"> @if(CurrentUser.IsAdmin() || CurrentUser.IsInGroup(UserGroup.Ambassador))
<i class="fa-solid fa-images me-2"></i> Hochgeladene Bilder ansehen (@ImageCount) {
</Button> <Button Color="Color.Success" Clicked="@ViewImagesAsync" Disabled="@(ImageCount == 0)">
<i class="fa-solid fa-images me-2"></i> Hochgeladene Bilder ansehen (@ImageCount)
</Button>
}
<Button Color="Color.Danger" Clicked="@DeleteImagesAsync" Disabled="@(ImageCount == 0)"> <Button Color="Color.Danger" Clicked="@DeleteImagesAsync" Disabled="@(ImageCount == 0)">
<i class="fa-solid fa-trash-can me-2"></i> Alle Bilder löschen <i class="fa-solid fa-trash-can me-2"></i> Alle Bilder löschen

View File

@@ -12,9 +12,6 @@ namespace FoodsharingSiegen.Server.Dialogs
[Inject] [Inject]
public ProspectService ProspectService { get; set; } = null!; public ProspectService ProspectService { get; set; } = null!;
[Inject]
public NavigationManager NavigationManager { get; set; } = null!;
[Inject] [Inject]
public IJSRuntime JS { get; set; } = null!; public IJSRuntime JS { get; set; } = null!;
@@ -26,6 +23,8 @@ namespace FoodsharingSiegen.Server.Dialogs
private int ImageCount { get; set; } = 0; private int ImageCount { get; set; } = 0;
private bool ShowLinkPanel { get; set; } = false; private bool ShowLinkPanel { get; set; } = false;
private bool CopySuccess { get; set; } = false;
private string LinkUrl { get; set; } = string.Empty; private string LinkUrl { get; set; } = string.Empty;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -80,6 +79,7 @@ namespace FoodsharingSiegen.Server.Dialogs
private async Task CopyLink() private async Task CopyLink()
{ {
await JS.InvokeVoidAsync("navigator.clipboard.writeText", LinkUrl); await JS.InvokeVoidAsync("navigator.clipboard.writeText", LinkUrl);
CopySuccess = true;
} }
private async Task ViewImagesAsync() private async Task ViewImagesAsync()
@@ -95,7 +95,7 @@ namespace FoodsharingSiegen.Server.Dialogs
{ {
if (Prospect == null) return; if (Prospect == null) return;
await ConfirmDialog.ShowAsync(ModalService, "Bilder Löschen", "Sollen alle Identitätsprüfungsbilder dieses Users unwiderruflich gelöscht werden?", async () => await ConfirmDialog.ShowAsync(ModalService, "Bilder Löschen", $"Sollen alle Identitätsprüfungsbilder von {Prospect.Name} unwiderruflich gelöscht werden?", async () =>
{ {
var result = await ProspectService.DeleteVerificationImagesAsync(Prospect.Id); var result = await ProspectService.DeleteVerificationImagesAsync(Prospect.Id);
if (result.Success) if (result.Success)

View File

@@ -7,7 +7,11 @@
<Button Color="Color.Secondary" Class="position-absolute start-0 top-0 m-2 z-3 text-white bg-dark border-0 rounded-circle w-40px h-40px fs-4 lh-1" Clicked="() => SelectedImageIndex = null"> <Button Color="Color.Secondary" Class="position-absolute start-0 top-0 m-2 z-3 text-white bg-dark border-0 rounded-circle w-40px h-40px fs-4 lh-1" Clicked="() => SelectedImageIndex = null">
<i class="fa-solid fa-close"></i> <i class="fa-solid fa-close"></i>
</Button> </Button>
<img src="@(_images[SelectedImageIndex.Value])" class="img-fluid rounded" style="max-height: 80vh;" />
<a href="@(_images[SelectedImageIndex.Value])" target="_blank" download>
<img src="@(_images[SelectedImageIndex.Value])" class="img-fluid rounded" style="max-height: 80vh;" />
</a>
<div class="d-flex justify-content-between position-absolute top-50 start-0 w-100"> <div class="d-flex justify-content-between position-absolute top-50 start-0 w-100">
<Button Color="Color.Dark" Class="rounded-circle" Disabled="@(SelectedImageIndex.Value == 0)" Clicked="() => SelectedImageIndex--"><i class="fa-solid fa-chevron-left"></i></Button> <Button Color="Color.Dark" Class="rounded-circle" Disabled="@(SelectedImageIndex.Value == 0)" Clicked="() => SelectedImageIndex--"><i class="fa-solid fa-chevron-left"></i></Button>
<Button Color="Color.Dark" Class="rounded-circle" Disabled="@(SelectedImageIndex.Value == _images.Count - 1)" Clicked="() => SelectedImageIndex++"><i class="fa-solid fa-chevron-right"></i></Button> <Button Color="Color.Dark" Class="rounded-circle" Disabled="@(SelectedImageIndex.Value == _images.Count - 1)" Clicked="() => SelectedImageIndex++"><i class="fa-solid fa-chevron-right"></i></Button>

View File

@@ -27,16 +27,9 @@
<DartSassMessageLevel>High</DartSassMessageLevel> <DartSassMessageLevel>High</DartSassMessageLevel>
</PropertyGroup> </PropertyGroup>
<Target Name="AddGeneratedCssToWebAssets" AfterTargets="DartSass_Build" BeforeTargets="ResolveStaticWebAssetsInputs;ResolveScopedCssInputs;Compile">
<ItemGroup>
<_SassOutput Include="@(SassFilesToCompile->'%(RelativeDir)%(Filename).css')" />
<Content Include="@(_SassOutput)" Exclude="@(Content)" />
</ItemGroup>
</Target>
<ItemGroup> <ItemGroup>
<PackageReference Include="DartSassBuilder" Version="1.1.0" /> <PackageReference Include="AspNetCore.SassCompiler" Version="1.81.1" />
<PackageReference Include="MailKit" Version="4.4.0" /> <PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.3" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

View File

@@ -12,7 +12,7 @@ namespace FoodsharingSiegen.Server.Migrations
{ {
[DbContext(typeof(FsContext))] [DbContext(typeof(FsContext))]
[Migration("20220521155432_init")] [Migration("20220521155432_init")]
partial class init partial class Init
{ {
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {

View File

@@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace FoodsharingSiegen.Server.Migrations namespace FoodsharingSiegen.Server.Migrations
{ {
public partial class init : Migration public partial class Init : Migration
{ {
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {

View File

@@ -0,0 +1,264 @@
// <auto-generated />
using System;
using FoodsharingSiegen.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FoodsharingSiegen.Server.Migrations
{
[DbContext(typeof(FsContext))]
[Migration("20260426081244_RemoveUserVerified")]
partial class RemoveUserVerified
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Audit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Data1")
.HasColumnType("TEXT");
b.Property<string>("Data2")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<Guid?>("UserID")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserID");
b.ToTable("Audits");
});
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Interaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Alert")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("Date")
.HasColumnType("TEXT");
b.Property<int>("Feedback")
.HasColumnType("INTEGER");
b.Property<string>("FeedbackInfo")
.HasColumnType("TEXT");
b.Property<string>("Info1")
.HasColumnType("TEXT");
b.Property<string>("Info2")
.HasColumnType("TEXT");
b.Property<bool>("NotNeeded")
.HasColumnType("INTEGER");
b.Property<Guid>("ProspectID")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<Guid>("UserID")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProspectID");
b.HasIndex("UserID");
b.ToTable("Interactions");
});
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Prospect", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<int>("FsId")
.HasColumnType("INTEGER");
b.Property<string>("Memo")
.HasColumnType("TEXT");
b.Property<DateTime?>("Modified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RecordState")
.HasColumnType("INTEGER");
b.Property<Guid?>("VerificationToken")
.HasColumnType("TEXT");
b.Property<bool>("Warning")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Prospects");
});
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.ProspectImage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<byte[]>("ImageData")
.IsRequired()
.HasColumnType("BLOB");
b.Property<Guid>("ProspectId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProspectId");
b.ToTable("ProspectImages");
});
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("EncryptedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("ForceLogout")
.HasColumnType("INTEGER");
b.Property<string>("Groups")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Mail")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Memo")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Network")
.HasColumnType("INTEGER");
b.Property<string>("ResetToken")
.HasColumnType("TEXT");
b.Property<DateTime?>("ResetTokenExpiry")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Audit", b =>
{
b.HasOne("FoodsharingSiegen.Contracts.Entity.User", "User")
.WithMany()
.HasForeignKey("UserID");
b.Navigation("User");
});
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Interaction", b =>
{
b.HasOne("FoodsharingSiegen.Contracts.Entity.Prospect", "Prospect")
.WithMany("Interactions")
.HasForeignKey("ProspectID")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FoodsharingSiegen.Contracts.Entity.User", "User")
.WithMany("Interactions")
.HasForeignKey("UserID")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Prospect");
b.Navigation("User");
});
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.ProspectImage", b =>
{
b.HasOne("FoodsharingSiegen.Contracts.Entity.Prospect", "Prospect")
.WithMany("Images")
.HasForeignKey("ProspectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Prospect");
});
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.Prospect", b =>
{
b.Navigation("Images");
b.Navigation("Interactions");
});
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.User", b =>
{
b.Navigation("Interactions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FoodsharingSiegen.Server.Migrations
{
/// <inheritdoc />
public partial class RemoveUserVerified : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Verified",
table: "Users");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Verified",
table: "Users",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
}
}

View File

@@ -200,9 +200,6 @@ namespace FoodsharingSiegen.Server.Migrations
b.Property<int>("Type") b.Property<int>("Type")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<bool>("Verified")
.HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Users"); b.ToTable("Users");

View File

@@ -13,6 +13,8 @@
<DataGrid TItem="Audit" <DataGrid TItem="Audit"
Data="@Audits" Data="@Audits"
ReadData="@OnReadData"
TotalItems="@TotalAudits"
VirtualizeOptions="@(new() { DataGridHeight = "100%", DataGridMaxHeight = "100%"})" VirtualizeOptions="@(new() { DataGridHeight = "100%", DataGridMaxHeight = "100%"})"
Virtualize="true" Virtualize="true"
Responsive> Responsive>

View File

@@ -1,4 +1,6 @@
using Blazorise.DataGrid;
using FoodsharingSiegen.Contracts.Entity; using FoodsharingSiegen.Contracts.Entity;
using FoodsharingSiegen.Contracts.Helper;
using FoodsharingSiegen.Server.Data.Service; using FoodsharingSiegen.Server.Data.Service;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@@ -26,16 +28,47 @@ namespace FoodsharingSiegen.Server.Pages
/// </summary> /// </summary>
private List<Audit>? Audits { get; set; } private List<Audit>? Audits { get; set; }
/// <summary>
/// Gets or sets the value of the total audits (ab)
/// </summary>
private int TotalAudits { get; set; }
#endregion #endregion
#region Override InitializeDataAsync #region Override InitializeDataAsync
/// <inheritdoc /> /// <inheritdoc />
protected override async Task InitializeDataAsync() protected override Task InitializeDataAsync()
{ {
var loadR = await AuditService?.Load(100)!; if (!CurrentUser.IsAdmin()) NavigationManager.NavigateTo("/");
if (loadR.Success) return Task.CompletedTask;
Audits = loadR.Data; }
#endregion
#region Private Method OnReadData
/// <summary>
/// Called when data is read (ab)
/// </summary>
/// <param name="e">The params</param>
private async Task OnReadData(DataGridReadDataEventArgs<Audit> e)
{
if (!CurrentUser.IsAdmin()) return;
var countLoad = await AuditService?.GetCount()!;
if (countLoad.Success)
TotalAudits = countLoad.Data;
// Default fallback if VirtualizeCount is not set, though Blazor shouldn't do this usually
var limit = e.VirtualizeCount > 0 ? e.VirtualizeCount : 50;
var offset = e.VirtualizeOffset;
var itemsLoad = await AuditService?.LoadPage(offset, limit)!;
if (itemsLoad.Success)
Audits = itemsLoad.Data;
await InvokeAsync(StateHasChanged);
} }
#endregion #endregion

View File

@@ -6,13 +6,13 @@
<PageTitle>@AppSettings.Terms.Title - Passwort vergessen</PageTitle> <PageTitle>@AppSettings.Terms.Title - Passwort vergessen</PageTitle>
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh; background-color: #f4f6f8;"> <div class="d-flex justify-content-center align-items-center-sm" style="min-height: 100vh; align-items: start;">
<div class="card shadow border-0" style="width: 100%; max-width: 420px; border-radius: 12px; margin: 1rem;"> <div class="card shadow border-0" style="width: 100%; max-width: 420px; border-radius: 12px; margin: 1rem;">
<div class="card-body p-4 p-md-5"> <div class="card-body p-4 p-md-5">
<div class="text-center mb-4"> <div class="text-center mb-4">
<i class="fa-solid fa-leaf mb-3" style="font-size: 3rem; color: #64ae24;"></i> <i class="fa-solid fa-leaf mb-3" style="font-size: 3rem; color: #64ae24;"></i>
<h4 class="font-weight-bold" style="color: #533a20;"><small style="font-size: .6em;">Einarbeitungen</small> @AppSettings.Terms.Title</h4> <h4 class="font-weight-bold" style="color: #533a20;"><small style="font-size: .6em;" class="d-block">Einarbeitungen</small> @AppSettings.Terms.Title</h4>
<p class="text-muted">Passwort zurücksetzen</p> <p class="text-muted">Passwort zurücksetzen</p>
</div> </div>
@@ -20,6 +20,8 @@
{ {
<div class="alert alert-success text-center"> <div class="alert alert-success text-center">
Wenn ein Benutzerkonto mit dieser E-Mail-Adresse existiert, wurde eine E-Mail mit weiteren Anweisungen versendet. Wenn ein Benutzerkonto mit dieser E-Mail-Adresse existiert, wurde eine E-Mail mit weiteren Anweisungen versendet.
<br><br>
<small><b>Hinweis:</b> Bitte überprüfe auch deinen Spam-Ordner, falls du künftige E-Mails nicht im regulären Posteingang findest.</small>
</div> </div>
<div class="text-center mt-4"> <div class="text-center mt-4">
<a href="/login" class="btn btn-outline-primary"><i class="fas fa-arrow-left mr-2"></i> Zurück zum Login</a> <a href="/login" class="btn btn-outline-primary"><i class="fas fa-arrow-left mr-2"></i> Zurück zum Login</a>
@@ -27,6 +29,13 @@
} }
else else
{ {
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="alert alert-danger text-center">
@ErrorMessage
</div>
}
<Validation Validator="ValidationHelper.ValidateMail" @bind-Status="@IsValidMail"> <Validation Validator="ValidationHelper.ValidateMail" @bind-Status="@IsValidMail">
<Field> <Field>
<FieldLabel>E-Mail Adresse</FieldLabel> <FieldLabel>E-Mail Adresse</FieldLabel>

View File

@@ -1,3 +1,4 @@
using System;
using Blazorise; using Blazorise;
using FoodsharingSiegen.Server.BaseClasses; using FoodsharingSiegen.Server.BaseClasses;
using FoodsharingSiegen.Server.Auth; using FoodsharingSiegen.Server.Auth;
@@ -15,19 +16,30 @@ namespace FoodsharingSiegen.Server.Pages
public bool IsSubmitted { get; set; } public bool IsSubmitted { get; set; }
public bool IsLoading { get; set; } public bool IsLoading { get; set; }
public string? ErrorMessage { get; set; }
public async Task SubmitRequest() public async Task SubmitRequest()
{ {
if (IsValidMail != ValidationStatus.Success) return; if (IsValidMail != ValidationStatus.Success) return;
IsLoading = true; IsLoading = true;
ErrorMessage = null;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
await AuthService.InitiatePasswordReset(MailAddress, NavigationManager.BaseUri); try
{
IsSubmitted = true; await AuthService.InitiatePasswordReset(MailAddress, NavigationManager.BaseUri);
IsLoading = false; IsSubmitted = true;
await InvokeAsync(StateHasChanged); }
catch (Exception)
{
ErrorMessage = "Es gab ein Problem bei der Verarbeitung der Anfrage. Bitte versuche es später erneut oder wende dich an einen Administrator.";
}
finally
{
IsLoading = false;
await InvokeAsync(StateHasChanged);
}
} }
public async Task TextEdit_KeyUp(KeyboardEventArgs e) public async Task TextEdit_KeyUp(KeyboardEventArgs e)

View File

@@ -6,13 +6,13 @@
<PageTitle>@AppSettings.Terms.Title - Login</PageTitle> <PageTitle>@AppSettings.Terms.Title - Login</PageTitle>
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh; background-color: #f4f6f8;"> <div class="d-flex justify-content-center align-items-center-sm" style="min-height: 100vh; align-items: start;">
<div class="card shadow border-0" style="width: 100%; max-width: 420px; border-radius: 12px; margin: 1rem;"> <div class="card shadow border-0" style="width: 100%; max-width: 420px; border-radius: 12px; margin: 1rem;">
<div class="card-body p-4 p-md-5"> <div class="card-body p-4 p-md-5">
<div class="text-center mb-4"> <div class="text-center mb-4">
<i class="fa-solid fa-leaf mb-3" style="font-size: 3rem; color: #64ae24;"></i> <i class="fa-solid fa-leaf mb-3" style="font-size: 3rem; color: #64ae24;"></i>
<h4 class="font-weight-bold" style="color: #533a20;"><small style="font-size: .6em;">Einarbeitungen</small> @AppSettings.Terms.Title</h4> <h4 class="font-weight-bold" style="color: #533a20;"><small style="font-size: .6em;" class="d-block">Einarbeitungen</small> @AppSettings.Terms.Title</h4>
<p class="text-muted">Bitte melde dich an, um fortzufahren.</p> <p class="text-muted">Bitte melde dich an, um fortzufahren.</p>
</div> </div>

View File

@@ -62,7 +62,7 @@ namespace FoodsharingSiegen.Server.Pages
if (loginR.Success) if (loginR.Success)
NavigationManager.NavigateTo("/", true); NavigationManager.NavigateTo("/", true);
else else
LoginErrorMessage = "E-Mail-Adresse oder Passwort ist ungültig."; LoginErrorMessage = loginR.ErrorMessage;
} }
#endregion #endregion

View File

@@ -29,14 +29,14 @@
<TextEdit @bind-Text="User.Mail" ReadOnly="true"></TextEdit> <TextEdit @bind-Text="User.Mail" ReadOnly="true"></TextEdit>
</FieldBody> </FieldBody>
</Field> </Field>
@* <Validation Validator="ValidationRule.None"> *@ <Validation Validator="ValidationRule.None">
@* <Field ColumnSize="ColumnSize.Is12"> *@ <Field ColumnSize="ColumnSize.Is12">
@* <FieldLabel>Info über dich</FieldLabel> *@ <FieldLabel>Info über dich</FieldLabel>
@* <FieldBody> *@ <FieldBody>
@* <MemoEdit Rows="3" Placeholder="z.B. Bieb bei Rewe Musterhausen" @bind-Text="User.Memo"/> *@ <MemoEdit Rows="3" Placeholder="z.B. Bieb bei Rewe Musterhausen" @bind-Text="User.Memo"/>
@* </FieldBody> *@ </FieldBody>
@* </Field> *@ </Field>
@* </Validation> *@ </Validation>
</Validations> </Validations>
</Fields> </Fields>
</div> </div>

View File

@@ -7,8 +7,8 @@
@using FoodsharingSiegen.Shared.Helper @using FoodsharingSiegen.Shared.Helper
@inherits FsBase @inherits FsBase
<PageTitle>Neue Foodsaver - @AppSettings.Terms.Title</PageTitle> <PageTitle>Aktuelle - @AppSettings.Terms.Title</PageTitle>
<h2>Neue Foodsaver</h2> <h2>Aktuelle Einarbeitungen</h2>
@if (AppSettings.TestMode) @if (AppSettings.TestMode)
{ {

View File

@@ -5,8 +5,8 @@
@using FoodsharingSiegen.Shared.Helper @using FoodsharingSiegen.Shared.Helper
@inherits FsBase @inherits FsBase
<PageTitle>Abgeschlossen - @AppSettings.Terms.Title</PageTitle> <PageTitle>Fertige - @AppSettings.Terms.Title</PageTitle>
<h2>Abgeschlossen</h2> <h2>Abgeschlossene Einarbeitungen</h2>
@if (AppSettings.TestMode) @if (AppSettings.TestMode)
{ {

View File

@@ -6,7 +6,7 @@
@inherits FsBase @inherits FsBase
<PageTitle>Freischalten - @AppSettings.Terms.Title</PageTitle> <PageTitle>Freischalten - @AppSettings.Terms.Title</PageTitle>
<h2>Freischalten</h2> <h2>Zum Freischalten freigegeben</h2>
@if (AppSettings.TestMode) @if (AppSettings.TestMode)
{ {

View File

@@ -6,13 +6,13 @@
<PageTitle>@AppSettings.Terms.Title - Neues Passwort setzen</PageTitle> <PageTitle>@AppSettings.Terms.Title - Neues Passwort setzen</PageTitle>
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh; background-color: #f4f6f8;"> <div class="d-flex justify-content-center align-items-center-sm" style="min-height: 100vh; align-items: start;">
<div class="card shadow border-0" style="width: 100%; max-width: 420px; border-radius: 12px; margin: 1rem;"> <div class="card shadow border-0" style="width: 100%; max-width: 420px; border-radius: 12px; margin: 1rem;">
<div class="card-body p-4 p-md-5"> <div class="card-body p-4 p-md-5">
<div class="text-center mb-4"> <div class="text-center mb-4">
<i class="fa-solid fa-leaf mb-3" style="font-size: 3rem; color: #64ae24;"></i> <i class="fa-solid fa-leaf mb-3" style="font-size: 3rem; color: #64ae24;"></i>
<h4 class="font-weight-bold" style="color: #533a20;"><small style="font-size: .6em;">Einarbeitungen</small> @AppSettings.Terms.Title</h4> <h4 class="font-weight-bold" style="color: #533a20;"><small style="font-size: .6em;" class="d-block">Einarbeitungen</small> @AppSettings.Terms.Title</h4>
<p class="text-muted">Neues Passwort festlegen</p> <p class="text-muted">Neues Passwort festlegen</p>
</div> </div>

View File

@@ -5,102 +5,119 @@
<PageTitle>Identitätsprüfung - @AppSettings.Value.Terms.Title</PageTitle> <PageTitle>Identitätsprüfung - @AppSettings.Value.Terms.Title</PageTitle>
<div class="row min-vh-100 align-items-center justify-content-center p-0 p-md-5 m-0"> <div class="row min-vh-100 align-items-center-sm justify-content-center p-0 p-md-5 m-0">
<div class="col-12 col-md-10 col-lg-8 col-xl-5 login-form p-2"> <div class="col-12 col-md-10 col-lg-8 col-xl-5 login-form p-2">
<div class="card shadow-sm border-0"> <div class="card shadow-sm border-0">
<div class="card-body p-3 p-md-5"> <div class="card-body p-3 p-md-5">
<div class="text-center mb-4"> <div class="text-center mb-4">
<h5 class="mb-0 text-success d-block d-sm-none"><i class="fa-solid fa-address-card me-2"></i><br>Identitätsprüfung</h5> <h5 class="mb-0 text-success d-block d-sm-none"><i
<h4 class="mb-0 text-success d-none d-sm-block"><i class="fa-solid fa-address-card me-2"></i>Identitätsprüfung</h4> class="fa-solid fa-address-card me-2"></i><br>Identitätsprüfung</h5>
<h4 class="mb-0 text-success d-none d-sm-block"><i
class="fa-solid fa-address-card me-2"></i>Identitätsprüfung</h4>
<p class="text-muted mt-2">@(AppSettings.Value.Terms.Title)</p> <p class="text-muted mt-2">@(AppSettings.Value.Terms.Title)</p>
</div>
@if (_isLoading)
{
<div class="text-center my-5">
<div class="spinner-border text-success" role="status">
<span class="visually-hidden">Laden...</span>
</div> </div>
<p class="mt-3 text-muted">Lade Daten...</p>
</div>
}
else if (_prospect == null)
{
<div class="alert alert-danger" role="alert">
<i class="fa-solid fa-triangle-exclamation me-2"></i> @(_message ?? "Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen Link an.")
</div>
}
else
{
<div class="alert alert-info mb-4">
<strong>Hinweis:</strong> Dies ist die Upload-Seite für Foodsaver <b>@_prospect.FsId</b>.
</div>
<div class="mb-1 text-muted"> @if (_isLoading)
Um dich auf der Foodsharing-Plattform als Foodsaver freischalten zu können, muss ein*e Botschafter*in Name, Adresse und Geburtsdatum im Profil des Foodsavers auf Korrektheit durch Vergleich mit einem Ausweisdokument prüfen. Das ist wichtig, damit die <a href="https://wiki.foodsharing.network/wiki/Rechtsvereinbarung" target="_blank">Rechtsvereinbarung</a> Bestand hat und wir die Zusagen erfüllen, die wir den Spenderbetrieben geben.<br>
</div>
<div class="mb-3 text-muted">
<a class="mt-3" href="https://wiki.foodsharing.network/wiki/Foodsaver#3.4_Verifizierung_(der_Daten),_Foodsaver-Ausweis_und_Freischaltung" target="_blank">Mehr dazu im Wiki</a>.
</div>
<div class="mb-4">
<h5 class="fw-bold">Anleitung:</h5>
<ul class="text-muted">
<li>Lade hier die Vorder- und Rückseite deines Personalausweises oder Reisepasses hoch</li>
<li>Dein Name und Adresse müssen gut und lesbar erkennbar sein</li>
<li>Wir nutzen diese Bilder ausschließlich zur Identitätsprüfung</li>
<li>Ausschließlich die Botschafter haben Zugriff auf diese Bilder</li>
<li>Die Bilder werden nach der Überprüfung sofort und unwiderruflich von uns gelöscht</li>
<li>Bereits hochgeladene Bilder werden hier hier aus Datenschutzgründen nicht angezeigt</li>
</ul>
</div>
<div class="mb-4 text-center">
@if (_uploadedCount >= 5)
{ {
<div class="alert alert-warning py-2 mb-0">Du hast die maximale Anzahl von 5 Bildern erreicht.</div> <div class="text-center my-5">
<div class="spinner-border text-success" role="status">
<span class="visually-hidden">Laden...</span>
</div>
<p class="mt-3 text-muted">Lade Daten...</p>
</div>
}
else if (_prospect == null)
{
<div class="alert alert-danger" role="alert">
<i class="fa-solid fa-triangle-exclamation me-2"></i> @(_message ?? "Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen Link an.")
</div>
} }
else else
{ {
<span class="badge bg-secondary mb-2 text-wrap">Es können noch bis zu @(5 - _uploadedCount) Bilder hochgeladen werden</span> <div class="alert alert-info mb-4">
<InputFile id="fileInput" OnChange="OnInputFileChange" class="d-none" accept="image/*" /> <strong>Hinweis:</strong> Dies ist die Upload-Seite für Foodsaver <b>@_prospect.FsId</b>.
<label for="fileInput" class="btn btn-outline-success w-100">
<i class="fa-solid fa-images me-2"></i>Bild auswählen
</label>
}
</div>
@if (_isUploading)
{
<div class="text-center my-3">
<div class="spinner-border text-primary spinner-border-sm" role="status">
<span class="visually-hidden">Laden...</span>
</div> </div>
<span class="ms-2">Bilder werden hochgeladen und verarbeitet...</span>
</div>
}
@if (!string.IsNullOrEmpty(_message)) <div class="mb-3 text-center">
{
<div class="alert alert-@(_isSuccess ? "success" : "danger") alert-dismissible fade show" role="alert"> @if (_uploadedCount >= 5)
@_message {
<button type="button" class="btn-close" @onclick="() => _message = null"></button> <div class="alert alert-warning py-2 mb-0">Du hast die maximale Anzahl von 5 Bildern erreicht.</div>
</div> }
} else
{
<InputFile id="fileInput" OnChange="OnInputFileChange" class="d-none" accept="image/*" />
<label for="fileInput" class="btn btn-outline-success w-100" style="height: 5rem;">
<i class="fa-solid fa-images me-2"></i>Bild auswählen
</label>
}
</div>
@if (!string.IsNullOrEmpty(_message))
{
<div class="alert alert-@(_isSuccess ? "success" : "danger") alert-dismissible fade show" role="alert">
@_message
<button type="button" class="btn-close" @onclick="() => _message = null"></button>
</div>
}
@if (_isSuccess) @if (_isSuccess)
{ {
<div class="alert alert-success text-center"> <div class="alert alert-success text-center">
<i class="fa-solid fa-check-circle fa-2x mb-2"></i><br/> <i class="fa-solid fa-check-circle fa-2x mb-2"></i><br />
Vielen Dank für den Upload. Wenn du alle benötigten Bilder hochgeladen hast, kannst du die Seite schließen. Du musst nichts weiter tun. Vielen Dank für den Upload. Wenn du alle benötigten Bilder hochgeladen hast, kannst du die Seite
schließen. Du musst nichts weiter tun.
</div>
}
@if (_uploadedCount < 5)
{
<div class="text-center mb-3">
<span class="badge bg-secondary text-wrap">Es können noch bis zu @(5 - _uploadedCount) Bilder
hochgeladen werden</span>
</div>
}
<div class="mb-1 text-muted">
Um dich auf der Foodsharing-Plattform als Foodsaver freischalten zu können, muss ein*e
Botschafter*in Name, Adresse und Geburtsdatum im Profil des Foodsavers auf Korrektheit durch
Vergleich mit einem Ausweisdokument prüfen. Das ist wichtig, damit die <a
href="https://wiki.foodsharing.network/wiki/Rechtsvereinbarung"
target="_blank">Rechtsvereinbarung</a> Bestand hat und wir die Zusagen erfüllen, die wir den
Spenderbetrieben geben.<br>
</div>
<div class="mb-3 text-muted">
<a class="mt-3"
href="https://wiki.foodsharing.network/wiki/Foodsaver#3.4_Verifizierung_(der_Daten),_Foodsaver-Ausweis_und_Freischaltung"
target="_blank">Mehr dazu im Wiki</a>.
</div>
<div class="mb-4">
<h5 class="fw-bold">Anleitung:</h5>
<ul class="text-muted">
<li>Lade hier die Vorder- und Rückseite deines Personalausweises oder Reisepasses hoch</li>
<li>Dein Name und Adresse müssen gut und lesbar erkennbar sein</li>
<li>Wir nutzen diese Bilder ausschließlich zur Identitätsprüfung</li>
<li>Ausschließlich die Botschafter haben Zugriff auf diese Bilder</li>
<li>Die Bilder werden nach der Überprüfung sofort und unwiderruflich von uns gelöscht</li>
<li>Bereits hochgeladene Bilder werden hier hier aus Datenschutzgründen nicht angezeigt</li>
</ul>
</div>
@if (_isUploading)
{
<div class="text-center my-3">
<div class="spinner-border text-primary spinner-border-sm" role="status">
<span class="visually-hidden">Laden...</span>
</div>
<span class="ms-2">Bilder werden hochgeladen und verarbeitet...</span>
</div>
}
}
</div> </div>
} </div>
} </div>
</div> </div>
</div>
</div>
</div>

View File

@@ -6,111 +6,137 @@
@inherits FoodsharingSiegen.Server.BaseClasses.FsBase @inherits FoodsharingSiegen.Server.BaseClasses.FsBase
@code {
private RenderFragment PopupTitleTemplate(PopupTitleContext<User> value)
{
var header = "Benutzer erstellen";
if (value.EditState == DataGridEditState.Edit) header = "Benutzer bearbeiten";
return builder =>
{
builder.OpenElement(0, "span");
builder.AddContent(1, header);
builder.CloseElement();
};
}
}
<PageTitle>Benutzerverwaltung - @AppSettings.Terms.Title</PageTitle> <PageTitle>Benutzerverwaltung - @AppSettings.Terms.Title</PageTitle>
<h2>Benutzerverwaltung <span style="font-size: .5em; line-height: 0;">Admin</span></h2> <div class="d-flex justify-content-between align-items-center mb-3">
<h2>Benutzerverwaltung <span style="font-size: .5em; line-height: 0;">Admin</span></h2>
<div class="my-2"> <Button Color="Color.Success" Clicked="CreateNewUser">
<Button Color="Color.Primary" Disabled="@(SelectedUser == null)" Clicked="async () => await PasswordModal?.Show(SelectedUser!)!"><i class="fa-solid fa-key"></i>&nbsp;setzen</Button> <Icon Name="IconName.Add" /> Benutzer erstellen
</Button>
</div> </div>
<DataGrid TItem="User" <div class="user-grid" style="max-width: 1000px;">
@ref="UserDataGrid" @if (SortedUsers != null)
Data="@UserList" {
CommandMode="DataGridCommandMode.Commands" @foreach (var user in SortedUsers)
EditMode="DataGridEditMode.Popup" {
PopupTitleTemplate="PopupTitleTemplate" <Card Class="user-card">
RowInserted="RowInserted" <CardBody>
RowUpdated="RowUpdated" <CardTitle Size="4">@user.Name</CardTitle>
PageSize="50" <CardSubtitle Class="mb-2 text-muted">@user.Mail</CardSubtitle>
@bind-SelectedRow="SelectedUser" <CardText>
RowDoubleClicked="arg => UserDataGrid?.Edit(arg.Item)!" Typ:
Editable @if (user.Type == UserType.Unverified)
Responsive="true"> {
<DataGridColumns> <span style="color: red" class="fw-bold">@user.Type</span>
<DataGridCommandColumn TItem="User" Width="100px" CellClass="@(_ => "px-0 d-flex align-items-center justify-content-center")"> }
<NewCommandTemplate> else if (user.Type == UserType.Admin)
<Button Size="Size.ExtraSmall" Color="Color.Success" Clicked="@context.Clicked" Class="mr-1" Style="min-width: auto;"> {
<i class="oi oi-plus"></i> <span style="color: blue" class="fw-bold">@user.Type</span>
</Button> }
</NewCommandTemplate> else
<EditCommandTemplate> {
<Button Size="Size.ExtraSmall" Color="Color.Secondary" Clicked="@context.Clicked" Class="mr-1" Style="min-width: auto;"> @user.Type
<i class="oi oi-pencil"></i> }
</Button> </CardText>
</EditCommandTemplate>
<DeleteCommandTemplate> <div class="mb-3">
<Button Size="Size.ExtraSmall" Color="Color.Danger" Clicked="() => RemoveUserAsync(context.Item)" Class="mr-1" Style="min-width: auto;"> @if (user.GroupsList != null && user.GroupsList.Any())
<i class="oi oi-trash"></i> {
</Button> @foreach(var group in user.GroupsList)
</DeleteCommandTemplate> {
<ClearFilterCommandTemplate> <Badge Color="Color.Primary" Class="me-1" Style="font-size: 0.8em;">@group.ToString()</Badge>
<Button Size="Size.ExtraSmall" Color="Color.Danger" Clicked="@context.Clicked" Style="min-width: auto;"> }
<i class="o"></i> <i class="fas fa-trash"></i> }
</Button> else
</ClearFilterCommandTemplate> {
</DataGridCommandColumn> <span class="text-muted" style="font-style: italic; font-size: 0.8em;">Keine Gruppen</span>
<DataGridCheckColumn TItem="User" Field="@nameof(User.Verified)" Caption="Verifiziert" Editable="true" Width="100px"> }
<DisplayTemplate> </div>
<Check TValue="bool" Checked="context.Verified" Disabled="true" ReadOnly="true"/> </CardBody>
</DisplayTemplate> <CardFooter Class="d-flex justify-content-between">
</DataGridCheckColumn> <Button Color="Color.Primary" Size="Size.Small" Clicked="() => EditUser(user)"><Icon Name="IconName.Edit" /></Button>
<DataGridColumn TItem="User" Field="@nameof(User.Type)" Caption="Typ" Editable="true" Width="200px"> <Button Color="Color.Info" Size="Size.Small" Clicked="() => SetPassword(user)"><i class="fa-solid fa-key"></i></Button>
<EditTemplate> <Button Color="Color.Secondary" Size="Size.Small" Clicked="() => SendPasswordSetupMail(user)"><Icon Name="IconName.Mail" /></Button>
<Select TValue="UserType" SelectedValue="@((UserType)context.CellValue)" SelectedValueChanged="@(v => context.CellValue = v)"> @if (!(user.Type == UserType.Admin && SortedUsers.Count(x => x.Type == UserType.Admin) <= 1))
@foreach (var enumValue in Enum.GetValues<UserType>())
{ {
<SelectItem TValue="UserType" Value="enumValue">@enumValue</SelectItem> <Button Color="Color.Danger" Size="Size.Small" Clicked="() => RemoveUserAsync(user)"><Icon Name="IconName.Delete" /></Button>
} }
</Select> </CardFooter>
</EditTemplate> </Card>
</DataGridColumn> }
<DataGridColumn TItem="User" Field="@nameof(User.Name)" Caption="Name" Editable="true" Width="250px"></DataGridColumn> }
<DataGridColumn TItem="User" Field="@nameof(User.Mail)" Caption="E-Mail" Editable="true"></DataGridColumn> </div>
<DataGridColumn TItem="User" Field="@nameof(User.GroupsList)" Caption="Gruppen" Editable="true">
<EditTemplate> <style>
<Autocomplete TItem="UserGroup" .user-grid {
TValue="UserGroup" display: grid;
Size="Size.ExtraSmall" grid-template-columns: repeat(1, 1fr);
Data="@UserGroups" gap: 1rem;
TextField="@(( item ) => item.ToString())" }
ValueField="@(( item ) => item)" @@media (min-width: 700px) {
Multiple="true" .user-grid {
SelectedValues="@((List<UserGroup>) context.CellValue)" grid-template-columns: repeat(2, 1fr);
SelectedValuesChanged="@(v => context.CellValue = v)" }
@bind-SelectedTexts="SelectedCompanyTexts"> }
</Autocomplete> @@media (min-width: 1250px) {
<small>Verfügbar: @string.Join(", ", Enum.GetValues<UserGroup>())</small> .user-grid {
</EditTemplate> grid-template-columns: repeat(3, 1fr);
<DisplayTemplate> }
@if (string.IsNullOrWhiteSpace(context.Groups)) }
{ </style>
<span style="font-style: italic;">Keine Gruppen</span>
} <Modal @ref="editUserModal">
else <ModalContent Centered>
{ <ModalHeader>
<span>@string.Join(", ", context.GroupsList)</span> <ModalTitle>@(IsEditing ? "Benutzer bearbeiten" : "Benutzer erstellen")</ModalTitle>
} <CloseButton />
</DisplayTemplate> </ModalHeader>
</DataGridColumn> <ModalBody>
</DataGridColumns> @if (EditModel != null)
</DataGrid> {
<Field>
<FieldLabel>Name</FieldLabel>
<TextEdit @bind-Text="EditModel.Name" />
</Field>
<Field>
<FieldLabel>E-Mail</FieldLabel>
<TextEdit @bind-Text="EditModel.Mail" />
</Field>
<Field>
<FieldLabel>Typ</FieldLabel>
<Select TValue="UserType" SelectedValue="EditModel.Type" SelectedValueChanged="@(v => EditModel.Type = v)" Disabled="@IsLastAdmin">
@foreach (var enumValue in Enum.GetValues<UserType>())
{
<SelectItem TValue="UserType" Value="enumValue">@enumValue</SelectItem>
}
</Select>
@if (IsLastAdmin)
{
<small class="text-danger mt-1 d-block">Das ist der letzte Administrator-Account. Der Typ kann nicht geändert werden.</small>
}
</Field>
<Field>
<FieldLabel>Gruppen</FieldLabel>
<Autocomplete TItem="UserGroup"
TValue="UserGroup"
Data="@UserGroups"
TextField="@(( item ) => item.ToString())"
ValueField="@(( item ) => item)"
SelectionMode="AutocompleteSelectionMode.Multiple"
SelectedValues="@EditModel.GroupsList"
SelectedValuesChanged="@(v => { EditModel.GroupsList = v.ToList(); })"
@bind-SelectedTexts="SelectedGroupTexts">
</Autocomplete>
<small>Verfügbar: @string.Join(", ", Enum.GetValues<UserGroup>())</small>
</Field>
}
</ModalBody>
<ModalFooter>
<Button Color="Color.Secondary" Clicked="() => editUserModal?.Hide()">Abbrechen</Button>
<Button Color="Color.Primary" Clicked="SaveUser">Speichern</Button>
</ModalFooter>
</ModalContent>
</Modal>
<SetPasswordModal @ref="PasswordModal" OnPasswortSet="OnPasswordSet"></SetPasswordModal> <SetPasswordModal @ref="PasswordModal" OnPasswortSet="OnPasswordSet"></SetPasswordModal>

View File

@@ -1,4 +1,4 @@
using Blazorise.DataGrid; using Blazorise;
using FoodsharingSiegen.Contracts.Entity; using FoodsharingSiegen.Contracts.Entity;
using FoodsharingSiegen.Contracts.Enums; using FoodsharingSiegen.Contracts.Enums;
using FoodsharingSiegen.Contracts.Helper; using FoodsharingSiegen.Contracts.Helper;
@@ -22,6 +22,12 @@ namespace FoodsharingSiegen.Server.Pages
[Inject] [Inject]
public UserService UserService { get; set; } = null!; public UserService UserService { get; set; } = null!;
[Inject]
public new FoodsharingSiegen.Server.Auth.AuthService AuthService { get; set; } = null!;
[Inject]
public new NavigationManager NavigationManager { get; set; } = null!;
#endregion #endregion
#region Private Properties #region Private Properties
@@ -32,19 +38,29 @@ namespace FoodsharingSiegen.Server.Pages
private SetPasswordModal? PasswordModal { get; set; } private SetPasswordModal? PasswordModal { get; set; }
/// <summary> /// <summary>
/// Gets or sets the value of the selected company texts (ab) /// Gets or sets the edit user modal
/// </summary> /// </summary>
private List<string> SelectedCompanyTexts { get; set; } = new(); private Modal? editUserModal { get; set; }
/// <summary> /// <summary>
/// Gets or sets the value of the selected user (ab) /// Gets or sets the selected group texts
/// </summary> /// </summary>
private User? SelectedUser { get; set; } private List<string> SelectedGroupTexts { get; set; } = new();
/// <summary> /// <summary>
/// Gets or sets the value of the user data grid (ab) /// Gets or sets the edit model
/// </summary> /// </summary>
private DataGrid<User>? UserDataGrid { get; set; } private User? EditModel { get; set; }
/// <summary>
/// Gets or sets a value indicating whether we are editing an existing user
/// </summary>
private bool IsEditing { get; set; }
/// <summary>
/// Gets a value indicating whether the current editing user is the last admin
/// </summary>
private bool IsLastAdmin => IsEditing && EditModel?.Type == UserType.Admin && UserList?.Count(x => x.Type == UserType.Admin) <= 1;
/// <summary> /// <summary>
/// Gets the value of the user groups (ab) /// Gets the value of the user groups (ab)
@@ -56,6 +72,11 @@ namespace FoodsharingSiegen.Server.Pages
/// </summary> /// </summary>
private List<User>? UserList { get; set; } private List<User>? UserList { get; set; }
/// <summary>
/// Gets the sorted users list
/// </summary>
private IEnumerable<User> SortedUsers => UserList?.OrderByDescending(x => x.Type).ThenBy(x => x.Name) ?? Enumerable.Empty<User>();
#endregion #endregion
#region Override InitializeDataAsync #region Override InitializeDataAsync
@@ -84,6 +105,66 @@ namespace FoodsharingSiegen.Server.Pages
#endregion #endregion
#region Actions
private void CreateNewUser()
{
EditModel = new User();
IsEditing = false;
editUserModal?.Show();
}
private void EditUser(User user)
{
EditModel = user.Clone();
IsEditing = true;
editUserModal?.Show();
}
private void SetPassword(User user)
{
PasswordModal?.Show(user);
}
private async Task SendPasswordSetupMail(User user)
{
await ConfirmDialog.ShowAsync(ModalService, "Bestätigen", $"Soll eine E-Mail zum Festlegen des Passworts an {user.Mail} gesendet werden?", async () =>
{
await AuthService.InitiateInitialPasswordSetup(user.Mail, NavigationManager.BaseUri);
if (Notification != null)
{
await Notification.Success("E-Mail gesendet. Bitte weise den Benutzer darauf hin, auch den Spam-Ordner zu prüfen.");
}
});
}
private async Task SaveUser()
{
if (EditModel == null) return;
if (IsEditing)
{
var updateR = await UserService.Update(EditModel);
if (!updateR.Success)
await Notification.Error($"Fehler beim Speichern: {updateR.ErrorMessage}")!;
else
await Notification.Success("Benutzer aktualisiert");
}
else
{
var addUserR = await UserService.AddUserAsync(EditModel);
if (!addUserR.Success)
await Notification.Error($"Fehler beim Anlegen: {addUserR.ErrorMessage}")!;
else
await Notification.Success("Benutzer erstellt");
}
await editUserModal?.Hide()!;
await LoadUsers();
}
#endregion
#region Private Method OnPasswordSet #region Private Method OnPasswordSet
/// <summary> /// <summary>
@@ -108,12 +189,6 @@ namespace FoodsharingSiegen.Server.Pages
/// <returns>A task that represents the asynchronous remove operation.</returns> /// <returns>A task that represents the asynchronous remove operation.</returns>
private async Task RemoveUserAsync(User user) private async Task RemoveUserAsync(User user)
{ {
if (user.IsAdmin())
{
await Notification.Error("Admins können nicht gelöscht werden!");
return;
}
await ConfirmDialog.ShowAsync(ModalService, "Bestätigen", $"User {user.Mail} löschen?", async () => await ConfirmDialog.ShowAsync(ModalService, "Bestätigen", $"User {user.Mail} löschen?", async () =>
{ {
var removeR = await UserService.RemoveAsync(user.Id); var removeR = await UserService.RemoveAsync(user.Id);
@@ -125,39 +200,5 @@ namespace FoodsharingSiegen.Server.Pages
} }
#endregion #endregion
#region Private Method RowInserted
/// <summary>
/// Rows the inserted using the specified arg (a. beging, 01.04.2022)
/// </summary>
/// <param name="arg">The arg</param>
private async Task RowInserted(SavedRowItem<User, Dictionary<string, object>> arg)
{
var addUserR = await UserService.AddUserAsync(arg.Item);
if (!addUserR.Success)
await Notification.Error($"Fehler beim Anlegen: {addUserR.ErrorMessage}")!;
else
await LoadUsers();
}
#endregion
#region Private Method RowUpdated
/// <summary>
/// Rows the updated using the specified arg (a. beging, 01.04.2022)
/// </summary>
/// <param name="arg">The arg</param>
private async Task RowUpdated(SavedRowItem<User, Dictionary<string, object>> arg)
{
if (arg.Item?.Id == null || arg.Item.Id.Equals(Guid.Empty) || arg.Values?.Any() != true) return;
var updateR = await UserService.Update(arg.Item);
if (!updateR.Success)
await Notification.Error($"Fehler beim Speichern: {updateR.ErrorMessage}")!;
}
#endregion
} }
} }

View File

@@ -8,18 +8,24 @@
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<base href="~/"/> <base href="~/"/>
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css"/> <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link href="css/site.css" rel="stylesheet"/> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link href="FoodsharingSiegen.Server.styles.css" rel="stylesheet"/> <link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Foodsharing Einarbeitungen" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="stylesheet" href="~/css/bootstrap/bootstrap.min.css" asp-append-version="true" />
<link href="~/css/site.css" rel="stylesheet" asp-append-version="true" />
<link href="~/FoodsharingSiegen.Server.styles.css" rel="stylesheet" asp-append-version="true" />
<!-- Material CSS --> <!-- Material CSS -->
<link href="css/material.min.css" rel="stylesheet"> <link href="~/css/material.min.css" rel="stylesheet" asp-append-version="true" />
<!-- Add Material font (Roboto) and Material icon as needed --> <!-- Add Material font (Roboto) and Material icon as needed -->
<link href="https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,500,500i,700,700i|Roboto+Mono:300,400,700|Roboto+Slab:300,400,700" rel="stylesheet"> <link href="https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,500,500i,700,700i|Roboto+Mono:300,400,700|Roboto+Slab:300,400,700" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="css/all.min.css" rel="stylesheet" /> <link href="~/css/all.min.css" rel="stylesheet" asp-append-version="true" />
<link href="_content/Blazorise/blazorise.css?v=1.7.5.0" rel="stylesheet" /> <link href="_content/Blazorise/blazorise.css?v=1.7.5.0" rel="stylesheet" />
<link href="_content/Blazorise.Material/blazorise.material.css?v=1.7.5.0" rel="stylesheet" /> <link href="_content/Blazorise.Material/blazorise.material.css?v=1.7.5.0" rel="stylesheet" />
<link href="_content/Blazorise.Icons.Material/blazorise.icons.material.css?v=1.7.5.0" rel="stylesheet" /> <link href="_content/Blazorise.Icons.Material/blazorise.icons.material.css?v=1.7.5.0" rel="stylesheet" />

View File

@@ -1,4 +1,5 @@
using System.Globalization; using System.Globalization;
using AspNetCore.SassCompiler;
using Blazorise; using Blazorise;
using Blazorise.Icons.Material; using Blazorise.Icons.Material;
using Blazorise.Material; using Blazorise.Material;
@@ -19,6 +20,9 @@ builder.WebHost.UseUrls("http://+:8700");
builder.Services.AddRazorPages(); builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor(); builder.Services.AddServerSideBlazor();
builder.AddDatabaseContext(); builder.AddDatabaseContext();
#if DEBUG
builder.Services.AddSassCompiler();
#endif
// DI // DI
builder.Services.AddScoped<LocalStorageService>(); builder.Services.AddScoped<LocalStorageService>();

View File

@@ -1,3 +1,4 @@
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using FoodsharingSiegen.Contracts.Model; using FoodsharingSiegen.Contracts.Model;
using MailKit.Net.Smtp; using MailKit.Net.Smtp;
@@ -15,15 +16,17 @@ namespace FoodsharingSiegen.Server.Service
{ {
private readonly MailSettings _mailSettings; private readonly MailSettings _mailSettings;
private readonly TermSettings _termSettings; private readonly TermSettings _termSettings;
private readonly Func<ISmtpClient> _smtpClientFactory;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="MailService"/> class. /// Initializes a new instance of the <see cref="MailService"/> class.
/// </summary> /// </summary>
/// <param name="appSettings">The configured application settings injected by DI, containing the <see cref="MailSettings"/>.</param> /// <param name="appSettings">The configured application settings injected by DI, containing the <see cref="MailSettings"/>.</param>
public MailService(IOptions<AppSettings> appSettings) public MailService(IOptions<AppSettings> appSettings, Func<ISmtpClient>? smtpClientFactory = null)
{ {
_mailSettings = appSettings.Value.Mail; _mailSettings = appSettings.Value.Mail;
_termSettings = appSettings.Value.Terms; _termSettings = appSettings.Value.Terms;
_smtpClientFactory = smtpClientFactory ?? (() => new SmtpClient());
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -40,7 +43,7 @@ namespace FoodsharingSiegen.Server.Service
}; };
email.Body = textPart; email.Body = textPart;
using var smtp = new SmtpClient(); using var smtp = _smtpClientFactory();
var secureOptions = _mailSettings.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto; var secureOptions = _mailSettings.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
await smtp.ConnectAsync(_mailSettings.Host, _mailSettings.Port, secureOptions); await smtp.ConnectAsync(_mailSettings.Host, _mailSettings.Port, secureOptions);

View File

@@ -24,5 +24,5 @@
</NotAuthorized> </NotAuthorized>
</AuthorizeView> </AuthorizeView>
<NotificationAlert/> <NotificationProvider/>
<MessageAlert/> <MessageProvider/>

View File

@@ -7,7 +7,7 @@ $sidebarBreakpoint: $breakpointM;
height: 100vh; height: 100vh;
aside { aside {
background-color: #f1e7c9; background-color: #f0f0f0;
width: 0; width: 0;
box-shadow: 5px 5px 5px #acacac; box-shadow: 5px 5px 5px #acacac;
transition: width 250ms; transition: width 250ms;

View File

@@ -8,11 +8,6 @@ main {
flex: 1; flex: 1;
} }
.sidebar {
/*background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);*/
background-color: #f1e7c9 !important;
}
.top-row { .top-row {
background-color: #f7f7f7; background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5; border-bottom: 1px solid #d6d5d5;

View File

@@ -1,11 +1,11 @@
@using FoodsharingSiegen.Contracts.Enums @using FoodsharingSiegen.Contracts.Enums
<nav class="d-flex flex-column h-100"> <nav class="d-flex flex-column h-100">
<div class="nav-logo"></div> <i class="fa-solid fa-leaf mb-3 mt-3 text-center" style="font-size: 4rem; color: #64ae24;"></i>
<div class="d-flex px-3 justify-content-center text-center font-weight-bold"> <div class="px-3 justify-content-center text-center font-weight-bold">
Einarbeitungen<br/> <span class="d-block">Einarbeitungen</span>
@(AppSettings.Terms.TitleShort ?? AppSettings.Terms.Title) <h5 class="d-block">@(AppSettings.Terms.Title ?? AppSettings.Terms.TitleShort)</h5>
</div> </div>
<div class="d-flex px-3 mt-3 justify-content-center text-center font-weight-bold"> <div class="d-flex px-3 justify-content-center text-center text-muted">
Hallo @CurrentUser.Name! Hallo @CurrentUser.Name!
</div> </div>
@@ -64,15 +64,15 @@
</NavLink> </NavLink>
</div> </div>
</div> </div>
}
<div class="nav-item px-3"> <div class="nav-item px-3">
<div @onclick="NavLinkClickedAsync"> <div @onclick="NavLinkClickedAsync">
<NavLink class="nav-link" href="audit" Match="NavLinkMatch.All"> <NavLink class="nav-link" href="audit" Match="NavLinkMatch.All">
<span class="fa-solid fa-clock-rotate-left mr-2" aria-hidden="true" style="font-size: 1.4em;"></span> Aktivitäten <span class="fa-solid fa-clock-rotate-left mr-2" aria-hidden="true" style="font-size: 1.4em;"></span> Aktivitäten
</NavLink> </NavLink>
</div>
</div> </div>
</div> }
<div class="flex-grow-1"></div> <div class="flex-grow-1"></div>
@@ -92,5 +92,5 @@
</div> </div>
</div> </div>
<div class="pb-1 text-center small">@($"v{Version ?? "0"}")</div> <div class="pb-1 text-center small text-muted">@($"Version {Version ?? "0"}")</div>
</nav> </nav>

View File

@@ -0,0 +1,16 @@
{
"GenerateScopedCss": true,
"ScopedCssFolders": [
"Views",
"Pages",
"Shared",
"Components",
"Controls"
],
"Compilations" : [
{
"Source": "wwwroot/css/site.scss",
"Target": "wwwroot/css/site.css"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -1,9 +1,9 @@
// Breakpoints matching typical Bootstrap breakpoints // Breakpoints matching typical Bootstrap breakpoints
$breakpoints: ( $breakpoints: (
"sm": 576px, sm: 576px,
"md": 768px, md: 768px,
"lg": 992px, lg: 992px,
"xl": 1200px xl: 1200px
); );
// Spacer values (1 to 5) mapped to rem // Spacer values (1 to 5) mapped to rem
@@ -58,6 +58,14 @@ $spacers: (
margin-top: $size-val !important; margin-top: $size-val !important;
margin-bottom: $size-val !important; margin-bottom: $size-val !important;
} }
.align-items-center#{$breakpoint} {
align-items: center !important;
}
.align-items-start#{$breakpoint} {
align-items: flex-start !important;
}
} }
} }

View File

@@ -4,6 +4,7 @@
html, body { html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
background-color: #f5ffed !important;
} }
h1:focus { h1:focus {
@@ -69,3 +70,6 @@ a, .btn-link {
content: "An error has occurred." content: "An error has occurred."
} }
.badge .badge-close {
padding: 0 .3rem;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -0,0 +1,21 @@
{
"name": "Foodsharing Einarbeitungen",
"short_name": "FS Onboarding",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#e8ffe2",
"background_color": "#f2ffe9",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -45,6 +45,10 @@ namespace FoodsharingSiegen.Shared.Helper
if (filter.DeletedOnly) if (filter.DeletedOnly)
filterListQ = filterListQ.Where(x => x.RecordState == RecordState.Archived); filterListQ = filterListQ.Where(x => x.RecordState == RecordState.Archived);
// Show only prospects with possible IdCheck
if (filter.IdCheckPossible)
filterListQ = filterListQ.Where(x => x.Images != null && x.Images.Count > 0);
// No Activity Filter // No Activity Filter
if (filter.NoActivity) if (filter.NoActivity)
filterListQ = filterListQ.Where(x => DateTime.Now - x.Modified > TimeSpan.FromDays(180)); filterListQ = filterListQ.Where(x => DateTime.Now - x.Modified > TimeSpan.FromDays(180));

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FoodsharingSiegen.Server\FoodsharingSiegen.Server.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,87 @@
using System.Text.Json;
using FoodsharingSiegen.Server.Service;
using Microsoft.JSInterop;
using Moq;
using Xunit;
namespace FoodsharingSiegen.Tests
{
public class LocalStorageServiceTests
{
[Fact]
public async Task GetItem_ReturnsDeserializedObject_WhenItemExists()
{
// Arrange
var mockJsRuntime = new Mock<IJSRuntime>();
var service = new LocalStorageService(mockJsRuntime.Object);
var expectedObject = new { Name = "Test" };
var jsonString = JsonSerializer.Serialize(expectedObject);
mockJsRuntime.Setup(x => x.InvokeAsync<string>("localStorage.getItem", It.IsAny<object[]>()))
.ReturnsAsync(jsonString);
// Act
var result = await service.GetItem<dynamic>("testKey");
// Assert
Assert.NotNull(result);
mockJsRuntime.Verify(x => x.InvokeAsync<string>("localStorage.getItem", It.Is<object[]>(args => args.Length == 1 && args[0].ToString() == "testKey")), Times.Once);
}
[Fact]
public async Task GetItem_ReturnsDefault_WhenItemDoesNotExist()
{
// Arrange
var mockJsRuntime = new Mock<IJSRuntime>();
var service = new LocalStorageService(mockJsRuntime.Object);
mockJsRuntime.Setup(x => x.InvokeAsync<string>("localStorage.getItem", It.IsAny<object[]>()))
.ReturnsAsync((string?)null);
// Act
var result = await service.GetItem<string>("testKey");
// Assert
Assert.Null(result);
}
[Fact]
public async Task SetItem_CallsSetItemInLocalStorage()
{
// Arrange
var mockJsRuntime = new Mock<IJSRuntime>();
var service = new LocalStorageService(mockJsRuntime.Object);
var objectToSave = new { Name = "Test" };
var expectedJson = JsonSerializer.Serialize(objectToSave);
// Act
await service.SetItem("testKey", objectToSave);
// Assert
// Note: InvokeVoidAsync is an extension method that calls InvokeAsync<IJSVoidResult> under the hood in Blazor.
mockJsRuntime.Verify(
x => x.InvokeAsync<It.IsAnyType>(
"localStorage.setItem",
It.Is<object[]>(args => args.Length == 2 && args[0].ToString() == "testKey" && args[1].ToString() == expectedJson)),
Times.Once);
}
[Fact]
public async Task RemoveItem_CallsRemoveItemInLocalStorage()
{
// Arrange
var mockJsRuntime = new Mock<IJSRuntime>();
var service = new LocalStorageService(mockJsRuntime.Object);
// Act
await service.RemoveItem("testKey");
// Assert
mockJsRuntime.Verify(
x => x.InvokeAsync<It.IsAnyType>(
"localStorage.removeItem",
It.Is<object[]>(args => args.Length == 1 && args[0].ToString() == "testKey")),
Times.Once);
}
}
}

View File

@@ -0,0 +1,110 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using FoodsharingSiegen.Contracts.Model;
using FoodsharingSiegen.Server.Service;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
using Moq;
using Xunit;
namespace FoodsharingSiegen.Tests
{
public class MailServiceTests
{
private readonly Mock<IOptions<AppSettings>> _mockOptions;
private readonly AppSettings _appSettings;
private readonly Mock<ISmtpClient> _mockSmtpClient;
public MailServiceTests()
{
_appSettings = new AppSettings
{
Mail = new MailSettings
{
Host = "smtp.test.com",
Port = 587,
UseSsl = false,
Username = "user@test.com",
Password = "password123",
FromAddress = "no-reply@test.com"
},
Terms = new TermSettings
{
Title = "Foodsharing Test"
}
};
_mockOptions = new Mock<IOptions<AppSettings>>();
_mockOptions.Setup(o => o.Value).Returns(_appSettings);
_mockSmtpClient = new Mock<ISmtpClient>();
}
[Fact]
public async Task SendEmailAsync_ConnectsAuthenticatesAndSendsEmail()
{
// Arrange
var service = new MailService(_mockOptions.Object, () => _mockSmtpClient.Object);
var toEmail = "recipient@test.com";
var subject = "Test Subject";
var body = "<p>Test Body</p>";
// Act
await service.SendEmailAsync(toEmail, subject, body);
// Assert
_mockSmtpClient.Verify(
x => x.ConnectAsync(
"smtp.test.com",
587,
SecureSocketOptions.Auto,
It.IsAny<CancellationToken>()),
Times.Once);
_mockSmtpClient.Verify(
x => x.AuthenticateAsync(
"user@test.com",
"password123",
It.IsAny<CancellationToken>()),
Times.Once);
// Verify a MimeMessage is passed to SendAsync with correct attributes
_mockSmtpClient.Verify(
x => x.SendAsync(
It.Is<MimeMessage>(m => m.Subject == subject),
It.IsAny<CancellationToken>(),
It.IsAny<MailKit.ITransferProgress>()),
Times.Once);
_mockSmtpClient.Verify(
x => x.DisconnectAsync(
true,
It.IsAny<CancellationToken>()),
Times.Once);
_mockSmtpClient.Verify(
x => x.Dispose(),
Times.Once);
}
[Fact]
public async Task SendEmailAsync_SkipsAuthentication_WhenUsernameIsBlank()
{
// Arrange
_appSettings.Mail.Username = "";
_appSettings.Mail.Password = "";
var service = new MailService(_mockOptions.Object, () => _mockSmtpClient.Object);
// Act
await service.SendEmailAsync("recipient@test.com", "Subject", "Body");
// Assert
_mockSmtpClient.Verify(
x => x.AuthenticateAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
}
}

View File

@@ -0,0 +1,236 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using FoodsharingSiegen.Contracts.Entity;
using FoodsharingSiegen.Contracts.Enums;
using FoodsharingSiegen.Contracts.Model;
using FoodsharingSiegen.Server.Auth;
using FoodsharingSiegen.Server.Data;
using FoodsharingSiegen.Server.Data.Service;
using FoodsharingSiegen.Server.Service;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.JSInterop;
using Moq;
using Xunit;
namespace FoodsharingSiegen.Tests
{
public class UserServiceTests
{
private FsContext CreateInMemoryContext(string dbName)
{
var options = new DbContextOptionsBuilder<FsContext>()
.UseInMemoryDatabase(databaseName: dbName)
.Options;
return new FsContext(options);
}
private AuthService CreateAuthService(FsContext context, User? currentUser = null)
{
var mockJsRuntime = new Mock<IJSRuntime>();
var localStorageService = new LocalStorageService(mockJsRuntime.Object);
var authStateProvider = new Mock<AuthenticationStateProvider>();
var mailService = new Mock<IMailService>();
var appSettings = new Mock<IOptions<AppSettings>>();
appSettings.Setup(x => x.Value).Returns(new AppSettings());
var authService = new AuthService(context, localStorageService, authStateProvider.Object, mailService.Object, appSettings.Object);
if (currentUser != null)
{
var field = typeof(AuthService).GetField("_user", BindingFlags.NonPublic | BindingFlags.Instance);
field?.SetValue(authService, currentUser);
}
return authService;
}
private AuditService CreateAuditService(FsContext context, AuthService authService)
{
return new AuditService(context, authService);
}
[Fact]
public async Task AddUserAsync_Fails_WhenEmailAlreadyExists()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var authService = CreateAuthService(context);
var auditService = CreateAuditService(context, authService);
var userService = new UserService(context, authService, auditService);
context.Users!.Add(new User { Mail = "existing@example.com", Name = "Existing", Password = "123" });
context.SaveChanges();
var result = await userService.AddUserAsync(new User { Mail = "EXISTING@example.com", Name = "New" });
Assert.False(result.Success);
Assert.Equal("Diese E-Mail Adresse wird bereits verwendet", result.ErrorMessage);
}
[Fact]
public async Task AddUserAsync_Succeeds_AndSetsPasswordEmpty_IfNull()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var authService = CreateAuthService(context);
var auditService = CreateAuditService(context, authService);
var userService = new UserService(context, authService, auditService);
var newUser = new User { Mail = "new@example.com", Name = "New", };
var result = await userService.AddUserAsync(newUser);
Assert.True(result.Success);
Assert.Equal(string.Empty, result.Data?.Password);
Assert.NotNull(result.Data?.Created);
Assert.Single(context.Users!);
Assert.Single(context.Audits!);
Assert.Equal(AuditType.CreateUser, context.Audits!.First().Type);
}
[Fact]
public async Task RemoveAsync_TransferInteractions_AndRemovesUser()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var currentUser = new User { Id = Guid.NewGuid(), Mail = "current@example.com" };
var authService = CreateAuthService(context, currentUser);
var auditService = CreateAuditService(context, authService);
var userService = new UserService(context, authService, auditService);
var userToRemove = new User { Id = Guid.NewGuid(), Mail = "remove@example.com", Type = UserType.User };
context.Users!.Add(currentUser);
context.Users!.Add(userToRemove);
context.Interactions!.Add(new Interaction { Id = Guid.NewGuid(), UserID = userToRemove.Id });
context.Audits!.Add(new Audit { Id = Guid.NewGuid(), UserID = userToRemove.Id, Type = AuditType.None });
context.SaveChanges();
var result = await userService.RemoveAsync(userToRemove.Id);
Assert.True(result.Success);
Assert.Empty(context.Users!.Where(u => u.Id == userToRemove.Id));
var interaction = context.Interactions!.First();
Assert.Equal(currentUser.Id, interaction.UserID);
Assert.Single(context.Audits!.Where(a => a.Type == AuditType.RemoveUser)); // created audit for remove
}
[Fact]
public async Task RemoveAsync_Fails_WhenLastAdmin()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var authService = CreateAuthService(context);
var auditService = CreateAuditService(context, authService);
var userService = new UserService(context, authService, auditService);
var admin = new User { Id = Guid.NewGuid(), Mail = "admin@example.com", Type = UserType.Admin };
context.Users!.Add(admin);
context.SaveChanges();
var result = await userService.RemoveAsync(admin.Id);
Assert.False(result.Success);
Assert.Equal("Der letzte Administrator kann nicht gelöscht werden.", result.ErrorMessage);
}
[Fact]
public async Task SetPassword_Fails_IfUserNotFound()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var authService = CreateAuthService(context);
var auditService = CreateAuditService(context, authService);
var userService = new UserService(context, authService, auditService);
var result = await userService.SetPassword(new User { Id = Guid.NewGuid(), Password = "P" });
Assert.False(result.Success);
Assert.Equal("User not found", result.ErrorMessage);
}
[Fact]
public async Task SetPassword_Succeeds()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var authService = CreateAuthService(context);
var auditService = CreateAuditService(context, authService);
var userService = new UserService(context, authService, auditService);
var user = new User { Id = Guid.NewGuid(), Mail = "test@example.com", Password = "Old" };
context.Users!.Add(user);
context.SaveChanges();
var result = await userService.SetPassword(new User { Id = user.Id, Password = "New" });
Assert.True(result.Success);
Assert.Equal("New", context.Users!.First().Password);
Assert.Equal(AuditType.SetUserPassword, context.Audits!.First().Type);
}
[Fact]
public async Task Update_Fails_IfNoChanges()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var authService = CreateAuthService(context);
var auditService = CreateAuditService(context, authService);
var userService = new UserService(context, authService, auditService);
var user = new User { Id = Guid.NewGuid(), Mail = "a@a.com", Type = UserType.User };
context.Users!.Add(user);
context.SaveChanges();
var result = await userService.Update(new User { Id = user.Id, Mail = "a@a.com", Type = UserType.User });
Assert.False(result.Success);
Assert.Equal("Nichts zum Speichern gefunden", result.ErrorMessage);
}
[Fact]
public async Task Update_ForcesLogoutOnChange()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var authService = CreateAuthService(context);
var auditService = CreateAuditService(context, authService);
var userService = new UserService(context, authService, auditService);
var user = new User { Id = Guid.NewGuid(), Mail = "a@a.com", Type = UserType.User };
context.Users!.Add(user);
context.SaveChanges();
var result = await userService.Update(new User { Id = user.Id, Mail = "b@b.com", Type = UserType.User });
Assert.True(result.Success);
Assert.True(context.Users!.First().ForceLogout);
Assert.Single(context.Audits!.Where(a => a.Type == AuditType.UpdateUser));
}
[Fact]
public async Task Update_Fails_WhenDemotingLastAdmin()
{
var dbName = Guid.NewGuid().ToString();
using var context = CreateInMemoryContext(dbName);
var authService = CreateAuthService(context);
var auditService = CreateAuditService(context, authService);
var userService = new UserService(context, authService, auditService);
var admin = new User { Id = Guid.NewGuid(), Mail = "a@a.com", Type = UserType.Admin };
context.Users!.Add(admin);
context.SaveChanges();
var result = await userService.Update(new User { Id = admin.Id, Mail = "a@a.com", Type = UserType.User });
Assert.False(result.Success);
Assert.Equal("Der Typ des letzten Administrators kann nicht geändert werden.", result.ErrorMessage);
}
}
}

View File

@@ -6,23 +6,68 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodsharingSiegen.Contracts
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodsharingSiegen.Shared", "FoodsharingSiegen.Shared\FoodsharingSiegen.Shared.csproj", "{625167D9-A375-40AF-82DE-87484519F6D9}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodsharingSiegen.Shared", "FoodsharingSiegen.Shared\FoodsharingSiegen.Shared.csproj", "{625167D9-A375-40AF-82DE-87484519F6D9}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FoodsharingSiegen.Tests", "FoodsharingSiegen.Tests\FoodsharingSiegen.Tests.csproj", "{A3BBF859-E3BB-420A-895F-B1BCF4B38B74}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{63D6CC91-095D-44C3-8752-660DDF9C710C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {63D6CC91-095D-44C3-8752-660DDF9C710C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{63D6CC91-095D-44C3-8752-660DDF9C710C}.Debug|Any CPU.Build.0 = Debug|Any CPU {63D6CC91-095D-44C3-8752-660DDF9C710C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{63D6CC91-095D-44C3-8752-660DDF9C710C}.Debug|x64.ActiveCfg = Debug|Any CPU
{63D6CC91-095D-44C3-8752-660DDF9C710C}.Debug|x64.Build.0 = Debug|Any CPU
{63D6CC91-095D-44C3-8752-660DDF9C710C}.Debug|x86.ActiveCfg = Debug|Any CPU
{63D6CC91-095D-44C3-8752-660DDF9C710C}.Debug|x86.Build.0 = Debug|Any CPU
{63D6CC91-095D-44C3-8752-660DDF9C710C}.Release|Any CPU.ActiveCfg = Release|Any CPU {63D6CC91-095D-44C3-8752-660DDF9C710C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{63D6CC91-095D-44C3-8752-660DDF9C710C}.Release|Any CPU.Build.0 = Release|Any CPU {63D6CC91-095D-44C3-8752-660DDF9C710C}.Release|Any CPU.Build.0 = Release|Any CPU
{63D6CC91-095D-44C3-8752-660DDF9C710C}.Release|x64.ActiveCfg = Release|Any CPU
{63D6CC91-095D-44C3-8752-660DDF9C710C}.Release|x64.Build.0 = Release|Any CPU
{63D6CC91-095D-44C3-8752-660DDF9C710C}.Release|x86.ActiveCfg = Release|Any CPU
{63D6CC91-095D-44C3-8752-660DDF9C710C}.Release|x86.Build.0 = Release|Any CPU
{F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Debug|Any CPU.Build.0 = Debug|Any CPU {F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Debug|x64.ActiveCfg = Debug|Any CPU
{F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Debug|x64.Build.0 = Debug|Any CPU
{F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Debug|x86.ActiveCfg = Debug|Any CPU
{F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Debug|x86.Build.0 = Debug|Any CPU
{F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Release|Any CPU.ActiveCfg = Release|Any CPU {F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Release|Any CPU.Build.0 = Release|Any CPU {F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Release|Any CPU.Build.0 = Release|Any CPU
{F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Release|x64.ActiveCfg = Release|Any CPU
{F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Release|x64.Build.0 = Release|Any CPU
{F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Release|x86.ActiveCfg = Release|Any CPU
{F39AE3B4-E4CE-421E-AFB0-E9C9B3B670FE}.Release|x86.Build.0 = Release|Any CPU
{625167D9-A375-40AF-82DE-87484519F6D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {625167D9-A375-40AF-82DE-87484519F6D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{625167D9-A375-40AF-82DE-87484519F6D9}.Debug|Any CPU.Build.0 = Debug|Any CPU {625167D9-A375-40AF-82DE-87484519F6D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{625167D9-A375-40AF-82DE-87484519F6D9}.Debug|x64.ActiveCfg = Debug|Any CPU
{625167D9-A375-40AF-82DE-87484519F6D9}.Debug|x64.Build.0 = Debug|Any CPU
{625167D9-A375-40AF-82DE-87484519F6D9}.Debug|x86.ActiveCfg = Debug|Any CPU
{625167D9-A375-40AF-82DE-87484519F6D9}.Debug|x86.Build.0 = Debug|Any CPU
{625167D9-A375-40AF-82DE-87484519F6D9}.Release|Any CPU.ActiveCfg = Release|Any CPU {625167D9-A375-40AF-82DE-87484519F6D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{625167D9-A375-40AF-82DE-87484519F6D9}.Release|Any CPU.Build.0 = Release|Any CPU {625167D9-A375-40AF-82DE-87484519F6D9}.Release|Any CPU.Build.0 = Release|Any CPU
{625167D9-A375-40AF-82DE-87484519F6D9}.Release|x64.ActiveCfg = Release|Any CPU
{625167D9-A375-40AF-82DE-87484519F6D9}.Release|x64.Build.0 = Release|Any CPU
{625167D9-A375-40AF-82DE-87484519F6D9}.Release|x86.ActiveCfg = Release|Any CPU
{625167D9-A375-40AF-82DE-87484519F6D9}.Release|x86.Build.0 = Release|Any CPU
{A3BBF859-E3BB-420A-895F-B1BCF4B38B74}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A3BBF859-E3BB-420A-895F-B1BCF4B38B74}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A3BBF859-E3BB-420A-895F-B1BCF4B38B74}.Debug|x64.ActiveCfg = Debug|Any CPU
{A3BBF859-E3BB-420A-895F-B1BCF4B38B74}.Debug|x64.Build.0 = Debug|Any CPU
{A3BBF859-E3BB-420A-895F-B1BCF4B38B74}.Debug|x86.ActiveCfg = Debug|Any CPU
{A3BBF859-E3BB-420A-895F-B1BCF4B38B74}.Debug|x86.Build.0 = Debug|Any CPU
{A3BBF859-E3BB-420A-895F-B1BCF4B38B74}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A3BBF859-E3BB-420A-895F-B1BCF4B38B74}.Release|Any CPU.Build.0 = Release|Any CPU
{A3BBF859-E3BB-420A-895F-B1BCF4B38B74}.Release|x64.ActiveCfg = Release|Any CPU
{A3BBF859-E3BB-420A-895F-B1BCF4B38B74}.Release|x64.Build.0 = Release|Any CPU
{A3BBF859-E3BB-420A-895F-B1BCF4B38B74}.Release|x86.ActiveCfg = Release|Any CPU
{A3BBF859-E3BB-420A-895F-B1BCF4B38B74}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal