Compare commits

...

12 Commits

Author SHA1 Message Date
troogs
ad6f28023e Implement password recovery feature with reset token and email notifications
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m28s
2026-04-18 13:36:21 +02:00
troogs
c5c24d44c9 Add GitHub Actions workflow for building and pushing development Docker image
All checks were successful
Build And Push Dev Docker Image / docker (push) Successful in 1m47s
2026-04-18 12:41:38 +02:00
troogs
3687e573e0 Enhance login page with improved UI and error handling for invalid credentials 2026-04-18 01:57:31 +02:00
troogs
b7a7a8e078 Initialize TestMailReceiver with current user's email in Settings page 2026-04-18 01:46:25 +02:00
troogs
15780bccee Implement mail service with configuration and settings management 2026-04-18 01:43:52 +02:00
troogs
82d2c48ff7 Add launchUrl to project settings in launchSettings.json 2026-04-17 16:35:52 +02:00
troogs
41b1f8ae1d Update .gitignore to include buildinfo.txt and remove outdated app.db entry 2026-04-17 16:35:47 +02:00
troogs
eda6625e91 Add sorting functionality for prospects with new ProspectSortControl component 2026-04-16 21:42:15 +02:00
troogs
701388ee34 Implement sorting functionality for prospects with a dialog and custom sort options 2026-04-16 19:36:32 +02:00
troogs
3e099988bc Add ProspectSortOption enum and implement sorting dialog with buttons 2026-04-16 19:36:26 +02:00
troogs
ee967cd046 Enhance Prospect display in ProspectContainer.razor to show warning for missing name and format FsId 2026-04-16 16:59:04 +02:00
troogs
76c3e6ddde Refactor Button component in Prospects.razor for improved styling and structure 2026-04-16 16:57:39 +02:00
45 changed files with 1145 additions and 76 deletions

View File

@@ -0,0 +1,41 @@
name: Build And Push Dev Docker Image
on:
push:
branches:
- master
workflow_dispatch:
jobs:
docker:
runs-on: ubuntu-latest
env:
DOTNET_ROLL_FORWARD: Major
REGISTRY: git.beging.de
BASE_IMAGE: git.beging.de/troogs/fs-onboarding-server
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup .NET SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: "9.0.x"
- name: Publish server project
run: dotnet publish ./FoodsharingSiegen.Server/FoodsharingSiegen.Server.csproj -c Release -o ./Publish/Server
- name: Login to Gitea registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ env.REGISTRY }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: Build docker image
run: |
docker build \
-f ./Docker/dockerfile.server \
-t "${{ env.BASE_IMAGE }}:dev" \
.
- name: Push docker images
run: |
docker push "${{ env.BASE_IMAGE }}:dev"

View File

@@ -1,50 +0,0 @@
name: Build And Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag_name:
description: "Tag for the release (for example: v1.0.0)"
required: true
type: string
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
env:
RELEASE_TAG: ${{ github.event.inputs.tag_name || github.ref_name }}
DOTNET_ROLL_FORWARD: Major
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup .NET SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: "9.0.x"
- name: Restore dependencies
run: dotnet restore FoodsharingSiegen.sln
- name: Publish server build
run: dotnet publish ./FoodsharingSiegen.Server/FoodsharingSiegen.Server.csproj -c Release -o ./artifacts/publish
- name: Create zip package
run: |
cd artifacts
zip -r FoodsharingSiegen.Server-${{ env.RELEASE_TAG }}.zip publish
- name: Create release and upload artifact
uses: https://gitea.com/actions/gitea-release-action@v1
with:
files: artifacts/FoodsharingSiegen.Server-${{ env.RELEASE_TAG }}.zip
tag_name: ${{ env.RELEASE_TAG }}
name: Release ${{ env.RELEASE_TAG }}
target_commitish: ${{ github.sha }}
token: ${{ github.token }}

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ riderModule.iml
Publish/
app.db
FoodsharingSiegen.Server/wwwroot/buildinfo.txt
FoodsharingSiegen.Server/config/appsettings.json

7
Docker/.env Normal file
View File

@@ -0,0 +1,7 @@
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=your_username
MAIL_PASSWORD=your_password
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME="FS Einarbeitungen Musterhausen"
MAIL_USE_SSL=true

View File

@@ -5,6 +5,17 @@ services:
image: ghcr.io/troogs/fs-onboarding/server:latest
ports:
- "8100:56000"
env_file:
- .env
environment:
# Mail Configuration Examples. These override options in appsettings.json.
# All values below can also be set via the active .env file or appsettings.json.
- Settings__Mail__Host=${MAIL_HOST:-smtp.example.com}
- Settings__Mail__Port=${MAIL_PORT:-587}
- Settings__Mail__Username=${MAIL_USERNAME:-your_username}
- Settings__Mail__Password=${MAIL_PASSWORD:-your_password}
- Settings__Mail__FromAddress=${MAIL_FROM_ADDRESS:-no-reply@example.com}
- Settings__Mail__UseSsl=${MAIL_USE_SSL:-true}
volumes:
- /docker/data/fs-onboarding/config:/app/config/
- /docker/data/fs-onboarding/data:/app/data/

View File

@@ -95,6 +95,16 @@ namespace FoodsharingSiegen.Contracts.Entity
set => EncryptedPassword = Cryptor.Encrypt(value);
}
/// <summary>
/// Gets or sets the reset token for password recovery (ab)
/// </summary>
public string? ResetToken { get; set; }
/// <summary>
/// Gets or sets the expiry date for the reset token (ab)
/// </summary>
public DateTime? ResetTokenExpiry { get; set; }
/// <summary>
/// Gets or sets the value of the type (ab)
/// </summary>

