multi-tenant implementierung
This commit is contained in:
@@ -1,15 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNotificationService notifier) : IAuthService
|
||||
public class AuthService(
|
||||
IDbContextFactory<TimetrackerDbContext> factory,
|
||||
UserNotificationService notifier,
|
||||
ITenantProvider tenantProvider) : IAuthService
|
||||
{
|
||||
public async Task<User?> LoginAsync(string username, string password)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
// Global query filter automatically restricts lookup to current tenant context
|
||||
var user = await db.Users
|
||||
.FirstOrDefaultAsync(u => u.Username == username);
|
||||
if (user == null) return null;
|
||||
@@ -19,7 +27,9 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
||||
public async Task<List<User>> GetAllUsersAsync()
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
// Superadmin bypasses query filters to manage all users globally
|
||||
return await db.Users
|
||||
.IgnoreQueryFilters()
|
||||
.OrderBy(u => u.Username)
|
||||
.ToListAsync();
|
||||
}
|
||||
@@ -27,7 +37,7 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
||||
public async Task DeleteUserAsync(int userId)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user != null)
|
||||
{
|
||||
db.Users.Remove(user);
|
||||
@@ -43,12 +53,16 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
||||
return "Benutzername muss mindestens 3 Zeichen lang sein.";
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
if (await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId))
|
||||
return "Benutzername bereits vergeben.";
|
||||
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
|
||||
// Find user ignoring filters so we can rename global admin too if needed
|
||||
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user == null) return "Benutzer nicht gefunden.";
|
||||
|
||||
// Check if name is taken within the same tenant context
|
||||
var isTaken = await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId && u.TenantId == user.TenantId);
|
||||
if (isTaken)
|
||||
return "Benutzername in dieser Firma bereits vergeben.";
|
||||
|
||||
user.Username = newUsername;
|
||||
await db.SaveChangesAsync();
|
||||
await notifier.NotifyUsersChangedAsync();
|
||||
@@ -61,7 +75,7 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
||||
return "Passwort muss mindestens 6 Zeichen lang sein.";
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user == null) return "Benutzer nicht gefunden.";
|
||||
|
||||
if (!VerifyPassword(currentPassword, user.PasswordHash, user.PasswordSalt))
|
||||
@@ -75,24 +89,164 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null) {
|
||||
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || username.Length < 3)
|
||||
return (null, "Benutzername muss mindestens 3 Zeichen lang sein.");
|
||||
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
|
||||
return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
|
||||
|
||||
var tenantId = tenantProvider.TenantId;
|
||||
if (tenantId == null)
|
||||
{
|
||||
return (null, "Mitarbeiter-Registrierung ist nur über die Firmen-Subdomain möglich.");
|
||||
}
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
// Filter handles checking uniqueness within the same tenant
|
||||
if (await db.Users.AnyAsync(u => u.Username == username))
|
||||
return (null, "Benutzername bereits vergeben.");
|
||||
return (null, "Benutzername in dieser Firma bereits vergeben.");
|
||||
|
||||
var (hash, salt) = HashPassword(password);
|
||||
var user = new User { Username = username, PasswordHash = hash, PasswordSalt = salt };
|
||||
var user = new User
|
||||
{
|
||||
Username = username,
|
||||
PasswordHash = hash,
|
||||
PasswordSalt = salt,
|
||||
TenantId = tenantId.Value
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
await notifier.NotifyUsersChangedAsync();
|
||||
return (user, null);
|
||||
}
|
||||
|
||||
public async Task<(Tenant? Tenant, string? Error)> RegisterFirmAsync(RegisterFirmRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.CompanyName) || req.CompanyName.Length < 3)
|
||||
return (null, "Firmenname muss mindestens 3 Zeichen lang sein.");
|
||||
if (string.IsNullOrWhiteSpace(req.Subdomain) || req.Subdomain.Length < 3)
|
||||
return (null, "Subdomain muss mindestens 3 Zeichen lang sein.");
|
||||
if (string.IsNullOrWhiteSpace(req.AdminUsername) || req.AdminUsername.Length < 3)
|
||||
return (null, "Administrator-Benutzername muss mindestens 3 Zeichen lang sein.");
|
||||
if (string.IsNullOrWhiteSpace(req.AdminPassword) || req.AdminPassword.Length < 6)
|
||||
return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
|
||||
|
||||
var subdomain = req.Subdomain.ToLower().Trim();
|
||||
if (!subdomain.All(c => char.IsLetterOrDigit(c) || c == '-'))
|
||||
{
|
||||
return (null, "Subdomain darf nur Buchstaben, Zahlen und Bindestriche enthalten.");
|
||||
}
|
||||
|
||||
var reservedSubdomains = new[] { "www", "api", "admin", "superadmin", "localhost", "mail", "portal", "app", "dev", "test" };
|
||||
if (reservedSubdomains.Contains(subdomain))
|
||||
{
|
||||
return (null, "Diese Subdomain ist reserviert und kann nicht verwendet werden.");
|
||||
}
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
|
||||
// Tenant checks are global, no filters applied to Tenants table
|
||||
var exists = await db.Tenants.AnyAsync(t => t.Subdomain == subdomain);
|
||||
if (exists)
|
||||
{
|
||||
return (null, "Diese Subdomain ist bereits vergeben.");
|
||||
}
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Name = req.CompanyName,
|
||||
Subdomain = subdomain,
|
||||
IsApproved = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
db.Tenants.Add(tenant);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var (hash, salt) = HashPassword(req.AdminPassword);
|
||||
var user = new User
|
||||
{
|
||||
Username = req.AdminUsername,
|
||||
PasswordHash = hash,
|
||||
PasswordSalt = salt,
|
||||
TenantId = tenant.Id,
|
||||
IsTenantAdmin = true
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
await notifier.NotifyUsersChangedAsync();
|
||||
return (tenant, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
return (null, $"Registrierungsfehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> AssignTenantAsync(int userId, int? tenantId)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
|
||||
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user == null) return "Benutzer nicht gefunden.";
|
||||
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
var tenantExists = await db.Tenants.AnyAsync(t => t.Id == tenantId.Value);
|
||||
if (!tenantExists) return "Firma existiert nicht.";
|
||||
}
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
user.TenantId = tenantId;
|
||||
|
||||
var settings = await db.AppSettings.IgnoreQueryFilters().Where(s => s.UserId == userId).ToListAsync();
|
||||
foreach (var s in settings) s.TenantId = tenantId;
|
||||
|
||||
var workDays = await db.WorkDays.IgnoreQueryFilters().Where(w => w.UserId == userId).ToListAsync();
|
||||
foreach (var w in workDays) w.TenantId = tenantId;
|
||||
|
||||
var vacationDays = await db.VacationDays.IgnoreQueryFilters().Where(v => v.UserId == userId).ToListAsync();
|
||||
foreach (var v in vacationDays) v.TenantId = tenantId;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
await notifier.NotifyUsersChangedAsync();
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
return $"Fehler beim Verschieben des Benutzers: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> ResetPasswordAsync(int userId, string newPassword)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6)
|
||||
return "Passwort muss mindestens 6 Zeichen lang sein.";
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user == null) return "Benutzer nicht gefunden.";
|
||||
|
||||
var (hash, salt) = HashPassword(newPassword);
|
||||
user.PasswordHash = hash;
|
||||
user.PasswordSalt = salt;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (string hash, string salt) HashPassword(string password)
|
||||
{
|
||||
var saltBytes = RandomNumberGenerator.GetBytes(32);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading.Tasks;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public interface ITenantProvider
|
||||
{
|
||||
int? TenantId { get; }
|
||||
Task<Tenant?> GetCurrentTenantAsync();
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using timetracker.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TimetrackerDbContext))]
|
||||
[Migration("20260624204954_AddMultiTenancy")]
|
||||
partial class AddMultiTenancy
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("DailyTargetHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateOnly?>("FlexTimeStartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("FlexTimeStartingBalanceHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("GermanState")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinimumBreakMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VacationDaysPerYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkFriday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkMonday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSaturday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSunday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkThursday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkTuesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkWednesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("WorkDayId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkDayId");
|
||||
|
||||
b.ToTable("BreakEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Counties")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PublicHolidays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsApproved")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subdomain")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Subdomain")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tenants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.Tenant", "Tenant")
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId");
|
||||
|
||||
b.Navigation("Tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMultiTenancy : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TenantId",
|
||||
table: "WorkDays",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TenantId",
|
||||
table: "VacationDays",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TenantId",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TenantId",
|
||||
table: "AppSettings",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Tenants",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Subdomain = table.Column<string>(type: "TEXT", nullable: false),
|
||||
IsApproved = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Tenants", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_TenantId",
|
||||
table: "Users",
|
||||
column: "TenantId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tenants_Subdomain",
|
||||
table: "Tenants",
|
||||
column: "Subdomain",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Users_Tenants_TenantId",
|
||||
table: "Users",
|
||||
column: "TenantId",
|
||||
principalTable: "Tenants",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Users_Tenants_TenantId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Tenants");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Users_TenantId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenantId",
|
||||
table: "WorkDays");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenantId",
|
||||
table: "VacationDays");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenantId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenantId",
|
||||
table: "AppSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using timetracker.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TimetrackerDbContext))]
|
||||
[Migration("20260624210436_AddTenantBranding")]
|
||||
partial class AddTenantBranding
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("DailyTargetHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateOnly?>("FlexTimeStartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("FlexTimeStartingBalanceHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("GermanState")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinimumBreakMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VacationDaysPerYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkFriday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkMonday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSaturday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSunday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkThursday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkTuesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkWednesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("WorkDayId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkDayId");
|
||||
|
||||
b.ToTable("BreakEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Counties")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PublicHolidays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsApproved")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PrimaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SecondaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subdomain")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Subdomain")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tenants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsTenantAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.Tenant", "Tenant")
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId");
|
||||
|
||||
b.Navigation("Tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTenantBranding : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsTenantAdmin",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PrimaryColor",
|
||||
table: "Tenants",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SecondaryColor",
|
||||
table: "Tenants",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsTenantAdmin",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PrimaryColor",
|
||||
table: "Tenants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SecondaryColor",
|
||||
table: "Tenants");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace timetracker.Data.Migrations
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.AppSettings", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -38,6 +38,9 @@ namespace timetracker.Data.Migrations
|
||||
b.Property<int>("MinimumBreakMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -70,7 +73,7 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -92,7 +95,7 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("BreakEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.PublicHoliday", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -113,12 +116,51 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("PublicHolidays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.User", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsApproved")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PrimaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SecondaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subdomain")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Subdomain")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tenants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsTenantAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
@@ -127,16 +169,21 @@ namespace timetracker.Data.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.VacationDay", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -148,6 +195,9 @@ namespace timetracker.Data.Migrations
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -156,7 +206,7 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -171,6 +221,9 @@ namespace timetracker.Data.Migrations
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -179,9 +232,9 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
|
||||
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
@@ -190,7 +243,16 @@ namespace timetracker.Data.Migrations
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.Tenant", "Tenant")
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId");
|
||||
|
||||
b.Navigation("Tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class TenantProvider : ITenantProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IDbContextFactory<TimetrackerDbContext> _dbContextFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public TenantProvider(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IDbContextFactory<TimetrackerDbContext> dbContextFactory,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public int? TenantId
|
||||
{
|
||||
get
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext == null) return null;
|
||||
|
||||
if (httpContext.Items.TryGetValue("TenantId", out var cachedId))
|
||||
{
|
||||
return (int?)cachedId;
|
||||
}
|
||||
|
||||
// Resolve and cache in HttpContext.Items
|
||||
ResolveTenantAsync().GetAwaiter().GetResult();
|
||||
|
||||
if (httpContext.Items.TryGetValue("TenantId", out cachedId))
|
||||
{
|
||||
return (int?)cachedId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Tenant?> GetCurrentTenantAsync()
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext == null) return null;
|
||||
|
||||
if (httpContext.Items.TryGetValue("CurrentTenant", out var cachedTenant))
|
||||
{
|
||||
return (Tenant?)cachedTenant;
|
||||
}
|
||||
|
||||
await ResolveTenantAsync();
|
||||
|
||||
if (httpContext.Items.TryGetValue("CurrentTenant", out cachedTenant))
|
||||
{
|
||||
return (Tenant?)cachedTenant;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task ResolveTenantAsync()
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext == null) return;
|
||||
|
||||
// Prevent double resolution in the same request
|
||||
if (httpContext.Items.ContainsKey("TenantResolved")) return;
|
||||
httpContext.Items["TenantResolved"] = true;
|
||||
|
||||
var host = httpContext.Request.Host.Host;
|
||||
var subdomain = GetSubdomain(host);
|
||||
|
||||
if (!string.IsNullOrEmpty(subdomain))
|
||||
{
|
||||
await using var db = await _dbContextFactory.CreateDbContextAsync();
|
||||
var tenant = await db.Tenants
|
||||
.FirstOrDefaultAsync(t => t.Subdomain.ToLower() == subdomain.ToLower());
|
||||
|
||||
if (tenant != null)
|
||||
{
|
||||
httpContext.Items["CurrentTenant"] = tenant;
|
||||
httpContext.Items["TenantId"] = tenant.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetSubdomain(string host)
|
||||
{
|
||||
var baseDomain = _configuration["TenantSettings:BaseDomain"] ?? "localhost";
|
||||
|
||||
// Ignore port if included in host
|
||||
var hostName = host.Split(':')[0];
|
||||
|
||||
if (hostName.Equals(baseDomain, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null; // Main domain
|
||||
}
|
||||
|
||||
if (hostName.EndsWith("." + baseDomain, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return hostName.Substring(0, hostName.Length - baseDomain.Length - 1);
|
||||
}
|
||||
|
||||
// Fallback for localhost testing
|
||||
if (baseDomain.Equals("localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parts = hostName.Split('.');
|
||||
if (parts.Length > 1 && !hostName.Equals("localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,72 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class TimetrackerDbContext(DbContextOptions<TimetrackerDbContext> options) : DbContext(options)
|
||||
public class TimetrackerDbContext : DbContext
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private ITenantProvider? _tenantProvider;
|
||||
private ITenantProvider TenantProvider => _tenantProvider ??= _serviceProvider.GetRequiredService<ITenantProvider>();
|
||||
|
||||
public TimetrackerDbContext(
|
||||
DbContextOptions<TimetrackerDbContext> options,
|
||||
IServiceProvider serviceProvider) : base(options)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<WorkDay> WorkDays => Set<WorkDay>();
|
||||
public DbSet<BreakEntry> BreakEntries => Set<BreakEntry>();
|
||||
public DbSet<AppSettings> AppSettings => Set<AppSettings>();
|
||||
public DbSet<VacationDay> VacationDays => Set<VacationDay>();
|
||||
public DbSet<PublicHoliday> PublicHolidays => Set<PublicHoliday>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Subdomain index unique
|
||||
modelBuilder.Entity<Tenant>()
|
||||
.HasIndex(t => t.Subdomain)
|
||||
.IsUnique();
|
||||
|
||||
// Global Query Filters (scopes database views by resolved TenantId)
|
||||
modelBuilder.Entity<User>().HasQueryFilter(u => u.TenantId == TenantProvider.TenantId);
|
||||
modelBuilder.Entity<WorkDay>().HasQueryFilter(w => w.TenantId == TenantProvider.TenantId);
|
||||
modelBuilder.Entity<VacationDay>().HasQueryFilter(v => v.TenantId == TenantProvider.TenantId);
|
||||
modelBuilder.Entity<AppSettings>().HasQueryFilter(s => s.TenantId == TenantProvider.TenantId);
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = TenantProvider.TenantId;
|
||||
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
foreach (var entry in ChangeTracker.Entries())
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
var tenantIdProp = entry.Entity.GetType().GetProperty("TenantId");
|
||||
if (tenantIdProp != null && tenantIdProp.CanWrite)
|
||||
{
|
||||
var currentValue = tenantIdProp.GetValue(entry.Entity);
|
||||
if (currentValue == null || (currentValue is int val && val == 0))
|
||||
{
|
||||
tenantIdProp.SetValue(entry.Entity, tenantId.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
+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