Compare commits

..

16 Commits

Author SHA1 Message Date
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
troogs
aea858a1ef Add GitHub Actions workflow for building and pushing Docker images
All checks were successful
Build And Push Docker Image / docker (push) Successful in 1m50s
2026-04-10 21:17:51 +02:00
troogs
85c90e4657 Add DOTNET_ROLL_FORWARD environment variable to build workflow
All checks were successful
Build And Release / release (push) Successful in 1m26s
2026-04-10 20:56:12 +02:00
troogs
840ecedbd0 Add GitHub Actions workflow for build and release process
Some checks failed
Build And Release / release (push) Failing after 1m8s
2026-04-10 20:52:03 +02:00
troogs
916c3142d4 Remove unused Blazorise script reference and add kill-port script for managing listening processes 2026-04-10 05:48:37 +02:00
troogs
f9426679ea Add tasks.json for managing FoodsharingSiegen.Server build and debug tasks 2026-04-10 05:48:29 +02:00
troogs
a68994d00b Add launch configuration for debugging FoodsharingSiegen.Server 2026-04-10 05:48:14 +02:00
troogs
fcda568905 Add app.db to .gitignore to prevent tracking of database file 2026-04-10 05:48:06 +02:00
Andre Beging
ac178e60e0 Update term for ReleasedForVerification interaction type
Changed the term "Freigabe zum Freischalten" to "Freigabe Freischalten" to ensure consistency with naming conventions. This adjustment clarifies language usage and aligns with the application's terminology standards.
2025-04-02 09:26:07 +02:00
37 changed files with 707 additions and 13 deletions

View File

@@ -0,0 +1,56 @@
name: Build And Push Docker Image
on:
push:
tags:
- "image"
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: Determine next timestamp tag
shell: bash
run: |
TODAY="$(date -u +%Y%m%d)"
i=1
while docker manifest inspect "${BASE_IMAGE}:${TODAY}-${i}" > /dev/null 2>&1; do
i=$((i + 1))
done
DATE_TAG="${TODAY}-${i}"
echo "DATE_TAG=${DATE_TAG}" >> "$GITHUB_ENV"
echo "Using image tag: ${DATE_TAG}"
- name: Build docker image
run: |
docker build \
-f ./Docker/dockerfile.server \
-t "${{ env.BASE_IMAGE }}:latest" \
-t "${{ env.BASE_IMAGE }}:${{ env.DATE_TAG }}" \
.
- name: Push docker images
run: |
docker push "${{ env.BASE_IMAGE }}:latest"
docker push "${{ env.BASE_IMAGE }}:${{ env.DATE_TAG }}"

50
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,50 @@
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 }}

3
.gitignore vendored
View File

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

19
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,19 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug FoodsharingSiegen.Server",
"type": "dotnet",
"request": "launch",
"projectPath": "${workspaceFolder}/FoodsharingSiegen.Server/FoodsharingSiegen.Server.csproj",
"env": {
"DOTNET_ROLL_FORWARD": "Major"
},
"launchBrowser": true,
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
}
}
]
}

53
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,53 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Watch FoodsharingSiegen.Server",
"type": "shell",
"command": "dotnet watch --project ./FoodsharingSiegen.Server/FoodsharingSiegen.Server.csproj run",
"isBackground": true,
"problemMatcher": "$msCompile",
"options": {
"statusbar": {
"hide": false,
"label": "Watch Server",
"color": "#3a96ff",
"icon": {
"id": "eye"
},
"running": {
"color": "#f7df06",
"icon": {
"id": "loading~spin"
}
}
}
}
},
{
"label": "Debug FoodsharingSiegen.Server",
"type": "shell",
"command": "dotnet run --project ./FoodsharingSiegen.Server/FoodsharingSiegen.Server.csproj",
"problemMatcher": "$msCompile",
"options": {
"env": {
"DOTNET_ROLL_FORWARD": "Major"
},
"statusbar": {
"hide": false,
"label": "Debug Server",
"color": "#3a96ff",
"icon": {
"id": "debug-start"
},
"running": {
"color": "#f7df06",
"icon": {
"id": "loading~spin"
}
}
}
}
}
]
}

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 image: ghcr.io/troogs/fs-onboarding/server:latest
ports: ports:
- "8100:56000" - "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: volumes:
- /docker/data/fs-onboarding/config:/app/config/ - /docker/data/fs-onboarding/config:/app/config/
- /docker/data/fs-onboarding/data:/app/data/ - /docker/data/fs-onboarding/data:/app/data/

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; } 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 TermSettings Terms { get; set; } = new();
public bool TestMode { get; set; } 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> /// </summary>
public const string ProspectFilter = "ProspectFilter"; 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> /// <summary>
/// The token key /// The token key
/// </summary> /// </summary>