View File

@@ -0,0 +1,10 @@
namespace FoodsharingSiegen.Contracts.Enums
{
public enum ProspectSortOption
{
NameAscending,
NameDescending,
ModifiedAscending,
ModifiedDescending
}
}

View File

@@ -6,6 +6,11 @@
public bool DisableStepIn { get; set; }
/// <summary>
/// Gets or sets the mail server settings.
/// </summary>
public MailSettings Mail { get; set; } = new();
public TermSettings Terms { get; set; } = new();
public bool TestMode { get; set; }

View File

@@ -0,0 +1,38 @@
namespace FoodsharingSiegen.Contracts.Model
{
/// <summary>
/// Configuration settings for the mail service.
/// </summary>
public class MailSettings
{
/// <summary>
/// Gets or sets the SMTP server host.
/// </summary>
public string Host { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the SMTP port.
/// </summary>
public int Port { get; set; } = 587;
/// <summary>
/// Gets or sets the username for SMTP authentication.
/// </summary>
public string Username { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the password for SMTP authentication.
/// </summary>
public string Password { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the sender address.
/// </summary>
public string FromAddress { get; set; } = string.Empty;
/// <summary>
/// Gets or sets whether to use SSL/TLS connection.
/// </summary>
public bool UseSsl { get; set; } = true;
}
}

View File

@@ -12,6 +12,26 @@ namespace FoodsharingSiegen.Contracts
/// </summary>
public const string ProspectFilter = "ProspectFilter";
/// <summary>
/// Represents the storage key used for prospect sorting preferences.
/// </summary>
public const string SortProspects = "SortProspects";
/// <summary>
/// Represents the storage key used for sorting all prospects.
/// </summary>
public const string SortProspectsAll = "SortProspectsAll";
/// <summary>
/// Represents the storage key used for sorting completed prospects.
/// </summary>
public const string SortProspectsDone = "SortProspectsDone";
/// <summary>
/// Represents the storage key used for sorting prospects pending verification.
/// </summary>
public const string SortProspectsVerify = "SortProspectsVerify";
/// <summary>
/// The token key
/// </summary>

View File

@@ -50,6 +50,11 @@ namespace FoodsharingSiegen.Server.Auth
/// </summary>
private User? _user;
/// <summary>
/// The mail service
/// </summary>
private readonly IMailService _mailService;
#endregion
#region Setup/Teardown
@@ -60,14 +65,17 @@ namespace FoodsharingSiegen.Server.Auth
/// <param name="context">The context</param>
/// <param name="localStorageService">The local storage service</param>
/// <param name="authenticationStateProvider">The authentication state provider</param>
/// <param name="mailService">The mail service</param>
public AuthService(
FsContext context,
LocalStorageService localStorageService,
AuthenticationStateProvider authenticationStateProvider)
AuthenticationStateProvider authenticationStateProvider,
IMailService mailService)
{
Context = context;
_localStorageService = localStorageService;
_authenticationStateProvider = authenticationStateProvider;
_mailService = mailService;
}
#endregion
@@ -186,5 +194,52 @@ namespace FoodsharingSiegen.Server.Auth
}
#endregion
#region Password Recovery
public async Task InitiatePasswordReset(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.AddMinutes(30);
await Context.SaveChangesAsync();
var resetLink = $"{baseUri.TrimEnd('/')}/reset-password/{resetToken}";
var mailBody = $"Hallo {user.Name},<br><br>Um dein Passwort zurückzusetzen, klicke bitte auf den folgenden Link (dieser ist 30 Minuten gültig):<br><a href='{resetLink}'>{resetLink}</a><br><br>Viele Grüße<br>Dein Foodsharing Team";
await _mailService.SendEmailAsync(user.Mail, "Passwort zurücksetzen", mailBody);
}
public async Task<OperationResult> ResetPassword(string token, string newPassword)
{
if (string.IsNullOrWhiteSpace(token)) return new OperationResult(new Exception("Ungültiges Token."));
if (string.IsNullOrWhiteSpace(newPassword)) return new OperationResult(new Exception("Passwort darf nicht leer sein."));
var user = await Context.Users!.FirstOrDefaultAsync(x => x.ResetToken == token && x.ResetTokenExpiry > DateTime.UtcNow);
if (user == null) return new OperationResult(new Exception("Token ist ungültig oder abgelaufen."));
user.Password = newPassword;
user.ResetToken = null;
user.ResetTokenExpiry = null;
await Context.SaveChangesAsync();
return new OperationResult();
}
public async Task<bool> IsResetTokenValid(string token)
{
if (string.IsNullOrWhiteSpace(token)) return false;
var user = await Context.Users!.FirstOrDefaultAsync(x => x.ResetToken == token && x.ResetTokenExpiry > DateTime.UtcNow);
return user != null;
}
#endregion
}
}

View File

@@ -12,8 +12,23 @@
<div class="@divClass">
<h5 class="mb-2 d-flex">
<div class="flex-grow-1">
@if(string.IsNullOrWhiteSpace(Prospect?.Name))
{
<i class="fa-solid fa-exclamation-triangle text-warning"></i>
<doublearrows></doublearrows>
<em>»Name fehlt«</em>
}
else
{
@Prospect?.Name
<small style="font-size: .9rem;">@Prospect?.FsId</small>
}
@if (Prospect?.FsId != null && Prospect.FsId != 0)
{
<small style="font-size: .9rem; margin-left: 0.5rem;">@Prospect?.FsId</small>
}
</div>
<div>
@if (CurrentUser.IsInGroup(UserGroup.WelcomeTeam, UserGroup.Ambassador))

View File

