multi-tenant implementierung
This commit is contained in:
+248
-10
@@ -47,6 +47,8 @@ builder.Services.AddAuthorization(options =>
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddTransient<ITenantProvider, TenantProvider>();
|
||||
|
||||
// Add SignalR
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddSingleton<UserNotificationService>();
|
||||
@@ -63,6 +65,31 @@ builder.Services.AddRazorComponents()
|
||||
|
||||
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 =>
|
||||
{
|
||||
@@ -162,54 +189,93 @@ app.MapGet("/api/auth/me", (HttpContext ctx) =>
|
||||
{
|
||||
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 });
|
||||
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) =>
|
||||
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.");
|
||||
|
||||
var claims = new[] {
|
||||
if (tenant != null)
|
||||
{
|
||||
if (user.TenantId != tenant.Id)
|
||||
{
|
||||
return Results.BadRequest("Benutzer gehört nicht zu dieser Firma.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (user.Username != "marc")
|
||||
{
|
||||
return Results.BadRequest("Anmeldung auf der Hauptdomain nur für Administratoren.");
|
||||
}
|
||||
}
|
||||
|
||||
var claims = new List<Claim> {
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username)
|
||||
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 });
|
||||
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) =>
|
||||
app.MapPost("/api/auth/register", async (HttpContext ctx, [FromBody] RegisterRequest req, IAuthService authService, ITenantProvider tenantProvider) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(req.Honeypot))
|
||||
{
|
||||
// Silently reject bots
|
||||
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[] {
|
||||
var claims = new List<Claim> {
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username)
|
||||
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 });
|
||||
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username, IsTenantAdmin = user.IsTenantAdmin });
|
||||
}).RequireRateLimiting("auth-limit");
|
||||
|
||||
app.MapGet("/auth/logout", async (HttpContext ctx) =>
|
||||
@@ -253,9 +319,57 @@ usersApi.MapPut("/{userId:int}/change-password", async (int userId, [FromBody] C
|
||||
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;
|
||||
@@ -366,6 +480,130 @@ holidaysApi.MapDelete("/{id:int}", async (int id, IHolidayService holidayService
|
||||
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();
|
||||
|
||||
// ── Models for Request Bodies ──────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user