WASM Mode activated

This commit is contained in:
MarcWieland
2026-06-08 16:24:51 +02:00
parent fe294e288a
commit 58e562adb1
118 changed files with 1038 additions and 470 deletions
-31
View File
@@ -1,31 +0,0 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>
-157
View File
@@ -1,157 +0,0 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}
-63
View File
@@ -1,63 +0,0 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
-19
View File
@@ -1,19 +0,0 @@
namespace timetracker.Data;
public class UserNotificationService
{
public event Func<Task>? OnUsersChanged;
public event Func<int, Task>? OnUserDeleted;
public async Task NotifyUsersChangedAsync()
{
if (OnUsersChanged != null)
await OnUsersChanged.Invoke();
}
public async Task NotifyUserDeletedAsync(int userId)
{
if (OnUserDeleted != null)
await OnUserDeleted.Invoke(userId);
}
}
-132
View File
@@ -1,132 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using timetracker.Components;
using timetracker.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/auth/logout";
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireClaim(System.Security.Claims.ClaimTypes.Name, "marc"));
});
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<timetracker.Data.UserNotificationService>();
builder.Services.AddScoped<AuthService>();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddMudServices();
builder.Services.AddHttpClient<HolidayService>();
var dbPath = Environment.GetEnvironmentVariable("TIMETRACKER_DB_PATH")
?? Path.Combine(builder.Environment.ContentRootPath, "timetracker.db");
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}"));
builder.Services.AddScoped<TimetrackerService>();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>();
await using var db = await factory.CreateDbContextAsync();
await db.Database.MigrateAsync();
}
var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
};
forwardedHeadersOptions.KnownProxies.Clear();
forwardedHeadersOptions.KnownIPNetworks.Clear();
app.UseForwardedHeaders(forwardedHeadersOptions);
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
if (app.Configuration.GetValue("EnableHttpsRedirect", !app.Environment.IsDevelopment()))
{
app.UseHttpsRedirection();
}
// Statische Dateien (inkl. _framework/, _content/) vor Auth bedienen,
// damit Blazor-JS und MudBlazor-CSS nie durch Auth-Middleware geblockt werden
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
// ── Auth-Endpoints ────────────────────────────────────────────────────────────
app.MapPost("/auth/login", async (HttpContext ctx, AuthService authService) =>
{
var form = await ctx.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
var user = await authService.LoginAsync(username, password);
if (user == null)
return Results.Redirect("/login?error=invalid");
var claims = new[] {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true });
return Results.Redirect("/");
}).DisableAntiforgery();
app.MapPost("/auth/register", async (HttpContext ctx, AuthService authService) =>
{
var form = await ctx.Request.ReadFormAsync();
var username = form["username"].ToString();
var password = form["password"].ToString();
var (user, error) = await authService.RegisterAsync(username, password);
if (user == null)
return Results.Redirect($"/login?tab=register&error={Uri.EscapeDataString(error!)}");
var claims = new[] {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true });
return Results.Redirect("/");
}).DisableAntiforgery();
app.MapGet("/auth/logout", async (HttpContext ctx) =>
{
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Redirect("/login");
});
app.Run();
+21 -7
View File
@@ -1,22 +1,36 @@
version: '3.8'
services:
db:
image: postgres:16-alpine
container_name: timetracker-db
restart: always
environment:
POSTGRES_USER: timetracker_user
POSTGRES_PASSWORD: SecretPassword123
POSTGRES_DB: timetracker
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
timetracker:
build:
context: .
dockerfile: Dockerfile
dockerfile: timetracker.Server/Dockerfile
container_name: timetracker-app
restart: always
ports:
- "8090:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_HTTP_PORTS=8080 # Lassen wir so, da intern völlig okay
- TIMETRACKER_DB_PATH=/data/timetracker.db
- ASPNETCORE_HTTP_PORTS=8080
- DB_PROVIDER=PostgreSQL
- ConnectionStrings__DefaultConnection=Host=db;Database=timetracker;Username=timetracker_user;Password=SecretPassword123;
- EnableHttpsRedirect=false
volumes:
- timetracker_data:/data
depends_on:
- db
volumes:
timetracker_data:
name: timetracker_prod_data
pgdata:
name: timetracker_pgdata
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# Exit on error
set -e
echo "=== 1. Bereinige alte Build-Dateien ==="
dotnet clean
echo "=== 2. Baue die gesamte Solution ==="
dotnet build
echo "=== 3. Starte den Server & öffne Browser ==="
# Browser parallel nach kurzem Delay öffnen
(sleep 3 && open http://localhost:5065) &
dotnet run --project timetracker.Server/timetracker.Server.csproj
@@ -1,7 +1,7 @@
@inherits LayoutComponentBase
@inherits LayoutComponentBase
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Nav
@inject timetracker.Data.UserNotificationService UserNotificationService
@inject IUserNotificationService UserNotificationService
@implements IDisposable
@using System.Security.Claims
@@ -29,7 +29,7 @@
<MudNavMenu>
<MudNavLink Href="/changelog" Icon="@Icons.Material.Filled.NewReleases"
Style="color: var(--mud-palette-text-disabled); font-size:0.75rem;">
Version 1.1
Version 1.2
</MudNavLink>
</MudNavMenu>
</MudTooltip>
@@ -1,10 +1,10 @@
@page "/admin/users"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@attribute [Authorize(Policy = "AdminOnly")]
@inject AuthService AuthService
@inject IAuthService AuthService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@inject timetracker.Data.UserNotificationService UserNotificationService
@inject IUserNotificationService UserNotificationService
@implements IDisposable
<PageTitle>Benutzerverwaltung Timetracker</PageTitle>
@@ -1,5 +1,5 @@
@page "/changelog"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@attribute [Authorize]
<PageTitle>Changelog Timetracker</PageTitle>
@@ -68,7 +68,15 @@
private readonly List<Release> _releases =
[
new("1.1", "08.06.2026", true,
new("1.2", "08.06.2026", true,
[
new("Upgrade", "Architektur-Migration auf Hosted Blazor WebAssembly (.NET 10) mit sauberer Projektstruktur (Client, Server, Shared)"),
new("Neu", "Unterstützung für PostgreSQL-Datenbanken als produktive und skalierbare Alternative zu SQLite"),
new("Neu", "Dynamische DB-Provider-Weiche (SQLite vs. PostgreSQL) über Konfigurations- und Umgebungsvariablen"),
new("Neu", "Docker-Compose-Konfiguration inklusive PostgreSQL-Container für vereinfachten Deployment-Betrieb"),
new("Neu", "Lokales Ausführungsskript (run-local.sh) für einfaches Testen auf dem Entwicklungsrechner"),
]),
new("1.1", "08.06.2026", false,
[
new("Neu", "Versionsnummer in der Navbar mit Link zum Changelog"),
new("Neu", "Changelog-Seite"),
@@ -1,8 +1,8 @@
@page "/feiertage"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject HolidayService HolidayService
@inject TimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject ITimetrackerService TrackerService
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>Feiertage Timetracker</PageTitle>
@@ -1,8 +1,8 @@
@page "/"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@@ -1,5 +1,7 @@
@page "/login"
@rendermode InteractiveWebAssembly
@attribute [AllowAnonymous]
@inject IAuthService AuthService
@inject NavigationManager Nav
<PageTitle>Anmelden Timetracker</PageTitle>
@@ -15,15 +17,15 @@
</MudStack>
<MudPaper Elevation="4" Class="pa-6 rounded-xl" Style="width:100%">
@* ── Static Tab Navigation ── *@
@* ── Tab Navigation ── *@
<MudStack Row="true" Justify="Justify.Center" Class="mb-4">
<MudButton Href="/login"
<MudButton OnClick="@(() => SetTab(0))"
Variant="@(_activeTab == 0 ? Variant.Filled : Variant.Text)"
Color="Color.Primary"
Style="min-width: 120px; border-radius: 20px;">
Anmelden
</MudButton>
<MudButton Href="/login?tab=register"
<MudButton OnClick="@(() => SetTab(1))"
Variant="@(_activeTab == 1 ? Variant.Filled : Variant.Text)"
Color="Color.Primary"
Style="min-width: 120px; border-radius: 20px;">
@@ -41,14 +43,14 @@
{
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
}
<form action="/auth/login" method="post">
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin">
<MudStack Spacing="3">
<MudTextField T="string"
Label="Benutzername"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Person"
name="username"
@bind-Value="_loginModel.Username"
Required="true"
AutoFocus="true" />
<MudTextField T="string"
@@ -57,7 +59,7 @@
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lock"
InputType="InputType.Password"
name="password"
@bind-Value="_loginModel.Password"
Required="true" />
<MudButton ButtonType="ButtonType.Submit"
Variant="Variant.Filled"
@@ -65,11 +67,16 @@
FullWidth="true"
Size="Size.Large"
StartIcon="@Icons.Material.Filled.Login"
Class="mt-2">
Class="mt-2"
Disabled="_loading">
@if (_loading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Anmelden
</MudButton>
</MudStack>
</form>
</EditForm>
</MudStack>
}
else
@@ -80,14 +87,14 @@
{
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
}
<form action="/auth/register" method="post">
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister">
<MudStack Spacing="3">
<MudTextField T="string"
Label="Benutzername"
Variant="Variant.Outlined"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Person"
name="username"
@bind-Value="_registerModel.Username"
Required="true"
HelperText="Mindestens 3 Zeichen" />
<MudTextField T="string"
@@ -96,7 +103,7 @@
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Lock"
InputType="InputType.Password"
name="password"
@bind-Value="_registerModel.Password"
Required="true"
HelperText="Mindestens 6 Zeichen" />
<MudButton ButtonType="ButtonType.Submit"
@@ -105,11 +112,16 @@
FullWidth="true"
Size="Size.Large"
StartIcon="@Icons.Material.Filled.PersonAdd"
Class="mt-2">
Class="mt-2"
Disabled="_loading">
@if (_loading)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
}
Konto erstellen
</MudButton>
</MudStack>
</form>
</EditForm>
</MudStack>
}
</MudPaper>
@@ -119,6 +131,10 @@
@code {
private int _activeTab = 0;
private string? _error;
private bool _loading;
private readonly AuthModel _loginModel = new();
private readonly AuthModel _registerModel = new();
[SupplyParameterFromQuery(Name = "error")]
public string? ErrorParam { get; set; }
@@ -136,5 +152,68 @@
};
_activeTab = TabParam == "register" ? 1 : 0;
}
}
private void SetTab(int tab)
{
_activeTab = tab;
_error = null;
}
private async Task HandleLogin()
{
_loading = true;
_error = null;
try
{
var user = await AuthService.LoginAsync(_loginModel.Username, _loginModel.Password);
if (user != null)
{
Nav.NavigateTo("/", forceLoad: true); // forceLoad forces state update/re-render of the root app
}
else
{
_error = "Benutzername oder Passwort falsch.";
}
}
catch (Exception ex)
{
_error = $"Login Fehler: {ex.Message}";
}
finally
{
_loading = false;
}
}
private async Task HandleRegister()
{
_loading = true;
_error = null;
try
{
var (user, error) = await AuthService.RegisterAsync(_registerModel.Username, _registerModel.Password);
if (user != null)
{
Nav.NavigateTo("/", forceLoad: true);
}
else
{
_error = error ?? "Registrierung fehlgeschlagen.";
}
}
catch (Exception ex)
{
_error = $"Registrierungs Fehler: {ex.Message}";
}
finally
{
_loading = false;
}
}
private class AuthModel
{
public string Username { get; set; } = "";
public string Password { get; set; } = "";
}
}
@@ -1,8 +1,8 @@
@page "/month"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year Monatsübersicht Timetracker</PageTitle>
@@ -1,8 +1,8 @@
@page "/settings"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@@ -1,8 +1,8 @@
@page "/urlaub-maximizer"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@attribute [Authorize]
@inject TimetrackerService TrackerService
@inject HolidayService HolidayService
@inject ITimetrackerService TrackerService
@inject IHolidayService HolidayService
@inject ISnackbar Snackbar
@inject AuthenticationStateProvider AuthStateProvider
@@ -1,10 +1,10 @@
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Router AppAssembly="typeof(timetracker.Client._Imports).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(timetracker.Client.Components.Layout.MainLayout)">
<NotAuthorized>
<RedirectToLogin />
<timetracker.Client.Components.RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
@@ -0,0 +1,17 @@
@using System.Net.Http
@using System.Net.Http.Json
@using System.Security.Claims
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using timetracker.Client
@using timetracker.Client.Components
@using timetracker.Client.Components.Layout
@using timetracker.Client.Services
@using timetracker.Shared
@using MudBlazor
+27
View File
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services;
using timetracker.Client.Services;
using timetracker.Shared;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddMudServices();
builder.Services.AddAuthorizationCore(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireClaim(System.Security.Claims.ClaimTypes.Name, "marc"));
});
builder.Services.AddScoped<HostAuthenticationStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<HostAuthenticationStateProvider>());
builder.Services.AddScoped<IAuthService, ClientAuthService>();
builder.Services.AddScoped<ITimetrackerService, ClientTimetrackerService>();
builder.Services.AddScoped<IHolidayService, ClientHolidayService>();
builder.Services.AddScoped<IUserNotificationService, UserNotificationService>();
await builder.Build().RunAsync();
@@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5016",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7270;http://localhost:5016",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,74 @@
using System.Net.Http.Json;
using Microsoft.AspNetCore.Components.Authorization;
using timetracker.Shared;
namespace timetracker.Client.Services;
public class ClientAuthService : IAuthService
{
private readonly HttpClient _http;
private readonly AuthenticationStateProvider _authStateProvider;
public ClientAuthService(HttpClient http, AuthenticationStateProvider authStateProvider)
{
_http = http;
_authStateProvider = authStateProvider;
}
private HostAuthenticationStateProvider HostProvider => (HostAuthenticationStateProvider)_authStateProvider;
public async Task<User?> LoginAsync(string username, string password)
{
var response = await _http.PostAsJsonAsync("api/auth/login", new { Username = username, Password = password });
if (response.IsSuccessStatusCode)
{
var userInfo = await response.Content.ReadFromJsonAsync<UserInfo>();
if (userInfo != null)
{
HostProvider.NotifyUserChanged(userInfo);
return new User { Id = userInfo.Id, Username = userInfo.Username };
}
}
return null;
}
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password)
{
var response = await _http.PostAsJsonAsync("api/auth/register", new { Username = username, Password = password });
if (response.IsSuccessStatusCode)
{
var userInfo = await response.Content.ReadFromJsonAsync<UserInfo>();
if (userInfo != null)
{
HostProvider.NotifyUserChanged(userInfo);
return (new User { Id = userInfo.Id, Username = userInfo.Username }, null);
}
}
else
{
var error = await response.Content.ReadAsStringAsync();
return (null, error);
}
return (null, "Registrierung fehlgeschlagen.");
}
public async Task<List<User>> GetAllUsersAsync()
{
return await _http.GetFromJsonAsync<List<User>>("api/users") ?? [];
}
public async Task DeleteUserAsync(int userId)
{
await _http.DeleteAsync($"api/users/{userId}");
}
public async Task<string?> RenameUserAsync(int userId, string newUsername)
{
var response = await _http.PutAsJsonAsync($"api/users/{userId}/rename", new { Username = newUsername });
if (!response.IsSuccessStatusCode)
{
return await response.Content.ReadAsStringAsync();
}
return null;
}
}
@@ -0,0 +1,49 @@
using System.Net.Http.Json;
using timetracker.Shared;
namespace timetracker.Client.Services;
public class ClientHolidayService : IHolidayService
{
private readonly HttpClient _http;
public ClientHolidayService(HttpClient http)
{
_http = http;
}
public async Task<List<PublicHoliday>> GetHolidaysAsync(int year, string? stateCode = null)
{
var url = $"api/holidays?year={year}";
if (!string.IsNullOrEmpty(stateCode))
{
url += $"&stateCode={stateCode}";
}
return await _http.GetFromJsonAsync<List<PublicHoliday>>(url) ?? [];
}
public async Task<(bool Success, string Message)> FetchAndStoreAsync(int year)
{
var response = await _http.PostAsync($"api/holidays/fetch/{year}", null);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<FetchResponse>();
if (result != null)
{
return (result.Success, result.Message);
}
}
return (false, "Fehler beim Abrufen der Feiertage.");
}
public async Task DeleteAsync(int id)
{
await _http.DeleteAsync($"api/holidays/{id}");
}
private class FetchResponse
{
public bool Success { get; set; }
public string Message { get; set; } = "";
}
}
@@ -0,0 +1,65 @@
using System.Net.Http.Json;
using timetracker.Shared;
namespace timetracker.Client.Services;
public class ClientTimetrackerService : ITimetrackerService
{
private readonly HttpClient _http;
public ClientTimetrackerService(HttpClient http)
{
_http = http;
}
public async Task<List<WorkDay>> GetWeekAsync(int userId, DateOnly monday)
{
return await _http.GetFromJsonAsync<List<WorkDay>>($"api/tracker/week?userId={userId}&monday={monday:yyyy-MM-dd}") ?? [];
}
public async Task UpsertWorkDayAsync(WorkDay workDay)
{
await _http.PostAsJsonAsync("api/tracker/workday", workDay);
}
public async Task<AppSettings> GetSettingsAsync(int userId)
{
return await _http.GetFromJsonAsync<AppSettings>($"api/tracker/settings/{userId}") ?? new AppSettings { UserId = userId };
}
public async Task SaveSettingsAsync(AppSettings settings)
{
await _http.PostAsJsonAsync("api/tracker/settings", settings);
}
public async Task<List<VacationDay>> GetVacationDaysAsync(int userId, int year)
{
return await _http.GetFromJsonAsync<List<VacationDay>>($"api/tracker/vacation/{userId}/{year}") ?? [];
}
public async Task AddVacationDayAsync(VacationDay vacationDay)
{
await _http.PostAsJsonAsync("api/tracker/vacation", vacationDay);
}
public async Task RemoveVacationDayAsync(int userId, int id)
{
await _http.DeleteAsync($"api/tracker/vacation/{userId}/{id}");
}
public async Task<TimeSpan> GetTotalOvertimeAsync(int userId, AppSettings settings)
{
var response = await _http.PostAsJsonAsync($"api/tracker/overtime/{userId}", settings);
if (response.IsSuccessStatusCode)
{
var hours = await response.Content.ReadFromJsonAsync<double>();
return TimeSpan.FromHours(hours);
}
return TimeSpan.Zero;
}
public async Task<List<WorkDay>> GetMonthAsync(int userId, int year, int month)
{
return await _http.GetFromJsonAsync<List<WorkDay>>($"api/tracker/month?userId={userId}&year={year}&month={month}") ?? [];
}
}
@@ -0,0 +1,73 @@
using System.Security.Claims;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Components.Authorization;
using timetracker.Shared;
namespace timetracker.Client.Services;
public class HostAuthenticationStateProvider : AuthenticationStateProvider
{
private readonly HttpClient _http;
private static readonly ClaimsPrincipal Anonymous = new(new ClaimsIdentity());
private ClaimsPrincipal? _currentUser;
public HostAuthenticationStateProvider(HttpClient http)
{
_http = http;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
if (_currentUser != null)
{
return new AuthenticationState(_currentUser);
}
try
{
var response = await _http.GetAsync("api/auth/me");
if (response.IsSuccessStatusCode)
{
var userInfo = await response.Content.ReadFromJsonAsync<UserInfo>();
if (userInfo != null)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()),
new Claim(ClaimTypes.Name, userInfo.Username)
};
var identity = new ClaimsIdentity(claims, "Cookie");
_currentUser = new ClaimsPrincipal(identity);
return new AuthenticationState(_currentUser);
}
}
}
catch
{
// Ignore error and fall back to anonymous (e.g. server offline or network issues)
}
_currentUser = Anonymous;
return new AuthenticationState(_currentUser);
}
public void NotifyUserChanged(UserInfo? userInfo)
{
if (userInfo != null)
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()),
new Claim(ClaimTypes.Name, userInfo.Username)
};
var identity = new ClaimsIdentity(claims, "Cookie");
_currentUser = new ClaimsPrincipal(identity);
}
else
{
_currentUser = Anonymous;
}
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_currentUser)));
}
}
@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using timetracker.Shared;
namespace timetracker.Client.Services;
public class UserNotificationService : IUserNotificationService, IAsyncDisposable
{
private readonly HubConnection _hubConnection;
public event Func<Task>? OnUsersChanged;
public event Func<int, Task>? OnUserDeleted;
public UserNotificationService(NavigationManager navigationManager)
{
_hubConnection = new HubConnectionBuilder()
.WithUrl(navigationManager.ToAbsoluteUri("/hubs/notifications"))
.WithAutomaticReconnect()
.Build();
_hubConnection.On("UsersChanged", async () =>
{
if (OnUsersChanged != null)
await OnUsersChanged.Invoke();
});
_hubConnection.On<int>("UserDeleted", async (userId) =>
{
if (OnUserDeleted != null)
await OnUserDeleted.Invoke(userId);
});
// Start connection asynchronously
_ = StartHubConnectionAsync();
}
private async Task StartHubConnectionAsync()
{
try
{
await _hubConnection.StartAsync();
}
catch (Exception ex)
{
Console.WriteLine($"SignalR Connection Error: {ex.Message}");
}
}
public async ValueTask DisposeAsync()
{
if (_hubConnection != null)
{
await _hubConnection.DisposeAsync();
}
}
}
+13
View File
@@ -0,0 +1,13 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using timetracker.Client
@using timetracker.Client.Components
@using timetracker.Client.Components.Layout
@using timetracker.Shared
@using MudBlazor
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.5" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.8" />
<PackageReference Include="MudBlazor" Version="9.4.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\timetracker.Shared\timetracker.Shared.csproj" />
</ItemGroup>
</Project>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
@@ -18,7 +18,6 @@
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
</body>
@@ -1,4 +1,4 @@
@using System.Net.Http
@using System.Net.Http
@using System.Net.Http.Json
@using System.Security.Claims
@using Microsoft.AspNetCore.Authorization
@@ -10,7 +10,8 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using timetracker
@using timetracker.Components
@using timetracker.Components.Layout
@using timetracker.Client.Components
@using timetracker.Client.Components.Layout
@using timetracker.Data
@using timetracker.Shared
@using MudBlazor
@@ -1,10 +1,11 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using timetracker.Shared;
namespace timetracker.Data;
public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNotificationService notifier)
public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNotificationService notifier) : IAuthService
{
public async Task<User?> LoginAsync(string username, string password)
{
@@ -1,10 +1,11 @@
using Microsoft.EntityFrameworkCore;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using timetracker.Shared;
namespace timetracker.Data;
public class HolidayService(IDbContextFactory<TimetrackerDbContext> factory, HttpClient http)
public class HolidayService(IDbContextFactory<TimetrackerDbContext> factory, HttpClient http) : IHolidayService
{
private const string ApiUrl = "https://date.nager.at/api/v3/PublicHolidays/{0}/DE";
@@ -0,0 +1,7 @@
using Microsoft.AspNetCore.SignalR;
namespace timetracker.Data;
public class NotificationHub : Hub
{
}
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using timetracker.Shared;
namespace timetracker.Data;
@@ -1,8 +1,9 @@
using Microsoft.EntityFrameworkCore;
using timetracker.Shared;
namespace timetracker.Data;
public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory)
public class TimetrackerService(IDbContextFactory<TimetrackerDbContext> factory) : ITimetrackerService
{
public async Task<List<WorkDay>> GetWeekAsync(int userId, DateOnly monday)
{
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.SignalR;
using timetracker.Shared;
namespace timetracker.Data;
public class UserNotificationService : IUserNotificationService
{
private readonly IHubContext<NotificationHub> _hubContext;
public event Func<Task>? OnUsersChanged;
public event Func<int, Task>? OnUserDeleted;
public UserNotificationService(IHubContext<NotificationHub> hubContext)
{
_hubContext = hubContext;
}
public async Task NotifyUsersChangedAsync()
{
// Broadcast via SignalR to all clients
await _hubContext.Clients.All.SendAsync("UsersChanged");
// Also trigger locally if there are server-side subscribers
if (OnUsersChanged != null)
await OnUsersChanged.Invoke();
}
public async Task NotifyUserDeletedAsync(int userId)
{
// Broadcast via SignalR to all clients
await _hubContext.Clients.All.SendAsync("UserDeleted", userId);
// Also trigger locally if there are server-side subscribers
if (OnUserDeleted != null)
await OnUserDeleted.Invoke(userId);
}
}
+17 -6
View File
@@ -2,11 +2,22 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Keine Unterordner mehr beim Kopieren!
COPY timetracker.csproj ./
RUN dotnet restore timetracker.csproj
# Copy project files for restoring dependencies
COPY timetracker.sln ./
COPY timetracker.Server/timetracker.Server.csproj timetracker.Server/
COPY timetracker.Client/timetracker.Client.csproj timetracker.Client/
COPY timetracker.Shared/timetracker.Shared.csproj timetracker.Shared/
COPY . ./
# Restore dependencies
RUN dotnet restore timetracker.sln
# Copy the rest of the source code
COPY timetracker.Server/ timetracker.Server/
COPY timetracker.Client/ timetracker.Client/
COPY timetracker.Shared/ timetracker.Shared/
# Publish
WORKDIR /src/timetracker.Server
RUN dotnet publish -c Release -o /app/publish
# ── Runtime Stage ─────────────────────────────────────────────────────────────
@@ -14,7 +25,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/publish .
# Verzeichnis für die SQLite-Datenbank
# Directory for SQLite database
RUN mkdir -p /data
ENV ASPNETCORE_HTTP_PORTS=8080
@@ -27,4 +38,4 @@ EXPOSE 8080
VOLUME ["/data"]
ENTRYPOINT ["dotnet", "timetracker.dll"]
ENTRYPOINT ["dotnet", "timetracker.Server.dll"]
+305
View File
@@ -0,0 +1,305 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using timetracker.Server.Components;
using timetracker.Data;
using timetracker.Shared;
var builder = WebApplication.CreateBuilder(args);
// Add Authentication
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/auth/logout";
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
// Cookie-Konfiguration für APIs & WASM
options.Events.OnRedirectToLogin = context =>
{
if (context.Request.Path.StartsWithSegments("/api"))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
}
else
{
context.Response.Redirect(context.RedirectUri);
}
return Task.CompletedTask;
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("AdminOnly", policy =>
policy.RequireClaim(ClaimTypes.Name, "marc"));
});
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();
// Add SignalR
builder.Services.AddSignalR();
builder.Services.AddSingleton<UserNotificationService>();
builder.Services.AddSingleton<IUserNotificationService>(sp => sp.GetRequiredService<UserNotificationService>());
// Register DB-backed services as the interfaces
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ITimetrackerService, TimetrackerService>();
builder.Services.AddScoped<IHolidayService, HolidayService>();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveWebAssemblyComponents();
builder.Services.AddMudServices();
builder.Services.AddHttpClient<HolidayService>();
var dbProvider = builder.Configuration["DB_PROVIDER"] ?? "SQLite";
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
{
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? builder.Configuration["ConnectionStrings:DefaultConnection"];
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
options.UseNpgsql(connectionString));
}
else
{
var dbPath = Environment.GetEnvironmentVariable("TIMETRACKER_DB_PATH")
?? Path.Combine(builder.Environment.ContentRootPath, "timetracker.db");
builder.Services.AddDbContextFactory<TimetrackerDbContext>(options =>
options.UseSqlite($"Data Source={dbPath}"));
}
var app = builder.Build();
// Migrate or Ensure Database Created
using (var scope = app.Services.CreateScope())
{
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<TimetrackerDbContext>>();
await using var db = await factory.CreateDbContextAsync();
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
{
await db.Database.EnsureCreatedAsync();
}
else
{
await db.Database.MigrateAsync();
}
}
var forwardedHeadersOptions = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
};
forwardedHeadersOptions.KnownProxies.Clear();
forwardedHeadersOptions.KnownIPNetworks.Clear();
app.UseForwardedHeaders(forwardedHeadersOptions);
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
if (app.Configuration.GetValue("EnableHttpsRedirect", !app.Environment.IsDevelopment()))
{
app.UseHttpsRedirection();
}
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapStaticAssets();
// Map Hub
app.MapHub<NotificationHub>("/hubs/notifications");
// Map Blazor WASM
app.MapRazorComponents<App>()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(timetracker.Client._Imports).Assembly);
// ── Auth-API-Endpoints ────────────────────────────────────────────────────────
app.MapGet("/api/auth/me", (HttpContext ctx) =>
{
if (ctx.User.Identity?.IsAuthenticated == true)
{
var idClaim = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var name = ctx.User.Identity.Name ?? "";
if (int.TryParse(idClaim, out var id))
{
return Results.Ok(new UserInfo { Id = id, Username = name });
}
}
return Results.Unauthorized();
});
app.MapPost("/api/auth/login", async (HttpContext ctx, [FromBody] LoginRequest req, IAuthService authService) =>
{
var user = await authService.LoginAsync(req.Username, req.Password);
if (user == null)
return Results.BadRequest("Benutzername oder Passwort falsch.");
var claims = new[] {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true });
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username });
});
app.MapPost("/api/auth/register", async (HttpContext ctx, [FromBody] LoginRequest req, IAuthService authService) =>
{
var (user, error) = await authService.RegisterAsync(req.Username, req.Password);
if (user == null)
return Results.BadRequest(error ?? "Registrierung fehlgeschlagen.");
var claims = new[] {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties { IsPersistent = true });
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username });
});
app.MapGet("/auth/logout", async (HttpContext ctx) =>
{
await ctx.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Results.Redirect("/login");
});
// ── Admin-API-Endpoints (Protected) ───────────────────────────────────────────
var usersApi = app.MapGroup("/api/users").RequireAuthorization("AdminOnly");
usersApi.MapGet("/", async (IAuthService authService) =>
{
var users = await authService.GetAllUsersAsync();
return Results.Ok(users);
});
usersApi.MapDelete("/{userId:int}", async (int userId, IAuthService authService) =>
{
await authService.DeleteUserAsync(userId);
return Results.NoContent();
});
usersApi.MapPut("/{userId:int}/rename", async (int userId, [FromBody] RenameRequest req, IAuthService authService) =>
{
var error = await authService.RenameUserAsync(userId, req.Username);
if (error != null)
{
return Results.BadRequest(error);
}
return Results.Ok();
});
// ── Timetracker-API-Endpoints (Protected) ─────────────────────────────────────
var trackerApi = app.MapGroup("/api/tracker").RequireAuthorization();
trackerApi.MapGet("/week", async ([FromQuery] int userId, [FromQuery] string monday, ITimetrackerService trackerService) =>
{
if (DateOnly.TryParse(monday, out var date))
{
var days = await trackerService.GetWeekAsync(userId, date);
return Results.Ok(days);
}
return Results.BadRequest("Ungültiges Datum.");
});
trackerApi.MapPost("/workday", async ([FromBody] WorkDay workDay, ITimetrackerService trackerService) =>
{
await trackerService.UpsertWorkDayAsync(workDay);
return Results.Ok();
});
trackerApi.MapGet("/settings/{userId:int}", async (int userId, ITimetrackerService trackerService) =>
{
var settings = await trackerService.GetSettingsAsync(userId);
return Results.Ok(settings);
});
trackerApi.MapPost("/settings", async ([FromBody] AppSettings settings, ITimetrackerService trackerService) =>
{
await trackerService.SaveSettingsAsync(settings);
return Results.Ok();
});
trackerApi.MapGet("/vacation/{userId:int}/{year:int}", async (int userId, int year, ITimetrackerService trackerService) =>
{
var days = await trackerService.GetVacationDaysAsync(userId, year);
return Results.Ok(days);
});
trackerApi.MapPost("/vacation", async ([FromBody] VacationDay vacationDay, ITimetrackerService trackerService) =>
{
await trackerService.AddVacationDayAsync(vacationDay);
return Results.Ok();
});
trackerApi.MapDelete("/vacation/{userId:int}/{id:int}", async (int userId, int id, ITimetrackerService trackerService) =>
{
await trackerService.RemoveVacationDayAsync(userId, id);
return Results.NoContent();
});
trackerApi.MapPost("/overtime/{userId:int}", async (int userId, [FromBody] AppSettings settings, ITimetrackerService trackerService) =>
{
var ts = await trackerService.GetTotalOvertimeAsync(userId, settings);
return Results.Ok(ts.TotalHours);
});
trackerApi.MapGet("/month", async ([FromQuery] int userId, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService) =>
{
var days = await trackerService.GetMonthAsync(userId, year, month);
return Results.Ok(days);
});
// ── Holiday-API-Endpoints (Protected) ─────────────────────────────────────────
var holidaysApi = app.MapGroup("/api/holidays").RequireAuthorization();
holidaysApi.MapGet("/", async ([FromQuery] int year, [FromQuery] string? stateCode, IHolidayService holidayService) =>
{
var list = await holidayService.GetHolidaysAsync(year, stateCode);
return Results.Ok(list);
});
holidaysApi.MapPost("/fetch/{year:int}", async (int year, IHolidayService holidayService) =>
{
var (success, message) = await holidayService.FetchAndStoreAsync(year);
return Results.Ok(new { Success = success, Message = message });
});
holidaysApi.MapDelete("/{id:int}", async (int id, IHolidayService holidayService) =>
{
await holidayService.DeleteAsync(id);
return Results.NoContent();
});
app.Run();
// ── Models for Request Bodies ──────────────────────────────────────────────────
public record LoginRequest(string Username, string Password);
public record RenameRequest(string Username);
@@ -8,9 +8,16 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
<PackageReference Include="MudBlazor" Version="9.4.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\timetracker.Shared\timetracker.Shared.csproj" />
<ProjectReference Include="..\timetracker.Client\timetracker.Client.csproj" />
</ItemGroup>
</Project>

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Some files were not shown because too many files have changed in this diff Show More