@@ -0,0 +1,17 @@
@using FoodsharingSiegen.Contracts.Enums
<Button Color="Color.Primary"
Width="Width.Px(50)"
Height="Height.Px(50)"
title="Sortieren"
style="min-width: auto;"
Clicked="@OpenSortDialogAsync">
<i class="fa-solid fa-sort"></i>
</Button>
<div class="badge-row mt-2">
@if (HasCustomSort)
{
<Badge Color="Color.Primary" Closable="true" CloseClicked="@EventCallback.Factory.Create(this, ResetSortAsync)">@CurrentSortText</Badge>
}
</div>

View File

@@ -0,0 +1,78 @@
using Blazorise;
using FoodsharingSiegen.Contracts.Entity;
using FoodsharingSiegen.Contracts.Enums;
using FoodsharingSiegen.Server.Data.Service;
using FoodsharingSiegen.Server.Dialogs;
using FoodsharingSiegen.Server.Service;
using Microsoft.AspNetCore.Components;
namespace FoodsharingSiegen.Server.Controls;
public partial class ProspectSortControl
{
[Inject] private IModalService ModalService { get; set; } = null!;
[Inject] private LocalStorageService LocalStorageService { get; set; } = null!;
[Parameter]
public ProspectSortOption CurrentSort { get; set; } = ProspectSortOption.NameAscending;
[Parameter]
public EventCallback<ProspectSortOption> CurrentSortChanged { get; set; }
[Parameter]
public EventCallback OnSortChanged { get; set; }
[Parameter]
public string? StorageKey { get; set; }
protected override async Task OnInitializedAsync()
{
if (!string.IsNullOrEmpty(StorageKey))
{
var savedSort = await LocalStorageService.GetItem<ProspectSortOption?>(StorageKey);
if (savedSort.HasValue)
{
CurrentSort = savedSort.Value;
await CurrentSortChanged.InvokeAsync(CurrentSort);
}
}
await base.OnInitializedAsync();
}
private async Task OpenSortDialogAsync()
{
await ProspectSortDialog.ShowAsync(ModalService, CurrentSort, async option =>
{
await UpdateSortAsync(option);
});
}
private bool HasCustomSort => CurrentSort != ProspectSortOption.NameAscending;
private string CurrentSortText => CurrentSort switch
{
ProspectSortOption.NameDescending => "Sortierung: Name (absteigend)",
ProspectSortOption.ModifiedAscending => "Sortierung: Zuletzt geändert (aufsteigend)",
ProspectSortOption.ModifiedDescending => "Sortierung: Zuletzt geaendert (absteigend)",
_ => string.Empty
};
private async Task ResetSortAsync()
{
await UpdateSortAsync(ProspectSortOption.NameAscending);
}
private async Task UpdateSortAsync(ProspectSortOption option)
{
CurrentSort = option;
if (!string.IsNullOrEmpty(StorageKey))
{
await LocalStorageService.SetItem(StorageKey, CurrentSort);
}
await CurrentSortChanged.InvokeAsync(CurrentSort);
await OnSortChanged.InvokeAsync();
}
}

View File

@@ -0,0 +1,9 @@
@inherits FsBase
@using FoodsharingSiegen.Contracts.Enums
<div class="d-grid gap-2">
<Button Color="@GetSortButtonColor(ProspectSortOption.NameAscending)" Clicked="() => SelectAsync(ProspectSortOption.NameAscending)">Name (aufsteigend)</Button>
<Button Color="@GetSortButtonColor(ProspectSortOption.NameDescending)" Clicked="() => SelectAsync(ProspectSortOption.NameDescending)">Name (absteigend)</Button>
<Button Color="@GetSortButtonColor(ProspectSortOption.ModifiedAscending)" Clicked="() => SelectAsync(ProspectSortOption.ModifiedAscending)">Geändert (aufsteigend)</Button>
<Button Color="@GetSortButtonColor(ProspectSortOption.ModifiedDescending)" Clicked="() => SelectAsync(ProspectSortOption.ModifiedDescending)">Geändert (absteigend)</Button>
</div>

View File

@@ -0,0 +1,39 @@
using Blazorise;
using FoodsharingSiegen.Contracts.Enums;
using FoodsharingSiegen.Server.BaseClasses;
using Microsoft.AspNetCore.Components;
namespace FoodsharingSiegen.Server.Dialogs
{
public partial class ProspectSortDialog : FsBase
{
[Parameter]
public ProspectSortOption CurrentSort { get; set; } = ProspectSortOption.NameAscending;
[Parameter]
public Func<ProspectSortOption, Task>? OnSortSelected { get; set; }
public static async Task ShowAsync(IModalService modalService, ProspectSortOption currentSort, Func<ProspectSortOption, Task> onSortSelected)
{
await modalService.Show<ProspectSortDialog>("Sortieren", p =>
{
p.Add(nameof(CurrentSort), currentSort);
p.Add(nameof(OnSortSelected), onSortSelected);
}, new ModalInstanceOptions
{
Size = ModalSize.Small,
});
}
private Color GetSortButtonColor(ProspectSortOption option)
{
return CurrentSort == option ? Color.Success : Color.Secondary;
}
private async Task SelectAsync(ProspectSortOption option)
{
if (OnSortSelected != null) await OnSortSelected(option);
await ModalService.Hide();
}
}
}

View File

@@ -29,6 +29,7 @@
<ItemGroup>
<PackageReference Include="LibSassBuilder" Version="2.0.1" />
<PackageReference Include="MailKit" Version="4.4.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets>

View File

