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(); // Add SignalR builder.Services.AddSignalR(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); // Register DB-backed services as the interfaces builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveWebAssemblyComponents(); builder.Services.AddMudServices(); builder.Services.AddHttpClient(); builder.Services.AddScoped(sp => { try { var navigationManager = sp.GetService(); 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(); 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(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(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>(); await using var db = await factory.CreateDbContextAsync(); if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase)) { try { var conn = db.Database.GetDbConnection(); if (conn.State != System.Data.ConnectionState.Open) { await conn.OpenAsync(); } // 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}"); } } await db.Database.MigrateAsync(); if (dbProvider.Equals("PostgreSQL", StringComparison.OrdinalIgnoreCase)) { try { var conn = db.Database.GetDbConnection(); if (conn.State != System.Data.ConnectionState.Open) { await conn.OpenAsync(); } // 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("/hubs/notifications"); // Map Blazor WASM app.MapRazorComponents() .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 { 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 { 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(); 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); } // ── 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);