WASM Mode activated
This commit is contained in:
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -1,22 +1,36 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
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:
|
timetracker:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: timetracker.Server/Dockerfile
|
||||||
container_name: timetracker-app
|
container_name: timetracker-app
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "8090:8080"
|
- "8090:8080"
|
||||||
environment:
|
environment:
|
||||||
- ASPNETCORE_ENVIRONMENT=Production
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
- ASPNETCORE_HTTP_PORTS=8080 # Lassen wir so, da intern völlig okay
|
- ASPNETCORE_HTTP_PORTS=8080
|
||||||
- TIMETRACKER_DB_PATH=/data/timetracker.db
|
- DB_PROVIDER=PostgreSQL
|
||||||
|
- ConnectionStrings__DefaultConnection=Host=db;Database=timetracker;Username=timetracker_user;Password=SecretPassword123;
|
||||||
- EnableHttpsRedirect=false
|
- EnableHttpsRedirect=false
|
||||||
volumes:
|
depends_on:
|
||||||
- timetracker_data:/data
|
- db
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
timetracker_data:
|
pgdata:
|
||||||
name: timetracker_prod_data
|
name: timetracker_pgdata
|
||||||
Executable
+16
@@ -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
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject timetracker.Data.UserNotificationService UserNotificationService
|
@inject IUserNotificationService UserNotificationService
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@using System.Security.Claims
|
@using System.Security.Claims
|
||||||
|
|
||||||
+1
-1
@@ -29,7 +29,7 @@
|
|||||||
<MudNavMenu>
|
<MudNavMenu>
|
||||||
<MudNavLink Href="/changelog" Icon="@Icons.Material.Filled.NewReleases"
|
<MudNavLink Href="/changelog" Icon="@Icons.Material.Filled.NewReleases"
|
||||||
Style="color: var(--mud-palette-text-disabled); font-size:0.75rem;">
|
Style="color: var(--mud-palette-text-disabled); font-size:0.75rem;">
|
||||||
Version 1.1
|
Version 1.2
|
||||||
</MudNavLink>
|
</MudNavLink>
|
||||||
</MudNavMenu>
|
</MudNavMenu>
|
||||||
</MudTooltip>
|
</MudTooltip>
|
||||||
+3
-3
@@ -1,10 +1,10 @@
|
|||||||
@page "/admin/users"
|
@page "/admin/users"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveWebAssembly
|
||||||
@attribute [Authorize(Policy = "AdminOnly")]
|
@attribute [Authorize(Policy = "AdminOnly")]
|
||||||
@inject AuthService AuthService
|
@inject IAuthService AuthService
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject timetracker.Data.UserNotificationService UserNotificationService
|
@inject IUserNotificationService UserNotificationService
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<PageTitle>Benutzerverwaltung – Timetracker</PageTitle>
|
<PageTitle>Benutzerverwaltung – Timetracker</PageTitle>
|
||||||
+10
-2
@@ -1,5 +1,5 @@
|
|||||||
@page "/changelog"
|
@page "/changelog"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveWebAssembly
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
|
||||||
<PageTitle>Changelog – Timetracker</PageTitle>
|
<PageTitle>Changelog – Timetracker</PageTitle>
|
||||||
@@ -68,7 +68,15 @@
|
|||||||
|
|
||||||
private readonly List<Release> _releases =
|
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", "Versionsnummer in der Navbar mit Link zum Changelog"),
|
||||||
new("Neu", "Changelog-Seite"),
|
new("Neu", "Changelog-Seite"),
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
@page "/feiertage"
|
@page "/feiertage"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveWebAssembly
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject HolidayService HolidayService
|
@inject IHolidayService HolidayService
|
||||||
@inject TimetrackerService TrackerService
|
@inject ITimetrackerService TrackerService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
|
||||||
<PageTitle>Feiertage – Timetracker</PageTitle>
|
<PageTitle>Feiertage – Timetracker</PageTitle>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveWebAssembly
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject TimetrackerService TrackerService
|
@inject ITimetrackerService TrackerService
|
||||||
@inject HolidayService HolidayService
|
@inject IHolidayService HolidayService
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
@page "/login"
|
@page "/login"
|
||||||
|
@rendermode InteractiveWebAssembly
|
||||||
@attribute [AllowAnonymous]
|
@attribute [AllowAnonymous]
|
||||||
|
@inject IAuthService AuthService
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
|
||||||
<PageTitle>Anmelden – Timetracker</PageTitle>
|
<PageTitle>Anmelden – Timetracker</PageTitle>
|
||||||
@@ -15,15 +17,15 @@
|
|||||||
</MudStack>
|
</MudStack>
|
||||||
|
|
||||||
<MudPaper Elevation="4" Class="pa-6 rounded-xl" Style="width:100%">
|
<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">
|
<MudStack Row="true" Justify="Justify.Center" Class="mb-4">
|
||||||
<MudButton Href="/login"
|
<MudButton OnClick="@(() => SetTab(0))"
|
||||||
Variant="@(_activeTab == 0 ? Variant.Filled : Variant.Text)"
|
Variant="@(_activeTab == 0 ? Variant.Filled : Variant.Text)"
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
Style="min-width: 120px; border-radius: 20px;">
|
Style="min-width: 120px; border-radius: 20px;">
|
||||||
Anmelden
|
Anmelden
|
||||||
</MudButton>
|
</MudButton>
|
||||||
<MudButton Href="/login?tab=register"
|
<MudButton OnClick="@(() => SetTab(1))"
|
||||||
Variant="@(_activeTab == 1 ? Variant.Filled : Variant.Text)"
|
Variant="@(_activeTab == 1 ? Variant.Filled : Variant.Text)"
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
Style="min-width: 120px; border-radius: 20px;">
|
Style="min-width: 120px; border-radius: 20px;">
|
||||||
@@ -41,14 +43,14 @@
|
|||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||||
}
|
}
|
||||||
<form action="/auth/login" method="post">
|
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin">
|
||||||
<MudStack Spacing="3">
|
<MudStack Spacing="3">
|
||||||
<MudTextField T="string"
|
<MudTextField T="string"
|
||||||
Label="Benutzername"
|
Label="Benutzername"
|
||||||
Variant="Variant.Outlined"
|
Variant="Variant.Outlined"
|
||||||
Adornment="Adornment.Start"
|
Adornment="Adornment.Start"
|
||||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||||
name="username"
|
@bind-Value="_loginModel.Username"
|
||||||
Required="true"
|
Required="true"
|
||||||
AutoFocus="true" />
|
AutoFocus="true" />
|
||||||
<MudTextField T="string"
|
<MudTextField T="string"
|
||||||
@@ -57,7 +59,7 @@
|
|||||||
Adornment="Adornment.Start"
|
Adornment="Adornment.Start"
|
||||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||||
InputType="InputType.Password"
|
InputType="InputType.Password"
|
||||||
name="password"
|
@bind-Value="_loginModel.Password"
|
||||||
Required="true" />
|
Required="true" />
|
||||||
<MudButton ButtonType="ButtonType.Submit"
|
<MudButton ButtonType="ButtonType.Submit"
|
||||||
Variant="Variant.Filled"
|
Variant="Variant.Filled"
|
||||||
@@ -65,11 +67,16 @@
|
|||||||
FullWidth="true"
|
FullWidth="true"
|
||||||
Size="Size.Large"
|
Size="Size.Large"
|
||||||
StartIcon="@Icons.Material.Filled.Login"
|
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
|
Anmelden
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</form>
|
</EditForm>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -80,14 +87,14 @@
|
|||||||
{
|
{
|
||||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||||
}
|
}
|
||||||
<form action="/auth/register" method="post">
|
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister">
|
||||||
<MudStack Spacing="3">
|
<MudStack Spacing="3">
|
||||||
<MudTextField T="string"
|
<MudTextField T="string"
|
||||||
Label="Benutzername"
|
Label="Benutzername"
|
||||||
Variant="Variant.Outlined"
|
Variant="Variant.Outlined"
|
||||||
Adornment="Adornment.Start"
|
Adornment="Adornment.Start"
|
||||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||||
name="username"
|
@bind-Value="_registerModel.Username"
|
||||||
Required="true"
|
Required="true"
|
||||||
HelperText="Mindestens 3 Zeichen" />
|
HelperText="Mindestens 3 Zeichen" />
|
||||||
<MudTextField T="string"
|
<MudTextField T="string"
|
||||||
@@ -96,7 +103,7 @@
|
|||||||
Adornment="Adornment.Start"
|
Adornment="Adornment.Start"
|
||||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||||
InputType="InputType.Password"
|
InputType="InputType.Password"
|
||||||
name="password"
|
@bind-Value="_registerModel.Password"
|
||||||
Required="true"
|
Required="true"
|
||||||
HelperText="Mindestens 6 Zeichen" />
|
HelperText="Mindestens 6 Zeichen" />
|
||||||
<MudButton ButtonType="ButtonType.Submit"
|
<MudButton ButtonType="ButtonType.Submit"
|
||||||
@@ -105,11 +112,16 @@
|
|||||||
FullWidth="true"
|
FullWidth="true"
|
||||||
Size="Size.Large"
|
Size="Size.Large"
|
||||||
StartIcon="@Icons.Material.Filled.PersonAdd"
|
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
|
Konto erstellen
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</form>
|
</EditForm>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
}
|
}
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -119,6 +131,10 @@
|
|||||||
@code {
|
@code {
|
||||||
private int _activeTab = 0;
|
private int _activeTab = 0;
|
||||||
private string? _error;
|
private string? _error;
|
||||||
|
private bool _loading;
|
||||||
|
|
||||||
|
private readonly AuthModel _loginModel = new();
|
||||||
|
private readonly AuthModel _registerModel = new();
|
||||||
|
|
||||||
[SupplyParameterFromQuery(Name = "error")]
|
[SupplyParameterFromQuery(Name = "error")]
|
||||||
public string? ErrorParam { get; set; }
|
public string? ErrorParam { get; set; }
|
||||||
@@ -136,5 +152,68 @@
|
|||||||
};
|
};
|
||||||
_activeTab = TabParam == "register" ? 1 : 0;
|
_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"
|
@page "/month"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveWebAssembly
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject TimetrackerService TrackerService
|
@inject ITimetrackerService TrackerService
|
||||||
@inject HolidayService HolidayService
|
@inject IHolidayService HolidayService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
|
||||||
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year – Monatsübersicht – Timetracker</PageTitle>
|
<PageTitle>@_deCulture.DateTimeFormat.GetMonthName(_month) @_year – Monatsübersicht – Timetracker</PageTitle>
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
@page "/settings"
|
@page "/settings"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveWebAssembly
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject TimetrackerService TrackerService
|
@inject ITimetrackerService TrackerService
|
||||||
@inject HolidayService HolidayService
|
@inject IHolidayService HolidayService
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
@page "/urlaub-maximizer"
|
@page "/urlaub-maximizer"
|
||||||
@rendermode InteractiveServer
|
@rendermode InteractiveWebAssembly
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject TimetrackerService TrackerService
|
@inject ITimetrackerService TrackerService
|
||||||
@inject HolidayService HolidayService
|
@inject IHolidayService HolidayService
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@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">
|
<Found Context="routeData">
|
||||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(timetracker.Client.Components.Layout.MainLayout)">
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
<RedirectToLogin />
|
<timetracker.Client.Components.RedirectToLogin />
|
||||||
</NotAuthorized>
|
</NotAuthorized>
|
||||||
</AuthorizeRouteView>
|
</AuthorizeRouteView>
|
||||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
<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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<Routes />
|
<Routes />
|
||||||
<ReconnectModal />
|
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
@using System.Net.Http
|
@using System.Net.Http
|
||||||
@using System.Net.Http.Json
|
@using System.Net.Http.Json
|
||||||
@using System.Security.Claims
|
@using System.Security.Claims
|
||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using timetracker
|
@using timetracker
|
||||||
@using timetracker.Components
|
@using timetracker.Client.Components
|
||||||
@using timetracker.Components.Layout
|
@using timetracker.Client.Components.Layout
|
||||||
@using timetracker.Data
|
@using timetracker.Data
|
||||||
|
@using timetracker.Shared
|
||||||
@using MudBlazor
|
@using MudBlazor
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using timetracker.Shared;
|
||||||
|
|
||||||
namespace timetracker.Data;
|
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)
|
public async Task<User?> LoginAsync(string username, string password)
|
||||||
{
|
{
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using timetracker.Shared;
|
||||||
|
|
||||||
namespace timetracker.Data;
|
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";
|
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 Microsoft.EntityFrameworkCore;
|
||||||
|
using timetracker.Shared;
|
||||||
|
|
||||||
namespace timetracker.Data;
|
namespace timetracker.Data;
|
||||||
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using timetracker.Shared;
|
||||||
|
|
||||||
namespace timetracker.Data;
|
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)
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,22 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Keine Unterordner mehr beim Kopieren!
|
# Copy project files for restoring dependencies
|
||||||
COPY timetracker.csproj ./
|
COPY timetracker.sln ./
|
||||||
RUN dotnet restore timetracker.csproj
|
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
|
RUN dotnet publish -c Release -o /app/publish
|
||||||
|
|
||||||
# ── Runtime Stage ─────────────────────────────────────────────────────────────
|
# ── Runtime Stage ─────────────────────────────────────────────────────────────
|
||||||
@@ -14,7 +25,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
# Verzeichnis für die SQLite-Datenbank
|
# Directory for SQLite database
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /data
|
||||||
|
|
||||||
ENV ASPNETCORE_HTTP_PORTS=8080
|
ENV ASPNETCORE_HTTP_PORTS=8080
|
||||||
@@ -27,4 +38,4 @@ EXPOSE 8080
|
|||||||
|
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
|
|
||||||
ENTRYPOINT ["dotnet", "timetracker.dll"]
|
ENTRYPOINT ["dotnet", "timetracker.Server.dll"]
|
||||||
@@ -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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="*" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="*" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
||||||
<PackageReference Include="MudBlazor" Version="9.4.0" />
|
<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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</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
Reference in New Issue
Block a user