View File

@@ -12,8 +12,23 @@
<div class="@divClass"> <div class="@divClass">
<h5 class="mb-2 d-flex"> <h5 class="mb-2 d-flex">
<div class="flex-grow-1"> <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 @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>
<div> <div>
@if (CurrentUser.IsInGroup(UserGroup.WelcomeTeam, UserGroup.Ambassador)) @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> <ItemGroup>
<PackageReference Include="LibSassBuilder" Version="2.0.1" /> <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" Version="9.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.3">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,45 @@
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;
}
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

@@ -43,7 +43,6 @@
</div> </div>
<script src="_framework/blazor.server.js"></script> <script src="_framework/blazor.server.js"></script>
<script src="_content/Blazorise/blazorise.js"></script>
<script src="_content/Blazorise.Material/blazorise.material.js?v=1.7.5.0"></script> <script src="_content/Blazorise.Material/blazorise.material.js?v=1.7.5.0"></script>
</body> </body>
</html> </html>

View File

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

View File

@@ -12,6 +12,7 @@
"commandName": "Project", "commandName": "Project",
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"launchUrl": "http://localhost:56000/",
"applicationUrl": "http://localhost:56000", "applicationUrl": "http://localhost:56000",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "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()) @if (CurrentUser.IsAdmin())
{ {
<div class="nav-item px-3 pb-0"> <div class="nav-item px-3">
<div @onclick="NavLinkClickedAsync"> <div @onclick="NavLinkClickedAsync">
<NavLink class="nav-link" href="users" Match="NavLinkMatch.All"> <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 <span class="fas fa-users mr-2" aria-hidden="true" style="font-size: 1.4em;"></span> Benutzer
</NavLink> </NavLink>
</div> </div>
</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"> <div class="nav-item px-3">

View File

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

View File

@@ -56,6 +56,20 @@ namespace FoodsharingSiegen.Shared.Helper
return filterListQ.ToList(); 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 #endregion
} }
} }

View File

@@ -28,7 +28,7 @@ namespace FoodsharingSiegen.Shared.Helper
InteractionType.Verify => "Verifizierung", InteractionType.Verify => "Verifizierung",
InteractionType.Complete => "Fertig", InteractionType.Complete => "Fertig",
InteractionType.StepInBriefing => appSettings.Terms.StepInName ?? "StepIn", InteractionType.StepInBriefing => appSettings.Terms.StepInName ?? "StepIn",
InteractionType.ReleasedForVerification => "Freigabe zum Freischalten", InteractionType.ReleasedForVerification => "Freigabe Freischalten",
_ => type.ToString() _ => type.ToString()
}; };
} }

View File

@@ -0,0 +1,53 @@
param(
[int]$Port = 56000
)
$killedAny = $false
# Try modern cmdlet first.
$connections = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue
if ($connections) {
$pids = $connections | Select-Object -ExpandProperty OwningProcess -Unique
foreach ($procId in $pids) {
try {
Stop-Process -Id $procId -Force -ErrorAction Stop
Write-Host "Stopped process $procId listening on port $Port."
$killedAny = $true
}
catch {
Write-Warning "Failed to stop process ${procId}: $($_.Exception.Message)"
}
}
}
# Fallback for environments where Get-NetTCPConnection is unavailable.
if (-not $killedAny) {
$netstatLines = netstat -ano | Select-String ":$Port\s"
$listenLines = $netstatLines | Where-Object { $_.Line -match "LISTENING" }
$fallbackPids = @()
foreach ($line in $listenLines) {
$parts = ($line.Line -replace "\s+", " ").Trim().Split(" ")
if ($parts.Length -ge 5) {
$fallbackPids += $parts[-1]
}
}
$fallbackPids = $fallbackPids | Sort-Object -Unique
foreach ($pidText in $fallbackPids) {
if ($pidText -match "^\d+$") {
try {
Stop-Process -Id ([int]$pidText) -Force -ErrorAction Stop
Write-Host "Stopped process $pidText listening on port $Port."
$killedAny = $true
}
catch {
Write-Warning "Failed to stop process ${pidText}: $($_.Exception.Message)"
}
}
}
}
if (-not $killedAny) {
Write-Host "No listening process found on port $Port."
}