960 lines
37 KiB
C#
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);
|