@@ -0,0 +1,223 @@
// <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("20260418111848_PasswordRecovery")]
partial class PasswordRecovery
{
/// <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<bool>("Warning")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Prospects");
});
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.Property<bool>("Verified")
.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.Prospect", b =>
{
b.Navigation("Interactions");
});
modelBuilder.Entity("FoodsharingSiegen.Contracts.Entity.User", b =>
{
b.Navigation("Interactions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FoodsharingSiegen.Server.Migrations
{
/// <inheritdoc />
public partial class PasswordRecovery : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ResetToken",
table: "Users",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ResetTokenExpiry",
table: "Users",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ResetToken",
table: "Users");
migrationBuilder.DropColumn(
name: "ResetTokenExpiry",
table: "Users");
}
}
}

View File

@@ -160,6 +160,12 @@ namespace FoodsharingSiegen.Server.Migrations
b.Property<int>("Network")
.HasColumnType("INTEGER");
b.Property<string>("ResetToken")
.HasColumnType("TEXT");
b.Property<DateTime?>("ResetTokenExpiry")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");

View File

@@ -0,0 +1,54 @@
@page "/forgot-password"
@using FoodsharingSiegen.Shared.Helper
@layout LoginLayout
@inherits FoodsharingSiegen.Server.BaseClasses.FsBase
<PageTitle>@AppSettings.Terms.Title - Passwort vergessen</PageTitle>
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh; background-color: #f4f6f8;">
<div class="card shadow border-0" style="width: 100%; max-width: 420px; border-radius: 12px; margin: 1rem;">
<div class="card-body p-4 p-md-5">
<div class="text-center mb-4">
<i class="fa-solid fa-leaf mb-3" style="font-size: 3rem; color: #64ae24;"></i>
<h4 class="font-weight-bold" style="color: #533a20;"><small style="font-size: .6em;">Einarbeitungen</small> @AppSettings.Terms.Title</h4>
<p class="text-muted">Passwort zurücksetzen</p>
</div>
@if (IsSubmitted)
{
<div class="alert alert-success text-center">
Wenn ein Benutzerkonto mit dieser E-Mail-Adresse existiert, wurde eine E-Mail mit weiteren Anweisungen versendet.
</div>
<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>
</div>
}
else
{
<Validation Validator="ValidationHelper.ValidateMail" @bind-Status="@IsValidMail">
<Field>
<FieldLabel>E-Mail Adresse</FieldLabel>
<TextEdit @bind-Text="MailAddress" Role="TextRole.Email" Placeholder="E-Mail" KeyUp="TextEdit_KeyUp" Size="Size.Large"></TextEdit>
</Field>
</Validation>
<Button Class="mt-4 w-100" Color="Color.Primary" Size="Size.Large" Clicked="SubmitRequest" Disabled="@(IsValidMail != ValidationStatus.Success || IsLoading)">
@if (IsLoading)
{
<i class="fas fa-spinner fa-spin mr-2"></i> <span>Wird gesendet...</span>
}
else
{
<i class="fas fa-paper-plane mr-2"></i> <span>Passwort zurücksetzen</span>
}
</Button>
<div class="text-center mt-3">
<a href="/login" style="font-size: 0.85rem; color: #64ae24; text-decoration: none;"><i class="fas fa-arrow-left mr-1"></i> Zurück zum Login</a>
</div>
}
</div>
</div>
</div>

View File

@@ -0,0 +1,41 @@
using Blazorise;
using FoodsharingSiegen.Server.BaseClasses;
using FoodsharingSiegen.Server.Auth;
using FoodsharingSiegen.Shared.Helper;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using System.Threading.Tasks;
namespace FoodsharingSiegen.Server.Pages
{
public partial class ForgotPassword : FsBase
{
public string MailAddress { get; set; } = string.Empty;
public ValidationStatus IsValidMail { get; set; } = ValidationStatus.None;
public bool IsSubmitted { get; set; }
public bool IsLoading { get; set; }
public async Task SubmitRequest()
{
if (IsValidMail != ValidationStatus.Success) return;
IsLoading = true;
await InvokeAsync(StateHasChanged);
await AuthService.InitiatePasswordReset(MailAddress, NavigationManager.BaseUri);
IsSubmitted = true;
IsLoading = false;
await InvokeAsync(StateHasChanged);
}
public async Task TextEdit_KeyUp(KeyboardEventArgs e)
{
if (e.Key == "Enter" && IsValidMail == ValidationStatus.Success)
{
await SubmitRequest();
}
}
}
}

View File

