Compare commits

...

2 Commits

Author SHA1 Message Date
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
9 changed files with 488 additions and 155 deletions

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

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

View File

@@ -179,7 +179,6 @@ namespace FoodsharingSiegen.Server.Data.Service
if (entityUser == null) return new(new Exception("User not found")); if (entityUser == null) return new(new Exception("User not found"));
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 +187,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

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

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

@@ -6,111 +6,131 @@
@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> <div>
<DataGridColumn TItem="User" Field="@nameof(User.Type)" Caption="Typ" Editable="true" Width="200px"> <Button Color="Color.Primary" Size="Size.Small" Class="me-2" Clicked="() => EditUser(user)"><Icon Name="IconName.Edit" /></Button>
<EditTemplate> <Button Color="Color.Info" Size="Size.Small" Clicked="() => SetPassword(user)"><i class="fa-solid fa-key"></i></Button>
<Select TValue="UserType" SelectedValue="@((UserType)context.CellValue)" SelectedValueChanged="@(v => context.CellValue = v)"> </div>
<Button Color="Color.Danger" Size="Size.Small" Clicked="() => RemoveUserAsync(user)"><Icon Name="IconName.Delete" /></Button>
</CardFooter>
</Card>
}
}
</div>
<style>
.user-grid {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 1rem;
}
@@media (min-width: 700px) {
.user-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@@media (min-width: 1070px) {
.user-grid {
grid-template-columns: repeat(3, 1fr);
}
}
</style>
<Modal @ref="editUserModal">
<ModalContent Centered>
<ModalHeader>
<ModalTitle>@(IsEditing ? "Benutzer bearbeiten" : "Benutzer erstellen")</ModalTitle>
<CloseButton />
</ModalHeader>
<ModalBody>
@if (EditModel != null)
{
<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)">
@foreach (var enumValue in Enum.GetValues<UserType>()) @foreach (var enumValue in Enum.GetValues<UserType>())
{ {
<SelectItem TValue="UserType" Value="enumValue">@enumValue</SelectItem> <SelectItem TValue="UserType" Value="enumValue">@enumValue</SelectItem>
} }
</Select> </Select>
</EditTemplate> </Field>
</DataGridColumn> <Field>
<DataGridColumn TItem="User" Field="@nameof(User.Name)" Caption="Name" Editable="true" Width="250px"></DataGridColumn> <FieldLabel>Gruppen</FieldLabel>
<DataGridColumn TItem="User" Field="@nameof(User.Mail)" Caption="E-Mail" Editable="true"></DataGridColumn>
<DataGridColumn TItem="User" Field="@nameof(User.GroupsList)" Caption="Gruppen" Editable="true">
<EditTemplate>
<Autocomplete TItem="UserGroup" <Autocomplete TItem="UserGroup"
TValue="UserGroup" TValue="UserGroup"
Size="Size.ExtraSmall"
Data="@UserGroups" Data="@UserGroups"
TextField="@(( item ) => item.ToString())" TextField="@(( item ) => item.ToString())"
ValueField="@(( item ) => item)" ValueField="@(( item ) => item)"
SelectionMode="AutocompleteSelectionMode.Multiple" SelectionMode="AutocompleteSelectionMode.Multiple"
SelectedValues="@((List<UserGroup>) context.CellValue)" SelectedValues="@EditModel.GroupsList"
SelectedValuesChanged="@(v => { context.CellValue = v.ToList(); })" SelectedValuesChanged="@(v => { EditModel.GroupsList = v.ToList(); })"
@bind-SelectedTexts="SelectedCompanyTexts"> @bind-SelectedTexts="SelectedGroupTexts">
</Autocomplete> </Autocomplete>
<small>Verfügbar: @string.Join(", ", Enum.GetValues<UserGroup>())</small> <small>Verfügbar: @string.Join(", ", Enum.GetValues<UserGroup>())</small>
</EditTemplate> </Field>
<DisplayTemplate>
@if (string.IsNullOrWhiteSpace(context.Groups))
{
<span style="font-style: italic;">Keine Gruppen</span>
} }
else </ModalBody>
{ <ModalFooter>
<span>@string.Join(", ", context.GroupsList)</span> <Button Color="Color.Secondary" Clicked="() => editUserModal?.Hide()">Abbrechen</Button>
} <Button Color="Color.Primary" Clicked="SaveUser">Speichern</Button>
</DisplayTemplate> </ModalFooter>
</DataGridColumn> </ModalContent>
</DataGridColumns> </Modal>
</DataGrid>
<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;
@@ -32,19 +32,24 @@ 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> /// <summary>
/// Gets the value of the user groups (ab) /// Gets the value of the user groups (ab)
@@ -56,6 +61,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 +94,54 @@ 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 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>
@@ -125,39 +183,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
} }
} }