Compare commits

..

19 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
34 changed files with 1364 additions and 305 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

View File

@@ -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

@@ -142,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);
@@ -156,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
@@ -206,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;
@@ -217,6 +251,14 @@ 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}";

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

@@ -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

@@ -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">

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

@@ -10,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>

View File

@@ -23,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()
@@ -77,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()

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

@@ -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>

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

@@ -11,96 +11,113 @@
<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-3 text-center"> @if (_isLoading)
@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
{ {
<InputFile id="fileInput" OnChange="OnInputFileChange" class="d-none" accept="image/*" /> <div class="alert alert-info mb-4">
<label for="fileInput" class="btn btn-outline-success w-100" style="height: 5rem;"> <strong>Hinweis:</strong> Dies ist die Upload-Seite für Foodsaver <b>@_prospect.FsId</b>.
<i class="fa-solid fa-images me-2"></i>Bild auswählen
</label>
<span class="badge bg-secondary mt-2 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> </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) {
SelectionMode="AutocompleteSelectionMode.Multiple" .user-grid {
SelectedValues="@((List<UserGroup>) context.CellValue)" grid-template-columns: repeat(2, 1fr);
SelectedValuesChanged="@(v => { context.CellValue = v.ToList(); })" }
@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.OldItem);
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.OldItem?.Id == null || arg.OldItem.Id.Equals(Guid.Empty) || arg.Values?.Any() != true) return;
var updateR = await UserService.Update(arg.OldItem);
if (!updateR.Success)
await Notification.Error($"Fehler beim Speichern: {updateR.ErrorMessage}")!;
}
#endregion
} }
} }

View File

@@ -14,18 +14,18 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="Foodsharing Einarbeitungen" /> <meta name="apple-mobile-web-app-title" content="Foodsharing Einarbeitungen" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css"/> <link rel="stylesheet" href="~/css/bootstrap/bootstrap.min.css" asp-append-version="true" />
<link href="css/site.css" rel="stylesheet"/> <link href="~/css/site.css" rel="stylesheet" asp-append-version="true" />
<link href="FoodsharingSiegen.Server.styles.css" rel="stylesheet"/> <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,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

@@ -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>

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