@@ -4,24 +4,45 @@
@inherits FoodsharingSiegen.Server.BaseClasses.FsBase
<PageTitle>@AppSettings.Terms.Title</PageTitle>
<PageTitle>@AppSettings.Terms.Title - Login</PageTitle>
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh; background-color: #f4f6f8;">
<div class="card shadow border-0" style="width: 100%; max-width: 420px; border-radius: 12px; margin: 1rem;">
<div class="card-body p-4 p-md-5">
<div class="text-center mb-4">
<i class="fa-solid fa-leaf mb-3" style="font-size: 3rem; color: #64ae24;"></i>
<h4 class="font-weight-bold" style="color: #533a20;"><small style="font-size: .6em;">Einarbeitungen</small> @AppSettings.Terms.Title</h4>
<p class="text-muted">Bitte melde dich an, um fortzufahren.</p>
</div>
<div class="d-flex justify-content-center align-items-center" style="height: 100vh;">
<div class="card" style="width: 100%; max-width: 380px;">
<div class="card-header">@AppSettings.Terms.Title</div>
<div class="card-body">
<Validation Validator="ValidationHelper.ValidateMail" @bind-Status="@IsValidMail">
<TextEdit @bind-Text="MailAddress" Role="TextRole.Email" Placeholder="E-Mail" Class="mt-0" KeyUp="TextEdit_KeyUp"></TextEdit>
<Field>
<FieldLabel>E-Mail Adresse</FieldLabel>
<TextEdit @bind-Text="MailAddress" Role="TextRole.Email" Placeholder="E-Mail" KeyUp="TextEdit_KeyUp" Size="Size.Large"></TextEdit>
</Field>
</Validation>
<Validation Validator="ValidationHelper.ValidatePassword" @bind-Status="@IsValidPassword">
<TextEdit @bind-Text="Password" Role="TextRole.Password" Placeholder="Passwort" Class="my-3" KeyUp="TextEdit_KeyUp"></TextEdit>
<Field Class="mt-3">
<FieldLabel>Passwort</FieldLabel>
<TextEdit @bind-Text="Password" Role="TextRole.Password" Placeholder="Passwort" KeyUp="TextEdit_KeyUp" Size="Size.Large"></TextEdit>
</Field>
</Validation>
<div class="d-flex justify-content-center">
<Button Clicked="PerformLogin" Disabled="@(IsValidMail != ValidationStatus.Success || IsValidPassword != ValidationStatus.Success)">Einloggen</Button>
@if (!string.IsNullOrEmpty(LoginErrorMessage))
{
<div class="text-danger mt-3 text-center">
<i class="fas fa-exclamation-triangle mr-1"></i> @LoginErrorMessage
</div>
}
<Button Class="mt-4 w-100" Color="Color.Primary" Size="Size.Large" Clicked="PerformLogin" Disabled="@(IsValidMail != ValidationStatus.Success || IsValidPassword != ValidationStatus.Success)">
<i class="fas fa-sign-in-alt mr-2"></i> Einloggen
</Button>
<div class="d-flex justify-content-center align-items-center mt-2">
<a href="/forgot-password" tabindex="-1" style="font-size: 0.85rem; color: #64ae24; text-decoration: none;">Passwort vergessen?</a>
</div>
</div>
</div>
</div>

View File

@@ -33,6 +33,11 @@ namespace FoodsharingSiegen.Server.Pages
/// </summary>
private string? Password { get; set; }
/// <summary>
/// Gets or sets the login error message
/// </summary>
private string? LoginErrorMessage { get; set; }
#endregion
#region Private Method PerformLogin
@@ -42,12 +47,14 @@ namespace FoodsharingSiegen.Server.Pages
/// </summary>
private async Task PerformLogin()
{
LoginErrorMessage = null;
//Todo Eingaben Validieren [04.04.22 - Andre Beging]
if (string.IsNullOrWhiteSpace(MailAddress) || string.IsNullOrWhiteSpace(Password))
{
MailAddress = string.Empty;
Password = string.Empty;
LoginErrorMessage = "E-Mail-Adresse oder Passwort ist ungültig.";
return;
}
@@ -55,7 +62,7 @@ namespace FoodsharingSiegen.Server.Pages
if (loginR.Success)
NavigationManager.NavigateTo("/", true);
else
await Notification.Error(loginR.ErrorMessage)!;
LoginErrorMessage = "E-Mail-Adresse oder Passwort ist ungültig.";
}
#endregion

View File

@@ -2,6 +2,7 @@
@page "/prospect"
@page "/prospects"
@using FoodsharingSiegen.Contracts
@using FoodsharingSiegen.Contracts.Enums
@using FoodsharingSiegen.Shared.Helper
@inherits FsBase
@@ -16,20 +17,27 @@
<Button
Color="Color.Primary"
Width="Width.Px(50)"
Height="Height.Px(50)"
title="Hinzufügen"
style="min-width: auto;"
Clicked="@CreateProspectAsync"
Visibility="@(CurrentUser.IsInGroup(UserGroup.WelcomeTeam, UserGroup.Ambassador) ? Visibility.Default : Visibility.Invisible)"
>Hinzufügen
><i class="fa-solid fa-plus"></i>
</Button>
<ProspectSortControl @bind-CurrentSort="CurrentSort" StorageKey="@StorageKeys.SortProspects" OnSortChanged="StateHasChanged" />
@{
var filterList = ProspectList.ApplyFilter(Filter);
var sortList = filterList.ApplySort(CurrentSort);
}
<hr/>
<ProspectFilterControl Filter="Filter" FilterChanged="FilterChangedAsync" StateFilter="ProspectStateFilter.OnBoarding"></ProspectFilterControl>
<hr />
<ProspectGrid
Prospects="filterList"
Prospects="sortList"
OnDataChanged="@LoadProspects"
StateFilter="ProspectStateFilter.OnBoarding">
</ProspectGrid>

View File

@@ -36,6 +36,8 @@ namespace FoodsharingSiegen.Server.Pages
/// </summary>
private List<Prospect>? ProspectList { get; set; }
private ProspectSortOption CurrentSort { get; set; } = ProspectSortOption.NameAscending;
#endregion
#region Override InitializeDataAsync

View File

@@ -1,6 +1,7 @@
@page "/all"
@page "/archive"
@using FoodsharingSiegen.Contracts
@using FoodsharingSiegen.Contracts.Enums
@using FoodsharingSiegen.Shared.Helper
@inherits FsBase
@@ -13,15 +14,18 @@
<div class="alert alert-danger"><strong>TESTMODUS!</strong> Änderungen werden wieder gelöscht.</div>
}
<ProspectSortControl @bind-CurrentSort="CurrentSort" StorageKey="@StorageKeys.SortProspectsAll" OnSortChanged="StateHasChanged" />
@{
var filterList = ProspectList.ApplyFilter(Filter);
var sortList = filterList.ApplySort(CurrentSort);
}
<hr/>
<ProspectFilterControl Filter="Filter" FilterChanged="FilterChangedAsync" StateFilter="ProspectStateFilter.All"></ProspectFilterControl>
<hr />
<ProspectGrid
Prospects="filterList"
Prospects="sortList"
OnDataChanged="@LoadProspects"
StateFilter="ProspectStateFilter.All">
</ProspectGrid>

