Files
timetracker/timetracker.Server/Program.cs
T
Wieland, Marc 6ff8743dad updates
2026-06-26 15:34:34 +02:00

960 lines
37 KiB
C#

using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using Serilog;
using timetracker.Server.Components;
using timetracker.Data;
using timetracker.Shared;
var builder = WebApplication.CreateBuilder(args);
const string SuperAdminUsername = "marc";
const string SuperAdminPassword = "123456";
var logDirectory = Environment.GetEnvironmentVariable("TIMETRACKER_LOG_DIR")
?? Path.Combine(builder.Environment.ContentRootPath, "logs");
Directory.CreateDirectory(logDirectory);
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File(
Path.Combine(logDirectory, "timetracker-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
shared: true,
flushToDiskInterval: TimeSpan.FromSeconds(1))
.CreateLogger();
builder.Host.UseSerilog();
// Add Authentication
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/auth/logout";
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
// 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();
builder.Services.AddTransient<ITenantProvider, TenantProvider>();
// 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>();
builder.Services.AddScoped(sp =>
{
try
{
var navigationManager = sp.GetService<Microsoft.AspNetCore.Components.NavigationManager>();
if (navigationManager != null && !string.IsNullOrEmpty(navigationManager.BaseUri))
{
return new System.Net.Http.HttpClient { BaseAddress = new Uri(navigationManager.BaseUri) };
}
}
catch (InvalidOperationException)
{
// NavigationManager is registered but not initialized in this request scope
}
var httpContextAccessor = sp.GetService<Microsoft.AspNetCore.Http.IHttpContextAccessor>();
var request = httpContextAccessor?.HttpContext?.Request;
if (request != null)
{
var baseUri = $"{request.Scheme}://{request.Host}{request.PathBase}/";
return new System.Net.Http.HttpClient { BaseAddress = new Uri(baseUri) };
}
return new System.Net.Http.HttpClient();
});
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth-limit", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString()
?? httpContext.Request.Headers["X-Forwarded-For"].ToString()
?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1),
PermitLimit = 5,
QueueLimit = 0
}));
});
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)
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
}
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}")
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
}
var app = builder.Build();
app.Logger.LogInformation("File logging active. Writing logs to {LogDirectory}", logDirectory);
// 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();
var databaseCreatedFromModel = false;
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase))
{
try
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync();
}
using (var cmdCheckTables = conn.CreateCommand())
{
cmdCheckTables.CommandText = @"
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name <> '__EFMigrationsHistory';";
var existingTableCount = Convert.ToInt32(await cmdCheckTables.ExecuteScalarAsync() ?? 0);
if (existingTableCount == 0)
{
await db.Database.EnsureCreatedAsync();
databaseCreatedFromModel = true;
}
}
if (!databaseCreatedFromModel)
{
// Check if AppSettings table already exists
using (var cmdCheck = conn.CreateCommand())
{
cmdCheck.CommandText = "SELECT EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'AppSettings');";
var appSettingsExists = (bool)(await cmdCheck.ExecuteScalarAsync() ?? false);
if (appSettingsExists)
{
// Ensure __EFMigrationsHistory table exists
using (var cmdCreateHistory = conn.CreateCommand())
{
cmdCreateHistory.CommandText = @"
CREATE TABLE IF NOT EXISTS ""__EFMigrationsHistory"" (
""MigrationId"" character varying(150) NOT NULL,
""ProductVersion"" character varying(32) NOT NULL,
CONSTRAINT ""PK___EFMigrationsHistory"" PRIMARY KEY (""MigrationId"")
);";
await cmdCreateHistory.ExecuteNonQueryAsync();
}
// Check if the initial migration is already recorded
using (var cmdCheckHistory = conn.CreateCommand())
{
cmdCheckHistory.CommandText = @"SELECT COUNT(*) FROM ""__EFMigrationsHistory"" WHERE ""MigrationId"" = '20260520133634_Initial';";
var historyCount = Convert.ToInt32(await cmdCheckHistory.ExecuteScalarAsync() ?? 0);
if (historyCount == 0)
{
// Seed the history table with the migrations that were already created by EnsureCreated in older versions
using (var cmdInsertHistory = conn.CreateCommand())
{
cmdInsertHistory.CommandText = @"
INSERT INTO ""__EFMigrationsHistory"" (""MigrationId"", ""ProductVersion"") VALUES
('20260520133634_Initial', '10.0.9'),
('20260520200000_AddPublicHolidays', '10.0.9'),
('20260522081459_AddMultiUser', '10.0.9'),
('20260607213215_AddFlexTimeAndHolidayState', '10.0.9');";
await cmdInsertHistory.ExecuteNonQueryAsync();
}
}
}
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Vorbereiten der PostgreSQL Migrationshistorie: {ex.Message}");
}
}
if (!databaseCreatedFromModel)
{
await db.Database.MigrateAsync();
}
if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase) && !databaseCreatedFromModel)
{
try
{
var conn = db.Database.GetDbConnection();
if (conn.State != System.Data.ConnectionState.Open)
{
await conn.OpenAsync();
}
foreach (var tableName in new[] { "Users", "Tenants", "AppSettings", "WorkDays", "BreakEntries", "VacationDays", "PublicHolidays" })
{
await EnsurePostgresIntegerPrimaryKeyDefaultAsync(conn, tableName);
}
// Check if column IsTenantAdmin in Users table is of type integer
using (var cmdCheckType = conn.CreateCommand())
{
cmdCheckType.CommandText = @"
SELECT data_type
FROM information_schema.columns
WHERE table_name = 'Users' AND column_name = 'IsTenantAdmin';";
var dataType = await cmdCheckType.ExecuteScalarAsync() as string;
if (dataType != null && dataType.Equals("integer", StringComparison.OrdinalIgnoreCase))
{
// Drop legacy integer default before changing the column type.
using (var cmdAlterType = conn.CreateCommand())
{
cmdAlterType.CommandText = @"
ALTER TABLE ""Users""
ALTER COLUMN ""IsTenantAdmin"" DROP DEFAULT,
ALTER COLUMN ""IsTenantAdmin"" TYPE boolean
USING (""IsTenantAdmin"" <> 0),
ALTER COLUMN ""IsTenantAdmin"" SET DEFAULT FALSE;";
await cmdAlterType.ExecuteNonQueryAsync();
}
}
}
// Check if column IsApproved in Tenants table is of type integer
using (var cmdCheckApproved = conn.CreateCommand())
{
cmdCheckApproved.CommandText = @"
SELECT data_type
FROM information_schema.columns
WHERE table_name = 'Tenants' AND column_name = 'IsApproved';";
var dataType = await cmdCheckApproved.ExecuteScalarAsync() as string;
if (dataType != null && dataType.Equals("integer", StringComparison.OrdinalIgnoreCase))
{
// Drop legacy integer default before changing the column type.
using (var cmdAlterApproved = conn.CreateCommand())
{
cmdAlterApproved.CommandText = @"
ALTER TABLE ""Tenants""
ALTER COLUMN ""IsApproved"" DROP DEFAULT,
ALTER COLUMN ""IsApproved"" TYPE boolean
USING (""IsApproved"" <> 0),
ALTER COLUMN ""IsApproved"" SET DEFAULT FALSE;";
await cmdAlterApproved.ExecuteNonQueryAsync();
}
}
}
// Check if column CreatedAt in Tenants table is of type text or character varying
using (var cmdCheckCreatedAt = conn.CreateCommand())
{
cmdCheckCreatedAt.CommandText = @"
SELECT data_type
FROM information_schema.columns
WHERE table_name = 'Tenants' AND column_name = 'CreatedAt';";
var dataType = await cmdCheckCreatedAt.ExecuteScalarAsync() as string;
if (dataType != null && (dataType.Equals("text", StringComparison.OrdinalIgnoreCase) || dataType.Equals("character varying", StringComparison.OrdinalIgnoreCase)))
{
// Alter the column type to timestamp without time zone
using (var cmdAlterCreatedAt = conn.CreateCommand())
{
cmdAlterCreatedAt.CommandText = @"
ALTER TABLE ""Tenants""
ALTER COLUMN ""CreatedAt"" TYPE timestamp without time zone
USING (""CreatedAt""::timestamp without time zone);";
await cmdAlterCreatedAt.ExecuteNonQueryAsync();
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Fehler beim Konvertieren der PostgreSQL-Spaltentypen (IsTenantAdmin/IsApproved/CreatedAt): {ex.Message}");
}
}
var superAdmin = await db.Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.TenantId == null && u.Username == SuperAdminUsername);
if (superAdmin == null)
{
var (passwordHash, passwordSalt) = HashPassword(SuperAdminPassword);
db.Users.Add(new User
{
Username = SuperAdminUsername,
PasswordHash = passwordHash,
PasswordSalt = passwordSalt,
TenantId = null,
IsTenantAdmin = true
});
await db.SaveChangesAsync();
}
else if (IsLegacySeedPassword(superAdmin.PasswordHash, superAdmin.PasswordSalt, SuperAdminPassword))
{
var (passwordHash, passwordSalt) = HashPassword(SuperAdminPassword);
superAdmin.PasswordHash = passwordHash;
superAdmin.PasswordSalt = passwordSalt;
superAdmin.IsTenantAdmin = true;
await db.SaveChangesAsync();
}
}
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.UseRateLimiter();
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 ?? "";
var isTenantAdmin = ctx.User.FindFirst("IsTenantAdmin")?.Value == "true";
if (int.TryParse(idClaim, out var id))
{
return Results.Ok(new UserInfo { Id = id, Username = name, IsTenantAdmin = isTenantAdmin });
}
}
return Results.Unauthorized();
});
app.MapPost("/api/auth/login", async (HttpContext ctx, [FromBody] LoginRequest req, IAuthService authService, ITenantProvider tenantProvider) =>
{
var tenant = await tenantProvider.GetCurrentTenantAsync();
if (tenant != null && !tenant.IsApproved)
{
return Results.BadRequest("Diese Firma ist noch nicht freigeschaltet.");
}
var user = await authService.LoginAsync(req.Username, req.Password);
if (user == null)
return Results.BadRequest("Benutzername oder Passwort falsch.");
if (tenant != null)
{
if (user.TenantId != tenant.Id)
{
return Results.BadRequest("Benutzer gehört nicht zu dieser Firma.");
}
}
else
{
if (user.Username != "marc" && user.TenantId != null)
{
return Results.BadRequest("Benutzer gehört zu einer Firma. Bitte melden Sie sich über Ihre Firmen-Subdomain an.");
}
}
var claims = new List<Claim> {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("IsTenantAdmin", user.IsTenantAdmin ? "true" : "false")
};
if (user.TenantId.HasValue)
{
claims.Add(new Claim("TenantId", user.TenantId.Value.ToString()));
}
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, IsTenantAdmin = user.IsTenantAdmin });
}).RequireRateLimiting("auth-limit");
app.MapPost("/api/auth/register", async (HttpContext ctx, [FromBody] RegisterRequest req, IAuthService authService, ITenantProvider tenantProvider) =>
{
if (!string.IsNullOrEmpty(req.Honeypot))
{
return Results.BadRequest("Registrierung fehlgeschlagen.");
}
var tenant = await tenantProvider.GetCurrentTenantAsync();
if (tenant != null && !tenant.IsApproved)
{
return Results.BadRequest("Registrierung blockiert: Firma noch nicht freigeschaltet.");
}
var (user, error) = await authService.RegisterAsync(req.Username, req.Password);
if (user == null)
return Results.BadRequest(error ?? "Registrierung fehlgeschlagen.");
var claims = new List<Claim> {
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Username),
new Claim("IsTenantAdmin", user.IsTenantAdmin ? "true" : "false")
};
if (user.TenantId.HasValue)
{
claims.Add(new Claim("TenantId", user.TenantId.Value.ToString()));
}
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, IsTenantAdmin = user.IsTenantAdmin });
}).RequireRateLimiting("auth-limit");
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();
});
usersApi.MapPut("/{userId:int}/change-password", async (int userId, [FromBody] ChangePasswordRequest req, IAuthService authService) =>
{
var error = await authService.ChangePasswordAsync(userId, req.CurrentPassword, req.NewPassword);
if (error != null)
{
return Results.BadRequest(error);
}
return Results.Ok();
});
usersApi.MapPut("/{userId:int}/assign-tenant", async (int userId, [FromBody] AssignTenantRequest req, IAuthService authService) =>
{
var error = await authService.AssignTenantAsync(userId, req.TenantId);
if (error != null)
{
return Results.BadRequest(error);
}
return Results.Ok();
});
usersApi.MapPut("/{userId:int}/reset-password", async (int userId, [FromBody] ResetPasswordRequest req, IAuthService authService) =>
{
var error = await authService.ResetPasswordAsync(userId, req.NewPassword);
if (error != null)
{
return Results.BadRequest(error);
}
return Results.Ok();
});
// ── Timetracker-API-Endpoints (Protected) ─────────────────────────────────────
var trackerApi = app.MapGroup("/api/tracker").RequireAuthorization();
trackerApi.MapPost("/tenant/settings", async (ClaimsPrincipal claimsPrincipal, [FromBody] TenantSettingsDto dto, ITenantProvider tenantProvider, TimetrackerDbContext db) =>
{
var isAdmin = claimsPrincipal.FindFirst("IsTenantAdmin")?.Value == "true";
if (!isAdmin) return Results.Forbid();
var tenant = await tenantProvider.GetCurrentTenantAsync();
if (tenant == null) return Results.BadRequest("Keine Firma aktiv.");
var dbTenant = await db.Tenants.FindAsync(tenant.Id);
if (dbTenant == null) return Results.NotFound("Firma nicht gefunden.");
if (!string.IsNullOrWhiteSpace(dto.Name))
{
dbTenant.Name = dto.Name.Trim();
}
if (!string.IsNullOrWhiteSpace(dto.PrimaryColor))
{
dbTenant.PrimaryColor = dto.PrimaryColor.Trim();
}
if (!string.IsNullOrWhiteSpace(dto.SecondaryColor))
{
dbTenant.SecondaryColor = dto.SecondaryColor.Trim();
}
await db.SaveChangesAsync();
return Results.Ok();
});
trackerApi.MapGet("/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
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 (ClaimsPrincipal claimsPrincipal, [FromBody] WorkDay workDay, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
workDay.UserId = userId; // Enforce owner
await trackerService.UpsertWorkDayAsync(workDay);
return Results.Ok();
});
trackerApi.MapGet("/settings", async (ClaimsPrincipal claimsPrincipal, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
var settings = await trackerService.GetSettingsAsync(userId);
return Results.Ok(settings);
});
trackerApi.MapPost("/settings", async (ClaimsPrincipal claimsPrincipal, [FromBody] AppSettings settings, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
settings.UserId = userId; // Enforce owner
await trackerService.SaveSettingsAsync(settings);
return Results.Ok();
});
trackerApi.MapGet("/vacation/{year:int}", async (ClaimsPrincipal claimsPrincipal, int year, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
var days = await trackerService.GetVacationDaysAsync(userId, year);
return Results.Ok(days);
});
trackerApi.MapPost("/vacation", async (ClaimsPrincipal claimsPrincipal, [FromBody] VacationDay vacationDay, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
vacationDay.UserId = userId; // Enforce owner
await trackerService.AddVacationDayAsync(vacationDay);
return Results.Ok();
});
trackerApi.MapDelete("/vacation/{id:int}", async (ClaimsPrincipal claimsPrincipal, int id, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
await trackerService.RemoveVacationDayAsync(userId, id);
return Results.NoContent();
});
trackerApi.MapPost("/overtime", async (ClaimsPrincipal claimsPrincipal, [FromBody] AppSettings settings, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
settings.UserId = userId; // Enforce owner
var ts = await trackerService.GetTotalOvertimeAsync(userId, settings);
return Results.Ok(ts.TotalHours);
});
trackerApi.MapGet("/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
var days = await trackerService.GetMonthAsync(userId, year, month);
return Results.Ok(days);
});
trackerApi.MapGet("/export/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService, TimetrackerDbContext db) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
if (DateOnly.TryParse(monday, out var date))
{
var mondayDate = date;
int diff = ((int)mondayDate.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
mondayDate = mondayDate.AddDays(-diff);
var sundayDate = mondayDate.AddDays(6);
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
userId,
mondayDate,
sundayDate,
$"Wochenbericht KW {System.Globalization.ISOWeek.GetWeekOfYear(new DateTime(mondayDate.Year, mondayDate.Month, mondayDate.Day))}",
trackerService,
db);
return Results.File(
fileBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"Wochenbericht_KW_{System.Globalization.ISOWeek.GetWeekOfYear(new DateTime(mondayDate.Year, mondayDate.Month, mondayDate.Day))}_{mondayDate:yyyyMMdd}.xlsx");
}
return Results.BadRequest("Ungültiges Datum.");
});
trackerApi.MapGet("/export/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService, TimetrackerDbContext db) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
if (month < 1 || month > 12) return Results.BadRequest("Ungültiger Monat.");
var startDate = new DateOnly(year, month, 1);
var endDate = startDate.AddMonths(1).AddDays(-1);
var deCulture = new System.Globalization.CultureInfo("de-DE");
var monthName = deCulture.DateTimeFormat.GetMonthName(month);
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
userId,
startDate,
endDate,
$"Monatsbericht {monthName} {year}",
trackerService,
db);
return Results.File(
fileBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"Monatsbericht_{monthName}_{year}.xlsx");
});
trackerApi.MapGet("/export/year", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, ITimetrackerService trackerService, TimetrackerDbContext db) =>
{
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized();
var startDate = new DateOnly(year, 1, 1);
var endDate = new DateOnly(year, 12, 31);
var fileBytes = await ExcelExporter.GenerateExcelExportAsync(
userId,
startDate,
endDate,
$"Jahresbericht {year}",
trackerService,
db);
return Results.File(
fileBytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
$"Jahresbericht_{year}.xlsx");
});
// ── 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.MapGet("/api/tenant/current", async (ITenantProvider tenantProvider) =>
{
var tenant = await tenantProvider.GetCurrentTenantAsync();
if (tenant == null)
{
return Results.Ok(new TenantInfo { IsTenant = false });
}
return Results.Ok(new TenantInfo
{
IsTenant = true,
Name = tenant.Name,
Subdomain = tenant.Subdomain,
IsApproved = tenant.IsApproved,
PrimaryColor = tenant.PrimaryColor,
SecondaryColor = tenant.SecondaryColor
});
});
app.MapPost("/api/auth/register-firm", async ([FromBody] RegisterFirmRequest req, IAuthService authService) =>
{
var (tenant, error) = await authService.RegisterFirmAsync(req);
if (tenant == null)
return Results.BadRequest(error ?? "Registrierung der Firma fehlgeschlagen.");
return Results.Ok(new { Success = true });
}).RequireRateLimiting("auth-limit");
// ── SuperAdmin-API-Endpoints (Protected) ───────────────────────────────────────────
var superAdminApi = app.MapGroup("/api/superadmin").RequireAuthorization("AdminOnly");
superAdminApi.MapGet("/tenants", async (TimetrackerDbContext db) =>
{
var tenants = await db.Tenants
.OrderByDescending(t => t.CreatedAt)
.ToListAsync();
var stats = new List<object>();
foreach (var t in tenants)
{
var userCount = await db.Users.IgnoreQueryFilters().CountAsync(u => u.TenantId == t.Id);
var workDaysCount = await db.WorkDays.IgnoreQueryFilters().CountAsync(w => w.TenantId == t.Id);
var tenantUsers = await db.Users.IgnoreQueryFilters()
.Where(u => u.TenantId == t.Id)
.Select(u => new { u.Id, u.Username, u.IsTenantAdmin })
.ToListAsync();
stats.Add(new
{
t.Id,
t.Name,
t.Subdomain,
t.IsApproved,
t.CreatedAt,
UserCount = userCount,
WorkDaysCount = workDaysCount,
Users = tenantUsers
});
}
return Results.Ok(stats);
});
superAdminApi.MapPost("/tenants/{id:int}/approve", async (int id, TimetrackerDbContext db) =>
{
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return Results.NotFound();
tenant.IsApproved = true;
await db.SaveChangesAsync();
return Results.Ok();
});
superAdminApi.MapPost("/tenants/{id:int}/toggle-active", async (int id, TimetrackerDbContext db) =>
{
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return Results.NotFound();
tenant.IsApproved = !tenant.IsApproved;
await db.SaveChangesAsync();
return Results.Ok();
});
superAdminApi.MapDelete("/tenants/{id:int}", async (int id, TimetrackerDbContext db, UserNotificationService notifier) =>
{
var tenant = await db.Tenants.FindAsync(id);
if (tenant == null) return Results.NotFound();
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var userIds = await db.Users.IgnoreQueryFilters()
.Where(u => u.TenantId == id)
.Select(u => u.Id)
.ToListAsync();
var workDays = await db.WorkDays.IgnoreQueryFilters().Where(w => w.TenantId == id).ToListAsync();
db.WorkDays.RemoveRange(workDays);
var vacationDays = await db.VacationDays.IgnoreQueryFilters().Where(v => v.TenantId == id).ToListAsync();
db.VacationDays.RemoveRange(vacationDays);
var settings = await db.AppSettings.IgnoreQueryFilters().Where(s => s.TenantId == id).ToListAsync();
db.AppSettings.RemoveRange(settings);
var users = await db.Users.IgnoreQueryFilters().Where(u => u.TenantId == id).ToListAsync();
db.Users.RemoveRange(users);
db.Tenants.Remove(tenant);
await db.SaveChangesAsync();
await transaction.CommitAsync();
foreach (var uid in userIds)
{
await notifier.NotifyUserDeletedAsync(uid);
}
await notifier.NotifyUsersChangedAsync();
return Results.NoContent();
}
catch (Exception ex)
{
await transaction.RollbackAsync();
return Results.BadRequest($"Fehler beim Löschen der Firma: {ex.Message}");
}
});
app.Run();
static (string Hash, string Salt) HashPassword(string password)
{
var saltBytes = RandomNumberGenerator.GetBytes(32);
var salt = Convert.ToBase64String(saltBytes);
var hash = ComputePasswordHash(password, salt);
return (hash, salt);
}
static bool IsLegacySeedPassword(string hash, string salt, string password)
{
var passwordBytes = Encoding.UTF8.GetBytes(password + salt);
var legacyHash = Convert.ToBase64String(SHA256.HashData(passwordBytes));
return hash == legacyHash;
}
static string ComputePasswordHash(string password, string salt)
{
var hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password),
Convert.FromBase64String(salt),
iterations: 200_000,
HashAlgorithmName.SHA256,
outputLength: 32);
return Convert.ToBase64String(hash);
}
static async Task EnsurePostgresIntegerPrimaryKeyDefaultAsync(System.Data.Common.DbConnection conn, string tableName)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = $@"
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = '{tableName}'
AND column_name = 'Id'
) THEN
EXECUTE 'CREATE SEQUENCE IF NOT EXISTS public.""{tableName}_Id_seq""';
EXECUTE 'ALTER SEQUENCE public.""{tableName}_Id_seq"" OWNED BY public.""{tableName}"".""Id""';
EXECUTE 'ALTER TABLE public.""{tableName}"" ALTER COLUMN ""Id"" SET DEFAULT nextval(''''public.""{tableName}_Id_seq""'''')';
EXECUTE 'SELECT setval(''''public.""{tableName}_Id_seq""'''', COALESCE((SELECT MAX(""Id"") FROM public.""{tableName}""), 0) + 1, false)';
END IF;
END $$;";
await cmd.ExecuteNonQueryAsync();
}
// ── Models for Request Bodies ──────────────────────────────────────────────────
public record LoginRequest(string Username, string Password);
public record RegisterRequest(string Username, string Password, string? Honeypot = null);
public record RenameRequest(string Username);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);