View File

@@ -39,6 +39,8 @@ namespace FoodsharingSiegen.Server.Pages
/// </summary>
private List<Prospect>? ProspectList { get; set; }
private ProspectSortOption CurrentSort { get; set; } = ProspectSortOption.NameAscending;
#endregion
#region Override InitializeDataAsync

View File

@@ -1,5 +1,6 @@
@page "/done"
@using FoodsharingSiegen.Contracts
@using FoodsharingSiegen.Contracts.Enums
@using FoodsharingSiegen.Shared.Helper
@inherits FsBase
@@ -12,15 +13,18 @@
<div class="alert alert-danger"><strong>TESTMODUS!</strong> Änderungen werden wieder gelöscht.</div>
}
<ProspectSortControl @bind-CurrentSort="CurrentSort" StorageKey="@StorageKeys.SortProspectsDone" OnSortChanged="StateHasChanged" />
@{
var filterList = ProspectList.ApplyFilter(Filter);
var sortList = filterList.ApplySort(CurrentSort);
}
<hr />
<ProspectFilterControl Filter="Filter" FilterChanged="FilterChangedAsync" StateFilter="ProspectStateFilter.Completed"></ProspectFilterControl>
<hr />
<ProspectGrid
Prospects="filterList"
Prospects="sortList"
OnDataChanged="@LoadProspects"
StateFilter="ProspectStateFilter.Completed">
</ProspectGrid>

View File

@@ -31,6 +31,8 @@ namespace FoodsharingSiegen.Server.Pages
/// </summary>
private List<Prospect>? ProspectList { get; set; }
private ProspectSortOption CurrentSort { get; set; } = ProspectSortOption.NameAscending;
#endregion
#region Override InitializeDataAsync

View File

@@ -1,5 +1,6 @@
@page "/verify"
@using FoodsharingSiegen.Contracts
@using FoodsharingSiegen.Contracts.Enums
@using FoodsharingSiegen.Shared.Helper
@inherits FsBase
@@ -12,15 +13,18 @@
<div class="alert alert-danger"><strong>TESTMODUS!</strong> Änderungen werden wieder gelöscht.</div>
}
<ProspectSortControl @bind-CurrentSort="CurrentSort" StorageKey="@StorageKeys.SortProspectsVerify" OnSortChanged="StateHasChanged" />
@{
var filterList = ProspectList.ApplyFilter(Filter);
var sortList = filterList.ApplySort(CurrentSort);
}
<hr/>
<ProspectFilterControl Filter="Filter" FilterChanged="FilterChangedAsync" StateFilter="ProspectStateFilter.Verification"></ProspectFilterControl>
<hr />
<ProspectGrid
Prospects="filterList"
Prospects="sortList"
OnDataChanged="@LoadProspects"
StateFilter="ProspectStateFilter.Verification">
</ProspectGrid>

View File

@@ -37,6 +37,8 @@ namespace FoodsharingSiegen.Server.Pages
/// </summary>
private List<Prospect>? ProspectList { get; set; }
private ProspectSortOption CurrentSort { get; set; } = ProspectSortOption.NameAscending;
#endregion
#region Override InitializeDataAsync

View File

@@ -0,0 +1,82 @@
@page "/reset-password/{Token}"
@using FoodsharingSiegen.Shared.Helper
@layout LoginLayout
@inherits FoodsharingSiegen.Server.BaseClasses.FsBase
<PageTitle>@AppSettings.Terms.Title - Neues Passwort setzen</PageTitle>
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh; background-color: #f4f6f8;">
<div class="card shadow border-0" style="width: 100%; max-width: 420px; border-radius: 12px; margin: 1rem;">
<div class="card-body p-4 p-md-5">
<div class="text-center mb-4">
<i class="fa-solid fa-leaf mb-3" style="font-size: 3rem; color: #64ae24;"></i>
<h4 class="font-weight-bold" style="color: #533a20;"><small style="font-size: .6em;">Einarbeitungen</small> @AppSettings.Terms.Title</h4>
<p class="text-muted">Neues Passwort festlegen</p>
</div>
@if (IsInitializing)
{
<div class="text-center mt-4">
<i class="fas fa-spinner fa-spin fa-2x mb-3" style="color: #64ae24;"></i>
<p>Token wird überprüft...</p>
</div>
}
else if (!IsTokenValid)
{
<div class="alert alert-danger text-center">
<i class="fas fa-exclamation-triangle fa-2x mb-2 d-block"></i>
Der Link zum Zurücksetzen des Passworts ist ungültig oder abgelaufen. Bitte fordere einen neuen an.
</div>
<div class="text-center mt-4">
<a href="/forgot-password" class="btn btn-outline-primary w-100 mb-2"><i class="fas fa-redo mr-2"></i> Neuen Link anfordern</a>
<a href="/login" class="btn btn-link w-100" style="color: #64ae24;">Zurück zum Login</a>
</div>
}
else if (IsSuccess)
{
<div class="alert alert-success text-center">
Passwort erfolgreich aktualisiert. Du kannst dich jetzt anmelden.
</div>
<div class="text-center mt-4">
<a href="/login" class="btn btn-outline-primary"><i class="fas fa-sign-in-alt mr-2"></i> Zum Login</a>
</div>
}
else
{
<Validation Validator="ValidationHelper.ValidatePassword" @bind-Status="@IsValidPassword">
<Field>
<FieldLabel>Neues Passwort</FieldLabel>
<TextEdit @bind-Text="NewPassword" Role="TextRole.Password" Placeholder="Passwort (min. 8 Zeichen, min. 1 Zahl)" KeyUp="TextEdit_KeyUp" Size="Size.Large"></TextEdit>
</Field>
</Validation>
<Validation Validator="ValidatePasswordConfirmation" @bind-Status="@IsValidPasswordConfirmation">
<Field Class="mt-3">
<FieldLabel>Passwort bestätigen</FieldLabel>
<TextEdit @bind-Text="ConfirmPassword" Role="TextRole.Password" Placeholder="Passwort bestätigen" KeyUp="TextEdit_KeyUp" Size="Size.Large"></TextEdit>
</Field>
</Validation>
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="text-danger mt-3 text-center">
<i class="fas fa-exclamation-triangle mr-1"></i> @ErrorMessage
</div>
}
<Button Class="mt-4 w-100" Color="Color.Primary" Size="Size.Large" Clicked="SubmitReset" Disabled="@(IsValidPassword != ValidationStatus.Success || IsValidPasswordConfirmation != ValidationStatus.Success || IsLoading)">
@if (IsLoading)
{
<i class="fas fa-spinner fa-spin mr-2"></i> <span>Speichern...</span>
}
else
{
<i class="fas fa-save mr-2"></i> <span>Passwort speichern</span>
}
</Button>
}
</div>
</div>
</div>

View File

@@ -0,0 +1,77 @@
using Blazorise;
using FoodsharingSiegen.Server.BaseClasses;
using FoodsharingSiegen.Server.Auth;
using FoodsharingSiegen.Shared.Helper;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using System.Threading.Tasks;
namespace FoodsharingSiegen.Server.Pages
{
public partial class ResetPassword : FsBase
{
[Parameter]
public string Token { get; set; } = string.Empty;
public string NewPassword { get; set; } = string.Empty;
public string ConfirmPassword { get; set; } = string.Empty;
public ValidationStatus IsValidPassword { get; set; } = ValidationStatus.None;
public ValidationStatus IsValidPasswordConfirmation { get; set; } = ValidationStatus.None;
public string ErrorMessage { get; set; } = string.Empty;
public bool IsSuccess { get; set; }
public bool IsLoading { get; set; }
public bool IsInitializing { get; set; } = true;
public bool IsTokenValid { get; set; }
protected override async Task OnInitializedAsync()
{
IsTokenValid = await AuthService.IsResetTokenValid(Token);
IsInitializing = false;
}
public void ValidatePasswordConfirmation(ValidatorEventArgs args)
{
var confirmPassword = System.Convert.ToString(args.Value);
if (string.IsNullOrWhiteSpace(confirmPassword))
{
args.Status = ValidationStatus.None;
return;
}
args.Status = confirmPassword == NewPassword ? ValidationStatus.Success : ValidationStatus.Error;
}
public async Task SubmitReset()
{
if (IsValidPassword != ValidationStatus.Success || IsValidPasswordConfirmation != ValidationStatus.Success) return;
IsLoading = true;
ErrorMessage = string.Empty;
await InvokeAsync(StateHasChanged);
var result = await AuthService.ResetPassword(Token, NewPassword);
if (result.Success)
{
IsSuccess = true;
}
else
{
ErrorMessage = result.Exception?.Message ?? "Ein Fehler ist aufgetreten.";
}
IsLoading = false;
await InvokeAsync(StateHasChanged);
}
public async Task TextEdit_KeyUp(KeyboardEventArgs e)
{
if (e.Key == "Enter" && IsValidPassword == ValidationStatus.Success && IsValidPasswordConfirmation == ValidationStatus.Success)
{
await SubmitReset();
}
}
}
}

View File

@@ -0,0 +1,30 @@
@page "/settings"
@inherits FsBase
<PageTitle>Einstellungen - @AppSettings.Terms.Title</PageTitle>
<div style="width: 100%; max-width: 500px;">
<h2>Einstellungen</h2>
<div class="card mt-3">
<div class="card-header" style="padding: .5rem 0 0 1rem;"><h5>Mail Service</h5></div>
<div class="card-body">
<p>
<strong>SMTP Server:</strong> @AppSettings.Mail.Host<br />
<strong>Port:</strong> @AppSettings.Mail.Port<br />
<strong>SSL:</strong> @(AppSettings.Mail.UseSsl ? "Ja" : "Nein")<br />
</p>
<hr />
<Fields Class="my-3">
<Field ColumnSize="ColumnSize.Is12">
<FieldLabel>Testmail-Empfänger</FieldLabel>
<FieldBody>
<TextEdit @bind-Text="TestMailReceiver" Placeholder="E-Mail Adresse"></TextEdit>
</FieldBody>
</Field>
</Fields>
<Button Color="Color.Primary" Clicked="SendTestMail">Testmail senden</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,46 @@
using FoodsharingSiegen.Contracts.Helper;
using FoodsharingSiegen.Server.Service;
using Microsoft.AspNetCore.Components;
using System;
using System.Threading.Tasks;
namespace FoodsharingSiegen.Server.Pages
{
public partial class Settings
{
[Inject]
public IMailService MailService { get; set; } = null!;
private string TestMailReceiver { get; set; } = string.Empty;
protected override async Task InitializeDataAsync()
{
if (!CurrentUser.IsAdmin())
{
NavigationManager.NavigateTo("/");
return;
}
TestMailReceiver = CurrentUser.Mail ?? string.Empty;
await Task.CompletedTask;
}
private async Task SendTestMail()
{
if (string.IsNullOrWhiteSpace(TestMailReceiver))
{
await Notification.Error("Bitte eine Empfänger-Adresse angeben.");
return;
}
try
{
await MailService.SendEmailAsync(TestMailReceiver, "Testmail", "Dies ist eine Testmail.");
await Notification.Success("Testmail versendet.");
}
catch (Exception ex)
{
await Notification.Error($"Fehler beim Senden: {ex.Message}");
}
}
}
}

View File

@@ -29,6 +29,7 @@ builder.Services.AddScoped<AuditService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<ProspectService>();
builder.Services.AddScoped<IMailService, MailService>();
builder.Services
.AddBlazorise()

View File

@@ -12,6 +12,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "http://localhost:56000/",
"applicationUrl": "http://localhost:56000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"

View File

@@ -0,0 +1,19 @@
using System.Threading.Tasks;
namespace FoodsharingSiegen.Server.Service
{
/// <summary>
/// Service interface for sending emails.
/// </summary>
public interface IMailService
{
/// <summary>
/// Sends an email asynchronously.
/// </summary>
/// <param name="toEmail">The recipient's email address.</param>
/// <param name="subject">The subject of the email.</param>
/// <param name="htmlBody">The HTML content of the email body.</param>
/// <returns>A task that represents the asynchronous email sending operation.</returns>
Task SendEmailAsync(string toEmail, string subject, string htmlBody);
}
}

View File

@@ -0,0 +1,57 @@
using System.Threading.Tasks;
using FoodsharingSiegen.Contracts.Model;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
using MimeKit.Text;
namespace FoodsharingSiegen.Server.Service
{
/// <summary>
/// Default implementation of <see cref="IMailService"/> which sends emails via SMTP using MailKit.
/// </summary>
public class MailService : IMailService
{
private readonly MailSettings _mailSettings;
private readonly TermSettings _termSettings;
/// <summary>
/// Initializes a new instance of the <see cref="MailService"/> class.
/// </summary>
/// <param name="appSettings">The configured application settings injected by DI, containing the <see cref="MailSettings"/>.</param>
public MailService(IOptions<AppSettings> appSettings)
{
_mailSettings = appSettings.Value.Mail;
_termSettings = appSettings.Value.Terms;
}
/// <inheritdoc/>
public async Task SendEmailAsync(string toEmail, string subject, string htmlBody)
{
var email = new MimeMessage();
email.From.Add(new MailboxAddress(_termSettings.Title, _mailSettings.FromAddress));
email.To.Add(MailboxAddress.Parse(toEmail));
email.Subject = subject;
var textPart = new TextPart(TextFormat.Html)
{
Text = htmlBody
};
email.Body = textPart;
using var smtp = new SmtpClient();
var secureOptions = _mailSettings.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
await smtp.ConnectAsync(_mailSettings.Host, _mailSettings.Port, secureOptions);
if (!string.IsNullOrWhiteSpace(_mailSettings.Username))
{
await smtp.AuthenticateAsync(_mailSettings.Username, _mailSettings.Password);
}
await smtp.SendAsync(email);
await smtp.DisconnectAsync(true);
}
}
}

View File

@@ -49,13 +49,21 @@
@if (CurrentUser.IsAdmin())
{
<div class="nav-item px-3 pb-0">
<div class="nav-item px-3">
<div @onclick="NavLinkClickedAsync">
<NavLink class="nav-link" href="users" Match="NavLinkMatch.All">
<span class="fas fa-users mr-2" aria-hidden="true" style="font-size: 1.4em;"></span> Benutzer
</NavLink>
</div>
</div>
<div class="nav-item px-3 pb-0">
<div @onclick="NavLinkClickedAsync">
<NavLink class="nav-link" href="settings" Match="NavLinkMatch.All">
<span class="fas fa-cog mr-2" aria-hidden="true" style="font-size: 1.4em;"></span> Einstellungen
</NavLink>
</div>
</div>
}
<div class="nav-item px-3">

View File

@@ -1,4 +1,4 @@
{
{
"Kestrel": {
"Endpoints": {
"Http": {
@@ -9,10 +9,18 @@
"DetailedErrors": true,
"Settings": {
"TestMode": true,
"Mail": {
"Host": "mail.example.com",
"Port": 587,
"Username": "your_username",
"Password": "your_password",
"FromAddress": "no-reply@example.com",
"UseSsl": true
},
"Terms": {
"Title": "Foodsharing Musterhausen",
"TitleShort": "Musterhausen",
"StepInName": "Krabbelgruppe2"
"StepInName": "Neulingstreffen"
}
}
}

View File

@@ -1 +0,0 @@
20260410

View File

@@ -56,6 +56,20 @@ namespace FoodsharingSiegen.Shared.Helper
return filterListQ.ToList();
}
public static List<Prospect> ApplySort(this List<Prospect>? prospectList, ProspectSortOption sortOption)
{
if (prospectList == null) return [];
return sortOption switch
{
ProspectSortOption.NameAscending => prospectList.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase).ToList(),
ProspectSortOption.NameDescending => prospectList.OrderByDescending(x => x.Name, StringComparer.OrdinalIgnoreCase).ToList(),
ProspectSortOption.ModifiedAscending => prospectList.OrderBy(x => x.Modified ?? x.Created).ToList(),
ProspectSortOption.ModifiedDescending => prospectList.OrderByDescending(x => x.Modified ?? x.Created).ToList(),
_ => prospectList
};
}
#endregion
}
}

View File

@@ -46,7 +46,7 @@ namespace FoodsharingSiegen.Shared.Helper
return;
}
var isValid = password.Length > 3;
var isValid = password.Length >= 8 && password.Any(char.IsDigit);
args.Status = isValid ? ValidationStatus.Success : ValidationStatus.Error;
}