diff --git a/timetracker.Client/Components/Layout/MainLayout.razor b/timetracker.Client/Components/Layout/MainLayout.razor index 0b214b4..ad1a0a7 100644 --- a/timetracker.Client/Components/Layout/MainLayout.razor +++ b/timetracker.Client/Components/Layout/MainLayout.razor @@ -3,6 +3,7 @@ @inject NavigationManager Nav @inject IUserNotificationService UserNotificationService @inject IJSRuntime JSRuntime +@inject HttpClient Http @implements IDisposable @using System.Security.Claims @@ -12,7 +13,7 @@ - + @@ -50,9 +51,74 @@ private bool _drawerOpen = true; private bool _isDarkMode; - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { UserNotificationService.OnUserDeleted += HandleUserDeleted; + + try + { + var tenantInfo = await Http.GetFromJsonAsync("api/tenant/current"); + if (tenantInfo?.IsTenant == true && !string.IsNullOrEmpty(tenantInfo.PrimaryColor)) + { + var primaryColor = tenantInfo.PrimaryColor; + var secondaryColor = tenantInfo.SecondaryColor ?? tenantInfo.PrimaryColor; + + _theme.PaletteLight.Primary = primaryColor; + _theme.PaletteLight.Secondary = secondaryColor; + _theme.PaletteDark.Primary = primaryColor; + _theme.PaletteDark.Secondary = secondaryColor; + + // Dynamically apply background colors to navbar + _theme.PaletteLight.AppbarBackground = primaryColor; + _theme.PaletteLight.DrawerBackground = primaryColor; + _theme.PaletteDark.AppbarBackground = primaryColor; + _theme.PaletteDark.DrawerBackground = primaryColor; + + // Adjust text/icon contrast based on background luminance + var isLight = IsLightColor(primaryColor); + var textCol = isLight ? "#0F172A" : "#FFFFFF"; + var subTextCol = isLight ? "rgba(15, 23, 42, 0.70)" : "rgba(255, 255, 255, 0.70)"; + + _theme.PaletteLight.AppbarText = textCol; + _theme.PaletteLight.DrawerText = subTextCol; + _theme.PaletteLight.DrawerIcon = subTextCol; + _theme.PaletteLight.PrimaryContrastText = textCol; + + _theme.PaletteDark.AppbarText = textCol; + _theme.PaletteDark.DrawerText = subTextCol; + _theme.PaletteDark.DrawerIcon = subTextCol; + _theme.PaletteDark.PrimaryContrastText = textCol; + } + } + catch + { + // Ignore background error + } + } + + private bool IsLightColor(string hexColor) + { + if (string.IsNullOrEmpty(hexColor)) return false; + hexColor = hexColor.TrimStart('#'); + if (hexColor.Length == 3) + { + hexColor = new string(new[] { hexColor[0], hexColor[0], hexColor[1], hexColor[1], hexColor[2], hexColor[2] }); + } + if (hexColor.Length != 6) return false; + + try + { + var r = Convert.ToInt32(hexColor.Substring(0, 2), 16); + var g = Convert.ToInt32(hexColor.Substring(2, 2), 16); + var b = Convert.ToInt32(hexColor.Substring(4, 2), 16); + + var luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0; + return luminance > 0.6; // slightly conservative threshold for white text readability + } + catch + { + return false; + } } protected override async Task OnAfterRenderAsync(bool firstRender) diff --git a/timetracker.Client/Components/Layout/MainLayout.razor.css b/timetracker.Client/Components/Layout/MainLayout.razor.css index 2b25e7b..8311fd4 100644 --- a/timetracker.Client/Components/Layout/MainLayout.razor.css +++ b/timetracker.Client/Components/Layout/MainLayout.razor.css @@ -14,6 +14,7 @@ } ::deep .modern-drawer { + background-color: var(--mud-palette-drawer-background) !important; border-right: 1px solid rgba(255, 255, 255, 0.08) !important; box-shadow: 4px 0 24px rgba(0, 0, 0, 0.1) !important; transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important; @@ -21,7 +22,7 @@ /* Glassmorphism for the appbar */ ::deep .mud-appbar { - background: rgba(15, 23, 42, 0.85) !important; + background: color-mix(in srgb, var(--mud-palette-appbar-background) 85%, transparent) !important; backdrop-filter: blur(12px) !important; -webkit-backdrop-filter: blur(12px) !important; border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important; diff --git a/timetracker.Client/Components/Layout/NavMenu.razor b/timetracker.Client/Components/Layout/NavMenu.razor index 1b89023..e52c1ed 100644 --- a/timetracker.Client/Components/Layout/NavMenu.razor +++ b/timetracker.Client/Components/Layout/NavMenu.razor @@ -1,4 +1,5 @@ @inject IJSRuntime JSRuntime +@inject HttpClient Http @* --- Brand / Logo Section --- *@ @@ -37,6 +38,11 @@ Admin + + + Firmen verwalten + + Benutzerverwaltung @@ -61,7 +67,7 @@ @context.User.Identity?.Name - Online + @_companyName @@ -93,6 +99,23 @@ [Parameter] public EventCallback OnToggleDrawer { get; set; } private bool _showVersionBadge; + private string _companyName = "Hauptdomain"; + + protected override async Task OnInitializedAsync() + { + try + { + var tenantInfo = await Http.GetFromJsonAsync("api/tenant/current"); + if (tenantInfo?.IsTenant == true && !string.IsNullOrEmpty(tenantInfo.Name)) + { + _companyName = tenantInfo.Name; + } + } + catch + { + // Ignore background error + } + } protected override async Task OnAfterRenderAsync(bool firstRender) { diff --git a/timetracker.Client/Components/Layout/NavMenu.razor.css b/timetracker.Client/Components/Layout/NavMenu.razor.css index b1087de..ad6391e 100644 --- a/timetracker.Client/Components/Layout/NavMenu.razor.css +++ b/timetracker.Client/Components/Layout/NavMenu.razor.css @@ -44,7 +44,7 @@ } ::deep .logo-icon { - color: #0EA5E9 !important; /* Sky 500 */ + color: var(--mud-palette-secondary) !important; font-size: 1.6rem !important; transition: opacity 0.2s ease; } @@ -52,7 +52,7 @@ .brand-text { font-size: 1.1rem; font-weight: 800; - color: #FFFFFF; + color: var(--mud-palette-appbar-text); letter-spacing: 0.5px; white-space: nowrap; opacity: 1; @@ -104,7 +104,7 @@ ::deep .custom-nav-link, ::deep .custom-nav-link .mud-nav-link-text, ::deep .custom-nav-link .mud-nav-link-icon-default { - color: #94A3B8 !important; /* Slate 400 */ + color: var(--mud-palette-drawer-text) !important; } /* NavLink hover */ @@ -115,15 +115,15 @@ ::deep .custom-nav-link:hover, ::deep .custom-nav-link:hover .mud-nav-link-text, ::deep .custom-nav-link:hover .mud-nav-link-icon-default { - color: #F8FAFC !important; /* Slate 50 */ + color: var(--mud-palette-appbar-text) !important; } /* NavLink active state (Fixes active color contrast issue) */ ::deep .custom-nav-link.active, ::deep .custom-nav-link .active { - background: linear-gradient(90deg, rgba(14, 165, 233, 0.15) 0%, rgba(14, 165, 233, 0.04) 100%) !important; + background: linear-gradient(90deg, color-mix(in srgb, var(--mud-palette-secondary) 15%, transparent) 0%, color-mix(in srgb, var(--mud-palette-secondary) 4%, transparent) 100%) !important; font-weight: 600 !important; - box-shadow: inset 3px 0 0 #0EA5E9 !important; + box-shadow: inset 3px 0 0 var(--mud-palette-secondary) !important; } ::deep .custom-nav-link.active, @@ -132,7 +132,7 @@ ::deep .custom-nav-link .active .mud-nav-link-text, ::deep .custom-nav-link.active .mud-nav-link-icon-default, ::deep .custom-nav-link .active .mud-nav-link-icon-default { - color: #0EA5E9 !important; /* Sky 500 */ + color: var(--mud-palette-secondary) !important; } /* Admin link color overrides & active state */ @@ -208,7 +208,7 @@ ::deep .user-avatar { width: 36px !important; height: 36px !important; - background: linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%) !important; + background: linear-gradient(135deg, var(--mud-palette-secondary) 0%, var(--mud-palette-secondary-darken) 100%) !important; color: #FFFFFF !important; font-weight: 700 !important; font-size: 0.875rem !important; @@ -222,7 +222,7 @@ width: 10px; height: 10px; background-color: #10B981; /* Emerald 500 */ - border: 2px solid #0F172A; + border: 2px solid var(--mud-palette-drawer-background); border-radius: 50%; } @@ -236,7 +236,7 @@ .user-name { font-size: 0.85rem; font-weight: 600; - color: #FFFFFF; + color: var(--mud-palette-appbar-text); white-space: nowrap; text-overflow: ellipsis; overflow: hidden; diff --git a/timetracker.Client/Components/Pages/AdminUsers.razor b/timetracker.Client/Components/Pages/AdminUsers.razor index 71cf2fd..7e1a26c 100644 --- a/timetracker.Client/Components/Pages/AdminUsers.razor +++ b/timetracker.Client/Components/Pages/AdminUsers.razor @@ -1,10 +1,14 @@ @page "/admin/users" @rendermode InteractiveWebAssembly @attribute [Authorize(Policy = "AdminOnly")] +@using System.Net.Http.Json +@using System.Security.Claims @inject IAuthService AuthService @inject ISnackbar Snackbar @inject AuthenticationStateProvider AuthStateProvider @inject IUserNotificationService UserNotificationService +@inject HttpClient Http +@inject IDialogService DialogService @implements IDisposable Benutzerverwaltung – Timetracker @@ -42,6 +46,7 @@ else ID Benutzername + Firma / Mandant Aktionen @@ -74,6 +79,34 @@ else } + + @if (_editUserId == context.Id) + { + + Hauptdomain (System/Global) + @foreach (var tenant in _tenants) + { + @tenant.Name (@tenant.Subdomain) + } + + } + else + { + @if (context.TenantId.HasValue) + { + var t = _tenants.FirstOrDefault(x => x.Id == context.TenantId.Value); + + @(t?.Name ?? $"Firma #{context.TenantId}") + + } + else + { + + Hauptdomain (SuperUser) + + } + } + @if (_editUserId == context.Id) { @@ -92,6 +125,12 @@ else Color="Color.Primary" Size="Size.Small" OnClick="@(() => StartEdit(context))" /> + + + @if (context.Username != "marc") { _users = []; + private List _tenants = []; private bool _loading = true; private int? _editUserId; private string _editUsername = ""; + private int? _editTenantId; protected override async Task OnInitializedAsync() { @@ -125,13 +166,31 @@ else if (claim == null) return; UserNotificationService.OnUsersChanged += RefreshUsers; - _users = await AuthService.GetAllUsersAsync(); + + try + { + _users = await AuthService.GetAllUsersAsync(); + _tenants = await Http.GetFromJsonAsync>("api/superadmin/tenants") ?? []; + } + catch (Exception ex) + { + Snackbar.Add($"Fehler beim Laden der Daten: {ex.Message}", Severity.Error); + } + _loading = false; } private async Task RefreshUsers() { _users = await AuthService.GetAllUsersAsync(); + try + { + _tenants = await Http.GetFromJsonAsync>("api/superadmin/tenants") ?? []; + } + catch + { + // Ignore background refresh errors + } await InvokeAsync(StateHasChanged); } @@ -144,27 +203,49 @@ else { _editUserId = user.Id; _editUsername = user.Username; + _editTenantId = user.TenantId; } private void CancelEdit() { _editUserId = null; _editUsername = ""; + _editTenantId = null; } private async Task SaveRename(User user) { var trimmed = _editUsername.Trim(); - var error = await AuthService.RenameUserAsync(user.Id, trimmed); - if (error != null) + + // 1. Rename if username changed + if (trimmed != user.Username) { - Snackbar.Add(error, Severity.Error); - return; + var error = await AuthService.RenameUserAsync(user.Id, trimmed); + if (error != null) + { + Snackbar.Add(error, Severity.Error); + return; + } + user.Username = trimmed; } - user.Username = trimmed; + + // 2. Move to tenant if tenant ID changed + if (_editTenantId != user.TenantId) + { + var error = await AuthService.AssignTenantAsync(user.Id, _editTenantId); + if (error != null) + { + Snackbar.Add(error, Severity.Error); + return; + } + user.TenantId = _editTenantId; + } + _editUserId = null; _editUsername = ""; - Snackbar.Add($"Benutzer umbenannt zu \"{trimmed}\".", Severity.Success); + _editTenantId = null; + + Snackbar.Add("Benutzerdaten erfolgreich aktualisiert.", Severity.Success); } private async Task DeleteUser(User user) @@ -173,4 +254,29 @@ else _users.Remove(user); Snackbar.Add($"Benutzer \"{user.Username}\" gelöscht.", Severity.Info); } + + private async Task OpenResetPasswordDialog(User user) + { + var parameters = new DialogParameters + { + { x => x.Username, user.Username } + }; + + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = await DialogService.ShowAsync("Passwort zurücksetzen", parameters, options); + var result = await dialog.Result; + + if (result != null && !result.Canceled && result.Data is string newPassword) + { + var error = await AuthService.ResetPasswordAsync(user.Id, newPassword); + if (error != null) + { + Snackbar.Add(error, Severity.Error); + } + else + { + Snackbar.Add($"Passwort für {user.Username} wurde erfolgreich zurückgesetzt.", Severity.Success); + } + } + } } diff --git a/timetracker.Client/Components/Pages/Agb.razor b/timetracker.Client/Components/Pages/Agb.razor index 427a80c..fe01093 100644 --- a/timetracker.Client/Components/Pages/Agb.razor +++ b/timetracker.Client/Components/Pages/Agb.razor @@ -8,12 +8,12 @@ @* ── Header ── *@ - + - + - Allgemeine Geschäftsbedingungen (AGB) - + Allgemeine Geschäftsbedingungen (AGB) + Rechtlich absolut unverbindliches Kauderwelsch und Flachwitze diff --git a/timetracker.Client/Components/Pages/Changelog.razor b/timetracker.Client/Components/Pages/Changelog.razor index 4008368..232cabc 100644 --- a/timetracker.Client/Components/Pages/Changelog.razor +++ b/timetracker.Client/Components/Pages/Changelog.razor @@ -8,13 +8,12 @@ @* ── Header ── *@ - + - + - Changelog - Versionshistorie & Änderungen + Changelog + Versionshistorie & Änderungen diff --git a/timetracker.Client/Components/Pages/Home.razor b/timetracker.Client/Components/Pages/Home.razor index 5dd8e16..5691e06 100644 --- a/timetracker.Client/Components/Pages/Home.razor +++ b/timetracker.Client/Components/Pages/Home.razor @@ -21,29 +21,28 @@ else @* ── Wochen-Header ── *@ - + + Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevWeek" /> - + @_weekLabel - + @_weekSubLabel @if (!IsCurrentWeek) { - Heute } + Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextWeek" /> @@ -316,34 +315,34 @@ else @* ── Wochensumme ── *@ + Style="background: var(--mud-palette-primary-darken); color: var(--mud-palette-primary-text);"> - - Wochensumme + + Wochensumme - Brutto - @FormatTs(WeekGross) + Brutto + @FormatTs(WeekGross) - Pausen - @FormatTs(WeekBreaks) + Pausen + @FormatTs(WeekBreaks) - Netto - @FormatTs(WeekNet) + Netto + @FormatTs(WeekNet) - Gleitzeit - = TimeSpan.Zero ? "#A5D6A7" : "#FFCC80")}; font-weight:700")"> + Gleitzeit + @FormatTs(WeekOvertime, sign: true) diff --git a/timetracker.Client/Components/Pages/Login.razor b/timetracker.Client/Components/Pages/Login.razor index 9ee1838..7a127b0 100644 --- a/timetracker.Client/Components/Pages/Login.razor +++ b/timetracker.Client/Components/Pages/Login.razor @@ -1,149 +1,312 @@ @page "/login" @rendermode InteractiveWebAssembly @attribute [AllowAnonymous] +@using System.Net.Http.Json @inject IAuthService AuthService @inject NavigationManager Nav @inject IJSRuntime JSRuntime +@inject HttpClient Http -Anmelden – Timetracker +@GetPageTitle() - - - - @* ── Logo / Header ── *@ - - - Timetracker - - - - @* ── Tab Navigation ── *@ - - - Anmelden - - - Registrieren - +@if (_tenantInfo == null) +{ + @* Loading indicator while resolving Tenant *@ + + + Lade Timetracker… + +} +else if (_tenantInfo.IsTenant && !_tenantInfo.IsApproved) +{ + @* Pending Approval Screen *@ + + + + + Timetracker - + + + Freischaltung ausstehend + + Die Firma @_tenantInfo.Name wurde erfolgreich registriert. + Der Zugang wird derzeit von unserem SuperAdmin geprüft und in Kürze freigeschaltet. + + + + Bitte laden Sie diese Seite in Kürze neu, sobald Ihr Account freigegeben wurde. + + + + - @if (_activeTab == 0) - { - @* ── Login Form ── *@ - - @if (_error != null) - { - @_error - } - - - - - - @if (_loading) - { - - } - Anmelden - - - - - } - else - { - @* ── Register Form ── *@ - - @if (_error != null) - { - @_error - } - - - - - - - - Ich akzeptiere die AGB. - + +} +else +{ + @* Main login/registration container *@ + + - - @if (_loading) - { - - } - Konto erstellen - + @* ── Logo / Header ── *@ + + + + @(_tenantInfo.IsTenant ? _tenantInfo.Name : "Timetracker SaaS") + + @if (!_tenantInfo.IsTenant) + { + Zentrales Portal + } + + + + @if (_firmRegisteredSuccessfully) + { + @* Success feedback after firm registration *@ + + + Registrierung erfolgreich! + + Ihre Firma @_firmModel.CompanyName wurde erfolgreich registriert. + + + Ihr Zugang muss nun durch den SuperAdmin freigegeben werden. Danach können Sie sich unter Ihrer Subdomain anmelden: + + @GetTenantUrl() + + + Zurück zum Login + + + } + else + { + @* ── Tab Navigation ── *@ + + + Anmelden + + + @(_tenantInfo.IsTenant ? "Registrieren" : "Firma registrieren") + + + + + + @if (_activeTab == 0) + { + @* ── Login Form ── *@ + + @if (_error != null) + { + @_error + } + + + + + + @if (_loading) + { + + } + Anmelden + + + - - - } - - - + } + else + { + @if (_tenantInfo.IsTenant) + { + @* ── Employee Register Form ── *@ + + @if (_error != null) + { + @_error + } + + + + + + + + Ich akzeptiere die AGB. + + + + @if (_loading) + { + + } + Konto erstellen + + + + + } + else + { + @* ── Firm Register Form ── *@ + + @if (_error != null) + { + @_error + } + + + + + + + + + Administrator-Konto + + + + + + + + + Ich akzeptiere die AGB. + + + + @if (_loading) + { + + } + Firma registrieren + + + + + } + } + } + + + +} @code { + private TenantInfo? _tenantInfo; private int _activeTab = 0; private string? _error; private bool _loading; private string _honeypot = ""; private bool _acceptAgb; + private bool _firmRegisteredSuccessfully; private readonly AuthModel _loginModel = new(); private readonly AuthModel _registerModel = new(); + private readonly FirmModel _firmModel = new(); [SupplyParameterFromQuery(Name = "error")] public string? ErrorParam { get; set; } @@ -151,6 +314,20 @@ [SupplyParameterFromQuery(Name = "tab")] public string? TabParam { get; set; } + protected override async Task OnInitializedAsync() + { + try + { + _tenantInfo = await Http.GetFromJsonAsync("api/tenant/current"); + } + catch (Exception ex) + { + Console.WriteLine($"Fehler beim Abrufen der TenantInfo: {ex.Message}"); + // Fallback + _tenantInfo = new TenantInfo { IsTenant = false }; + } + } + protected override void OnParametersSet() { _error = ErrorParam switch @@ -168,6 +345,35 @@ _error = null; } + private string GetPageTitle() + { + if (_tenantInfo == null) return "Lade…"; + return _tenantInfo.IsTenant ? $"Anmelden bei {_tenantInfo.Name} – Timetracker" : "Anmelden – Timetracker SaaS"; + } + + private string GetDomainSuffix() + { + var uri = new Uri(Nav.BaseUri); + var host = uri.Host; + return $".{host}"; + } + + private string GetTenantUrl() + { + var uri = new Uri(Nav.BaseUri); + var scheme = uri.Scheme; + var port = uri.Port == 80 || uri.Port == 443 ? "" : $":{uri.Port}"; + var host = uri.Host; + return $"{scheme}://{_firmModel.Subdomain.ToLower().Trim()}.{host}{port}"; + } + + private void ResetRegistrationFlow() + { + _firmRegisteredSuccessfully = false; + _activeTab = 0; + _error = null; + } + private async Task HandleLogin() { _loading = true; @@ -177,7 +383,7 @@ var user = await AuthService.LoginAsync(_loginModel.Username, _loginModel.Password); if (user != null) { - Nav.NavigateTo("/", forceLoad: true); // forceLoad forces state update/re-render of the root app + Nav.NavigateTo("/", forceLoad: true); } else { @@ -226,9 +432,62 @@ } } + private async Task HandleRegisterFirm() + { + if (!_acceptAgb) + { + _error = "Du musst die AGB akzeptieren."; + return; + } + if (_firmModel.AdminPassword != _firmModel.ConfirmPassword) + { + _error = "Passwörter stimmen nicht überein."; + return; + } + + _loading = true; + _error = null; + try + { + var req = new RegisterFirmRequest( + _firmModel.CompanyName, + _firmModel.Subdomain, + _firmModel.AdminUsername, + _firmModel.AdminPassword + ); + + var (tenant, error) = await AuthService.RegisterFirmAsync(req); + if (tenant != null) + { + _firmRegisteredSuccessfully = true; + } + else + { + _error = error ?? "Registrierung der Firma fehlgeschlagen."; + } + } + catch (Exception ex) + { + _error = $"Registrierungs Fehler: {ex.Message}"; + } + finally + { + _loading = false; + } + } + private class AuthModel { public string Username { get; set; } = ""; public string Password { get; set; } = ""; } + + private class FirmModel + { + public string CompanyName { get; set; } = ""; + public string Subdomain { get; set; } = ""; + public string AdminUsername { get; set; } = ""; + public string AdminPassword { get; set; } = ""; + public string ConfirmPassword { get; set; } = ""; + } } diff --git a/timetracker.Client/Components/Pages/Month.razor b/timetracker.Client/Components/Pages/Month.razor index 007e1f6..c629625 100644 --- a/timetracker.Client/Components/Pages/Month.razor +++ b/timetracker.Client/Components/Pages/Month.razor @@ -19,29 +19,28 @@ else @* ── Monats-Header ── *@ - + + Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevMonth" /> - + @_deCulture.DateTimeFormat.GetMonthName(_month) @_year - + @_subLabel @if (!IsCurrentMonth) { - Heute } + Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextMonth" /> @@ -95,7 +94,7 @@ else @* ── Monatszusammenfassung ── *@ + Style="background: linear-gradient(135deg, color-mix(in srgb, var(--mud-palette-primary) 8%, transparent) 0%, color-mix(in srgb, var(--mud-palette-primary) 2%, transparent) 100%); border-left: 6px solid var(--mud-palette-primary);"> Monatszusammenfassung diff --git a/timetracker.Client/Components/Pages/Profile.razor b/timetracker.Client/Components/Pages/Profile.razor index fc5fd51..6e8c1fe 100644 --- a/timetracker.Client/Components/Pages/Profile.razor +++ b/timetracker.Client/Components/Pages/Profile.razor @@ -22,13 +22,12 @@ else @* ── Header ── *@ - + - + - Mein Profil - + Mein Profil + Persönliche Details verwalten und Account sichern diff --git a/timetracker.Client/Components/Pages/ResetPasswordDialog.razor b/timetracker.Client/Components/Pages/ResetPasswordDialog.razor new file mode 100644 index 0000000..c8326fd --- /dev/null +++ b/timetracker.Client/Components/Pages/ResetPasswordDialog.razor @@ -0,0 +1,29 @@ +@namespace timetracker.Client.Components.Pages +@inject ISnackbar Snackbar + + + + Geben Sie das neue Passwort für den Benutzer @Username ein. + + + + Abbrechen + Passwort zurücksetzen + + + +@code { + [CascadingParameter] IMudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public string Username { get; set; } = ""; + + private string _newPassword = ""; + + private void Submit() => MudDialog.Close(DialogResult.Ok(_newPassword)); + private void Cancel() => MudDialog.Cancel(); +} diff --git a/timetracker.Client/Components/Pages/Settings.razor b/timetracker.Client/Components/Pages/Settings.razor index be9ee39..85e7a4c 100644 --- a/timetracker.Client/Components/Pages/Settings.razor +++ b/timetracker.Client/Components/Pages/Settings.razor @@ -7,6 +7,7 @@ @inject AuthenticationStateProvider AuthStateProvider @inject IJSRuntime JSRuntime @inject NavigationManager Nav +@inject HttpClient Http Einstellungen – Timetracker @@ -22,13 +23,12 @@ else @* ── Header ── *@ - + - + - Einstellungen - + Einstellungen + Arbeitszeit, Arbeitstage und Urlaub konfigurieren @@ -217,6 +217,52 @@ else + @if (_isTenantAdmin) + { + @* ── Firmen-Einstellungen (Branding) ── *@ + + + + + + + Firmen-Einstellungen + + + + + + + + + + + + + + + Firma speichern + + + + + + } + @* ── Speichern-Button ── *@ @@ -467,6 +513,11 @@ else private AppSettings? _settings; private int _userId; + private bool _isTenantAdmin; + private TenantInfo? _tenantInfo; + private string _tenantName = ""; + private string _tenantPrimaryColor = "#0EA5E9"; + private string _tenantSecondaryColor = "#0EA5E9"; private DateTime? _flexStartDate { get => _settings?.FlexTimeStartDate?.ToDateTime(TimeOnly.MinValue); @@ -526,6 +577,25 @@ else _userId = int.Parse(claim.Value); _settings = await TrackerService.GetSettingsAsync(_userId); + _isTenantAdmin = authState.User.IsInRole("TenantAdmin") || authState.User.HasClaim("IsTenantAdmin", "true"); + if (_isTenantAdmin) + { + try + { + _tenantInfo = await Http.GetFromJsonAsync("api/tenant/current"); + if (_tenantInfo != null) + { + _tenantName = _tenantInfo.Name ?? ""; + _tenantPrimaryColor = _tenantInfo.PrimaryColor ?? "#0EA5E9"; + _tenantSecondaryColor = _tenantInfo.SecondaryColor ?? "#0EA5E9"; + } + } + catch (Exception ex) + { + Snackbar.Add($"Fehler beim Laden der Firmen-Einstellungen: {ex.Message}", Severity.Error); + } + } + var loadVacationsTask = LoadVacations(); var loadHolidaysTask = HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState); @@ -622,6 +692,35 @@ else } } + private async Task SaveTenantSettings() + { + if (string.IsNullOrWhiteSpace(_tenantName)) + { + Snackbar.Add("Bitte einen Firmennamen eingeben.", Severity.Warning); + return; + } + + var dto = new TenantSettingsDto + { + Name = _tenantName.Trim(), + PrimaryColor = _tenantPrimaryColor, + SecondaryColor = _tenantSecondaryColor + }; + + var response = await Http.PostAsJsonAsync("api/tracker/tenant/settings", dto); + if (response.IsSuccessStatusCode) + { + Snackbar.Add("Firmen-Einstellungen gespeichert. Lade Seite neu...", Severity.Success); + await Task.Delay(1000); + Nav.NavigateTo(Nav.Uri, forceLoad: true); + } + else + { + var err = await response.Content.ReadAsStringAsync(); + Snackbar.Add($"Fehler beim Speichern: {err}", Severity.Error); + } + } + private static string FormatHours(double hours) { var ts = TimeSpan.FromHours(hours); diff --git a/timetracker.Client/Components/Pages/Stats.razor b/timetracker.Client/Components/Pages/Stats.razor index 6a930c6..ca2801f 100644 --- a/timetracker.Client/Components/Pages/Stats.razor +++ b/timetracker.Client/Components/Pages/Stats.razor @@ -20,12 +20,12 @@ else @* ── Header ── *@ - + - + - Statistiken - Auswertung deiner Arbeitsleistung und Gleitzeitkonto + Statistiken + Auswertung deiner Arbeitsleistung und Gleitzeitkonto @@ -46,7 +46,7 @@ else - @displayPct% + @displayPct% Erreicht @@ -54,7 +54,7 @@ else Arbeitszeit (Ist): - @FormatHours(_weekWorkedHours) Std. + @FormatHours(_weekWorkedHours) Std. Wochensoll (Soll): @@ -68,7 +68,7 @@ else @* ── Card 2: Wochensaldo ── *@ - + Wochensaldo @@ -115,7 +115,7 @@ else - Arbeitszeitverteilung diese Woche + Arbeitszeitverteilung diese Woche Geleistete Nettoarbeitsstunden pro Wochentag @@ -148,8 +148,8 @@ else { double val = (_maxHoursValue / 4.0) * i; double yPos = plotBottom - (val / _maxHoursValue) * plotHeight; - - @((MarkupString)$"{val:F1}h") + + @((MarkupString)$"{val:F1}h") } @@ -163,7 +163,7 @@ else string fillCol = "#94A3B8"; // Default light slate if (d.IsToday) { - fillCol = "#0EA5E9"; // Sky Blue for today + fillCol = "var(--mud-palette-secondary)"; // Brand color for today } else if (d.WorkedHours >= d.TargetHours && d.TargetHours > 0) { @@ -183,7 +183,7 @@ else } - @d.DayName: @FormatHours(d.WorkedHours) Std. (Soll: @FormatHours(d.TargetHours) Std.) @@ -191,19 +191,19 @@ else @if (d.WorkedHours > 0) { - @((MarkupString)$"{FormatHours(d.WorkedHours)}") + @((MarkupString)$"{FormatHours(d.WorkedHours)}") } - @((MarkupString)$"{d.DayShortName}") + @((MarkupString)$"{d.DayShortName}") } @if (_settings.DailyTargetHours > 0) { double yTarget = plotBottom - (_settings.DailyTargetHours / _maxHoursValue) * plotHeight; - - @((MarkupString)$"Soll ({_settings.DailyTargetHours} h)") + + @((MarkupString)$"Soll ({_settings.DailyTargetHours} h)") } @@ -269,6 +269,7 @@ else private Dictionary _dbMatchStatus = new(); private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE"); + private static string F(double value) => value.ToString("F2", System.Globalization.CultureInfo.InvariantCulture); protected override async Task OnInitializedAsync() { diff --git a/timetracker.Client/Components/Pages/SuperAdmin.razor b/timetracker.Client/Components/Pages/SuperAdmin.razor new file mode 100644 index 0000000..3109c7c --- /dev/null +++ b/timetracker.Client/Components/Pages/SuperAdmin.razor @@ -0,0 +1,267 @@ +@page "/superadmin" +@rendermode InteractiveWebAssembly +@attribute [Authorize(Policy = "AdminOnly")] +@using System.Net.Http.Json +@inject HttpClient Http +@inject ISnackbar Snackbar +@inject IDialogService DialogService + +Firmenverwaltung – Timetracker SaaS + +@if (_loading) +{ + + + Lade Firmen und Mandanten… + +} +else +{ + + + @* ── Header Card ── *@ + + + + + SuperAdmin Dashboard + + @_tenants.Count registrierte Firmen im System + + + + + + @* ── Tenants Table ── *@ + + + + + + ID + Name + Subdomain + Erstellt am + Benutzer + Arbeitstage + Status + Aktionen + + + + + + + @context.Id + + + @context.Name + + + + @context.Subdomain + + + + @context.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm") + + + + + + @context.WorkDaysCount + + + @if (context.IsApproved) + { + Aktiv + } + else + { + Ausstehend + } + + + @if (!context.IsApproved) + { + + Freigeben + + } + else + { + + Sperren + + } + + + + + @if (context.ShowDetails) + { + + + + + Benutzer für Firma: @context.Name + + @if (context.Users == null || context.Users.Count == 0) + { + Keine Benutzer in dieser Firma registriert. + } + else + { + + @foreach (var user in context.Users) + { + + + + + @user.Username.Substring(0, Math.Min(2, user.Username.Length)).ToUpper() + + + @user.Username + ID: @user.Id + + + @if (user.IsTenantAdmin) + { + Admin + } + else + { + Mitarbeiter + } + + + } + + } + + + + } + + + + + +} + +@code { + private bool _loading = true; + private List _tenants = []; + + protected override async Task OnInitializedAsync() + { + await LoadTenants(); + } + + private async Task LoadTenants() + { + _loading = true; + try + { + _tenants = await Http.GetFromJsonAsync>("api/superadmin/tenants") ?? []; + } + catch (Exception ex) + { + Snackbar.Add($"Fehler beim Laden der Firmen: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + } + } + + private async Task ApproveTenant(TenantStats tenant) + { + try + { + var response = await Http.PostAsync($"api/superadmin/tenants/{tenant.Id}/approve", null); + if (response.IsSuccessStatusCode) + { + tenant.IsApproved = true; + Snackbar.Add($"Firma '{tenant.Name}' wurde erfolgreich freigegeben.", Severity.Success); + } + else + { + Snackbar.Add("Fehler bei der Freigabe.", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Fehler: {ex.Message}", Severity.Error); + } + } + + private async Task ToggleActiveTenant(TenantStats tenant) + { + try + { + var response = await Http.PostAsync($"api/superadmin/tenants/{tenant.Id}/toggle-active", null); + if (response.IsSuccessStatusCode) + { + tenant.IsApproved = !tenant.IsApproved; + var stateMsg = tenant.IsApproved ? "aktiviert" : "gesperrt"; + Snackbar.Add($"Firma '{tenant.Name}' wurde {stateMsg}.", Severity.Info); + } + else + { + Snackbar.Add("Fehler beim Ändern des Status.", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Fehler: {ex.Message}", Severity.Error); + } + } + + private async Task DeleteTenant(TenantStats tenant) + { + bool? result = await DialogService.ShowMessageBoxAsync( + "Firma unwiderruflich löschen?", + $"Sind Sie sicher, dass Sie die Firma '{tenant.Name}' ({tenant.Subdomain}) inklusive ALLER zugehörigen Benutzer, Einstellungen und Zeiterfassungen unwiderruflich löschen möchten? Dieser Vorgang kann nicht rückgängig gemacht werden!", + yesText: "Löschen", cancelText: "Abbrechen"); + + if (result == true) + { + try + { + var response = await Http.DeleteAsync($"api/superadmin/tenants/{tenant.Id}"); + if (response.IsSuccessStatusCode) + { + _tenants.Remove(tenant); + Snackbar.Add($"Firma '{tenant.Name}' wurde gelöscht.", Severity.Success); + } + else + { + var err = await response.Content.ReadAsStringAsync(); + Snackbar.Add($"Fehler beim Löschen: {err}", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Fehler: {ex.Message}", Severity.Error); + } + } + } +} diff --git a/timetracker.Client/Services/ClientAuthService.cs b/timetracker.Client/Services/ClientAuthService.cs index 566e091..4f60f84 100644 --- a/timetracker.Client/Services/ClientAuthService.cs +++ b/timetracker.Client/Services/ClientAuthService.cs @@ -81,4 +81,38 @@ public class ClientAuthService : IAuthService } return null; } + + public async Task<(Tenant? Tenant, string? Error)> RegisterFirmAsync(RegisterFirmRequest req) + { + var response = await _http.PostAsJsonAsync("api/auth/register-firm", req); + if (response.IsSuccessStatusCode) + { + return (new Tenant { Name = req.CompanyName, Subdomain = req.Subdomain }, null); + } + else + { + var error = await response.Content.ReadAsStringAsync(); + return (null, error); + } + } + + public async Task AssignTenantAsync(int userId, int? tenantId) + { + var response = await _http.PutAsJsonAsync($"api/users/{userId}/assign-tenant", new AssignTenantRequest(tenantId)); + if (!response.IsSuccessStatusCode) + { + return await response.Content.ReadAsStringAsync(); + } + return null; + } + + public async Task ResetPasswordAsync(int userId, string newPassword) + { + var response = await _http.PutAsJsonAsync($"api/users/{userId}/reset-password", new ResetPasswordRequest(newPassword)); + if (!response.IsSuccessStatusCode) + { + return await response.Content.ReadAsStringAsync(); + } + return null; + } } diff --git a/timetracker.Client/Services/HostAuthenticationStateProvider.cs b/timetracker.Client/Services/HostAuthenticationStateProvider.cs index 655f30a..7899a3a 100644 --- a/timetracker.Client/Services/HostAuthenticationStateProvider.cs +++ b/timetracker.Client/Services/HostAuthenticationStateProvider.cs @@ -34,7 +34,9 @@ public class HostAuthenticationStateProvider : AuthenticationStateProvider var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()), - new Claim(ClaimTypes.Name, userInfo.Username) + new Claim(ClaimTypes.Name, userInfo.Username), + new Claim("IsTenantAdmin", userInfo.IsTenantAdmin ? "true" : "false"), + new Claim(ClaimTypes.Role, userInfo.IsTenantAdmin ? "TenantAdmin" : "Employee") }; var identity = new ClaimsIdentity(claims, "Cookie"); _currentUser = new ClaimsPrincipal(identity); @@ -58,7 +60,9 @@ public class HostAuthenticationStateProvider : AuthenticationStateProvider var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()), - new Claim(ClaimTypes.Name, userInfo.Username) + new Claim(ClaimTypes.Name, userInfo.Username), + new Claim("IsTenantAdmin", userInfo.IsTenantAdmin ? "true" : "false"), + new Claim(ClaimTypes.Role, userInfo.IsTenantAdmin ? "TenantAdmin" : "Employee") }; var identity = new ClaimsIdentity(claims, "Cookie"); _currentUser = new ClaimsPrincipal(identity); diff --git a/timetracker.Server/Data/AuthService.cs b/timetracker.Server/Data/AuthService.cs index 45b4326..34d4688 100644 --- a/timetracker.Server/Data/AuthService.cs +++ b/timetracker.Server/Data/AuthService.cs @@ -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 factory, UserNotificationService notifier) : IAuthService +public class AuthService( + IDbContextFactory factory, + UserNotificationService notifier, + ITenantProvider tenantProvider) : IAuthService { public async Task 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 factory, UserNo public async Task> 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 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 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 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 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 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 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); diff --git a/timetracker.Server/Data/ITenantProvider.cs b/timetracker.Server/Data/ITenantProvider.cs new file mode 100644 index 0000000..2f443cc --- /dev/null +++ b/timetracker.Server/Data/ITenantProvider.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using timetracker.Shared; + +namespace timetracker.Data; + +public interface ITenantProvider +{ + int? TenantId { get; } + Task GetCurrentTenantAsync(); +} diff --git a/timetracker.Server/Data/Migrations/20260624204954_AddMultiTenancy.Designer.cs b/timetracker.Server/Data/Migrations/20260624204954_AddMultiTenancy.Designer.cs new file mode 100644 index 0000000..0470bf3 --- /dev/null +++ b/timetracker.Server/Data/Migrations/20260624204954_AddMultiTenancy.Designer.cs @@ -0,0 +1,254 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DailyTargetHours") + .HasColumnType("REAL"); + + b.Property("FlexTimeStartDate") + .HasColumnType("TEXT"); + + b.Property("FlexTimeStartingBalanceHours") + .HasColumnType("REAL"); + + b.Property("GermanState") + .HasColumnType("TEXT"); + + b.Property("MinimumBreakMinutes") + .HasColumnType("INTEGER"); + + b.Property("TenantId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("VacationDaysPerYear") + .HasColumnType("INTEGER"); + + b.Property("WorkFriday") + .HasColumnType("INTEGER"); + + b.Property("WorkMonday") + .HasColumnType("INTEGER"); + + b.Property("WorkSaturday") + .HasColumnType("INTEGER"); + + b.Property("WorkSunday") + .HasColumnType("INTEGER"); + + b.Property("WorkThursday") + .HasColumnType("INTEGER"); + + b.Property("WorkTuesday") + .HasColumnType("INTEGER"); + + b.Property("WorkWednesday") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("timetracker.Shared.BreakEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("WorkDayId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WorkDayId"); + + b.ToTable("BreakEntries"); + }); + + modelBuilder.Entity("timetracker.Shared.PublicHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Counties") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PublicHolidays"); + }); + + modelBuilder.Entity("timetracker.Shared.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsApproved") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Subdomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Subdomain") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("timetracker.Shared.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("timetracker.Shared.VacationDay", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("VacationDays"); + }); + + modelBuilder.Entity("timetracker.Shared.WorkDay", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("INTEGER"); + + b.Property("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 + } + } +} diff --git a/timetracker.Server/Data/Migrations/20260624204954_AddMultiTenancy.cs b/timetracker.Server/Data/Migrations/20260624204954_AddMultiTenancy.cs new file mode 100644 index 0000000..34890e2 --- /dev/null +++ b/timetracker.Server/Data/Migrations/20260624204954_AddMultiTenancy.cs @@ -0,0 +1,104 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace timetracker.Data.Migrations +{ + /// + public partial class AddMultiTenancy : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TenantId", + table: "WorkDays", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "TenantId", + table: "VacationDays", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "TenantId", + table: "Users", + type: "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + name: "TenantId", + table: "AppSettings", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateTable( + name: "Tenants", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false), + Subdomain = table.Column(type: "TEXT", nullable: false), + IsApproved = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(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"); + } + + /// + 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"); + } + } +} diff --git a/timetracker.Server/Data/Migrations/20260624210436_AddTenantBranding.Designer.cs b/timetracker.Server/Data/Migrations/20260624210436_AddTenantBranding.Designer.cs new file mode 100644 index 0000000..5a70d9e --- /dev/null +++ b/timetracker.Server/Data/Migrations/20260624210436_AddTenantBranding.Designer.cs @@ -0,0 +1,265 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DailyTargetHours") + .HasColumnType("REAL"); + + b.Property("FlexTimeStartDate") + .HasColumnType("TEXT"); + + b.Property("FlexTimeStartingBalanceHours") + .HasColumnType("REAL"); + + b.Property("GermanState") + .HasColumnType("TEXT"); + + b.Property("MinimumBreakMinutes") + .HasColumnType("INTEGER"); + + b.Property("TenantId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("VacationDaysPerYear") + .HasColumnType("INTEGER"); + + b.Property("WorkFriday") + .HasColumnType("INTEGER"); + + b.Property("WorkMonday") + .HasColumnType("INTEGER"); + + b.Property("WorkSaturday") + .HasColumnType("INTEGER"); + + b.Property("WorkSunday") + .HasColumnType("INTEGER"); + + b.Property("WorkThursday") + .HasColumnType("INTEGER"); + + b.Property("WorkTuesday") + .HasColumnType("INTEGER"); + + b.Property("WorkWednesday") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("timetracker.Shared.BreakEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("WorkDayId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("WorkDayId"); + + b.ToTable("BreakEntries"); + }); + + modelBuilder.Entity("timetracker.Shared.PublicHoliday", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Counties") + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PublicHolidays"); + }); + + modelBuilder.Entity("timetracker.Shared.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsApproved") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Subdomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Subdomain") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("timetracker.Shared.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsTenantAdmin") + .HasColumnType("INTEGER"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("timetracker.Shared.VacationDay", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("VacationDays"); + }); + + modelBuilder.Entity("timetracker.Shared.WorkDay", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("TenantId") + .HasColumnType("INTEGER"); + + b.Property("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 + } + } +} diff --git a/timetracker.Server/Data/Migrations/20260624210436_AddTenantBranding.cs b/timetracker.Server/Data/Migrations/20260624210436_AddTenantBranding.cs new file mode 100644 index 0000000..897b5eb --- /dev/null +++ b/timetracker.Server/Data/Migrations/20260624210436_AddTenantBranding.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace timetracker.Data.Migrations +{ + /// + public partial class AddTenantBranding : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsTenantAdmin", + table: "Users", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "PrimaryColor", + table: "Tenants", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "SecondaryColor", + table: "Tenants", + type: "TEXT", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsTenantAdmin", + table: "Users"); + + migrationBuilder.DropColumn( + name: "PrimaryColor", + table: "Tenants"); + + migrationBuilder.DropColumn( + name: "SecondaryColor", + table: "Tenants"); + } + } +} diff --git a/timetracker.Server/Data/Migrations/TimetrackerDbContextModelSnapshot.cs b/timetracker.Server/Data/Migrations/TimetrackerDbContextModelSnapshot.cs index 7570773..6380088 100644 --- a/timetracker.Server/Data/Migrations/TimetrackerDbContextModelSnapshot.cs +++ b/timetracker.Server/Data/Migrations/TimetrackerDbContextModelSnapshot.cs @@ -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("Id") .ValueGeneratedOnAdd() @@ -38,6 +38,9 @@ namespace timetracker.Data.Migrations b.Property("MinimumBreakMinutes") .HasColumnType("INTEGER"); + b.Property("TenantId") + .HasColumnType("INTEGER"); + b.Property("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("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("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("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsApproved") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Subdomain") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Subdomain") + .IsUnique(); + + b.ToTable("Tenants"); + }); + + modelBuilder.Entity("timetracker.Shared.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsTenantAdmin") + .HasColumnType("INTEGER"); + b.Property("PasswordHash") .IsRequired() .HasColumnType("TEXT"); @@ -127,16 +169,21 @@ namespace timetracker.Data.Migrations .IsRequired() .HasColumnType("TEXT"); + b.Property("TenantId") + .HasColumnType("INTEGER"); + b.Property("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("Id") .ValueGeneratedOnAdd() @@ -148,6 +195,9 @@ namespace timetracker.Data.Migrations b.Property("Note") .HasColumnType("TEXT"); + b.Property("TenantId") + .HasColumnType("INTEGER"); + b.Property("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("Id") .ValueGeneratedOnAdd() @@ -171,6 +221,9 @@ namespace timetracker.Data.Migrations b.Property("StartTime") .HasColumnType("TEXT"); + b.Property("TenantId") + .HasColumnType("INTEGER"); + b.Property("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"); }); diff --git a/timetracker.Server/Data/TenantProvider.cs b/timetracker.Server/Data/TenantProvider.cs new file mode 100644 index 0000000..bd93216 --- /dev/null +++ b/timetracker.Server/Data/TenantProvider.cs @@ -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 _dbContextFactory; + private readonly IConfiguration _configuration; + + public TenantProvider( + IHttpContextAccessor httpContextAccessor, + IDbContextFactory 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 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; + } +} diff --git a/timetracker.Server/Data/TimetrackerDbContext.cs b/timetracker.Server/Data/TimetrackerDbContext.cs index 1d23e64..d8d22a5 100644 --- a/timetracker.Server/Data/TimetrackerDbContext.cs +++ b/timetracker.Server/Data/TimetrackerDbContext.cs @@ -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 options) : DbContext(options) +public class TimetrackerDbContext : DbContext { + private readonly IServiceProvider _serviceProvider; + private ITenantProvider? _tenantProvider; + private ITenantProvider TenantProvider => _tenantProvider ??= _serviceProvider.GetRequiredService(); + + public TimetrackerDbContext( + DbContextOptions options, + IServiceProvider serviceProvider) : base(options) + { + _serviceProvider = serviceProvider; + } + + public DbSet Tenants => Set(); public DbSet Users => Set(); public DbSet WorkDays => Set(); public DbSet BreakEntries => Set(); public DbSet AppSettings => Set(); public DbSet VacationDays => Set(); public DbSet PublicHolidays => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Subdomain index unique + modelBuilder.Entity() + .HasIndex(t => t.Subdomain) + .IsUnique(); + + // Global Query Filters (scopes database views by resolved TenantId) + modelBuilder.Entity().HasQueryFilter(u => u.TenantId == TenantProvider.TenantId); + modelBuilder.Entity().HasQueryFilter(w => w.TenantId == TenantProvider.TenantId); + modelBuilder.Entity().HasQueryFilter(v => v.TenantId == TenantProvider.TenantId); + modelBuilder.Entity().HasQueryFilter(s => s.TenantId == TenantProvider.TenantId); + } + + public override Task 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); + } } diff --git a/timetracker.Server/Program.cs b/timetracker.Server/Program.cs index 14b731e..6506ab2 100644 --- a/timetracker.Server/Program.cs +++ b/timetracker.Server/Program.cs @@ -47,6 +47,8 @@ builder.Services.AddAuthorization(options => builder.Services.AddCascadingAuthenticationState(); builder.Services.AddHttpContextAccessor(); +builder.Services.AddTransient(); + // Add SignalR builder.Services.AddSignalR(); builder.Services.AddSingleton(); @@ -63,6 +65,31 @@ builder.Services.AddRazorComponents() builder.Services.AddMudServices(); builder.Services.AddHttpClient(); +builder.Services.AddScoped(sp => +{ + try + { + var navigationManager = sp.GetService(); + if (navigationManager != null && !string.IsNullOrEmpty(navigationManager.BaseUri)) + { + return new System.Net.Http.HttpClient { BaseAddress = new Uri(navigationManager.BaseUri) }; + } + } + catch (InvalidOperationException) + { + // NavigationManager is registered but not initialized in this request scope + } + + var httpContextAccessor = sp.GetService(); + var request = httpContextAccessor?.HttpContext?.Request; + if (request != null) + { + var baseUri = $"{request.Scheme}://{request.Host}{request.PathBase}/"; + return new System.Net.Http.HttpClient { BaseAddress = new Uri(baseUri) }; + } + + return new System.Net.Http.HttpClient(); +}); builder.Services.AddRateLimiter(options => { @@ -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 { 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 { 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(); + 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 ────────────────────────────────────────────────── diff --git a/timetracker.Shared/AppSettings.cs b/timetracker.Shared/AppSettings.cs index a7a8ad0..3cddb46 100644 --- a/timetracker.Shared/AppSettings.cs +++ b/timetracker.Shared/AppSettings.cs @@ -4,6 +4,7 @@ public class AppSettings { public int Id { get; set; } public int UserId { get; set; } + public int? TenantId { get; set; } public double DailyTargetHours { get; set; } = 7.5; public int MinimumBreakMinutes { get; set; } = 30; public int VacationDaysPerYear { get; set; } = 30; diff --git a/timetracker.Shared/AssignTenantRequest.cs b/timetracker.Shared/AssignTenantRequest.cs new file mode 100644 index 0000000..1f24248 --- /dev/null +++ b/timetracker.Shared/AssignTenantRequest.cs @@ -0,0 +1,3 @@ +namespace timetracker.Shared; + +public record AssignTenantRequest(int? TenantId); diff --git a/timetracker.Shared/IAuthService.cs b/timetracker.Shared/IAuthService.cs index 72f7132..62382b6 100644 --- a/timetracker.Shared/IAuthService.cs +++ b/timetracker.Shared/IAuthService.cs @@ -8,4 +8,7 @@ public interface IAuthService Task RenameUserAsync(int userId, string newUsername); Task ChangePasswordAsync(int userId, string currentPassword, string newPassword); Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null); + Task<(Tenant? Tenant, string? Error)> RegisterFirmAsync(RegisterFirmRequest req); + Task AssignTenantAsync(int userId, int? tenantId); + Task ResetPasswordAsync(int userId, string newPassword); } diff --git a/timetracker.Shared/RegisterFirmRequest.cs b/timetracker.Shared/RegisterFirmRequest.cs new file mode 100644 index 0000000..ad37205 --- /dev/null +++ b/timetracker.Shared/RegisterFirmRequest.cs @@ -0,0 +1,8 @@ +namespace timetracker.Shared; + +public record RegisterFirmRequest( + string CompanyName, + string Subdomain, + string AdminUsername, + string AdminPassword +); diff --git a/timetracker.Shared/ResetPasswordRequest.cs b/timetracker.Shared/ResetPasswordRequest.cs new file mode 100644 index 0000000..be66e6f --- /dev/null +++ b/timetracker.Shared/ResetPasswordRequest.cs @@ -0,0 +1,3 @@ +namespace timetracker.Shared; + +public record ResetPasswordRequest(string NewPassword); diff --git a/timetracker.Shared/Tenant.cs b/timetracker.Shared/Tenant.cs new file mode 100644 index 0000000..ce363b5 --- /dev/null +++ b/timetracker.Shared/Tenant.cs @@ -0,0 +1,14 @@ +using System; + +namespace timetracker.Shared; + +public class Tenant +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Subdomain { get; set; } = ""; + public bool IsApproved { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public string PrimaryColor { get; set; } = "#0EA5E9"; + public string SecondaryColor { get; set; } = "#0EA5E9"; +} diff --git a/timetracker.Shared/TenantInfo.cs b/timetracker.Shared/TenantInfo.cs new file mode 100644 index 0000000..e32023f --- /dev/null +++ b/timetracker.Shared/TenantInfo.cs @@ -0,0 +1,11 @@ +namespace timetracker.Shared; + +public class TenantInfo +{ + public bool IsTenant { get; set; } + public string? Name { get; set; } + public string? Subdomain { get; set; } + public bool IsApproved { get; set; } + public string? PrimaryColor { get; set; } + public string? SecondaryColor { get; set; } +} diff --git a/timetracker.Shared/TenantSettingsDto.cs b/timetracker.Shared/TenantSettingsDto.cs new file mode 100644 index 0000000..5297259 --- /dev/null +++ b/timetracker.Shared/TenantSettingsDto.cs @@ -0,0 +1,8 @@ +namespace timetracker.Shared; + +public class TenantSettingsDto +{ + public string Name { get; set; } = ""; + public string PrimaryColor { get; set; } = "#0EA5E9"; + public string SecondaryColor { get; set; } = "#0EA5E9"; +} diff --git a/timetracker.Shared/TenantStats.cs b/timetracker.Shared/TenantStats.cs new file mode 100644 index 0000000..6bbd4c5 --- /dev/null +++ b/timetracker.Shared/TenantStats.cs @@ -0,0 +1,23 @@ +using System; + +namespace timetracker.Shared; + +public class TenantStats +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Subdomain { get; set; } = ""; + public bool IsApproved { get; set; } + public DateTime CreatedAt { get; set; } + public int UserCount { get; set; } + public int WorkDaysCount { get; set; } + public List Users { get; set; } = new(); + public bool ShowDetails { get; set; } +} + +public class TenantUserDto +{ + public int Id { get; set; } + public string Username { get; set; } = ""; + public bool IsTenantAdmin { get; set; } +} diff --git a/timetracker.Shared/User.cs b/timetracker.Shared/User.cs index a339b3f..6b614c0 100644 --- a/timetracker.Shared/User.cs +++ b/timetracker.Shared/User.cs @@ -6,4 +6,8 @@ public class User public string Username { get; set; } = ""; public string PasswordHash { get; set; } = ""; public string PasswordSalt { get; set; } = ""; + + public int? TenantId { get; set; } + public Tenant? Tenant { get; set; } + public bool IsTenantAdmin { get; set; } } diff --git a/timetracker.Shared/UserInfo.cs b/timetracker.Shared/UserInfo.cs index 67ed616..a8792bc 100644 --- a/timetracker.Shared/UserInfo.cs +++ b/timetracker.Shared/UserInfo.cs @@ -4,4 +4,5 @@ public class UserInfo { public int Id { get; set; } public string Username { get; set; } = ""; + public bool IsTenantAdmin { get; set; } } diff --git a/timetracker.Shared/VacationDay.cs b/timetracker.Shared/VacationDay.cs index 781d256..cfced56 100644 --- a/timetracker.Shared/VacationDay.cs +++ b/timetracker.Shared/VacationDay.cs @@ -4,6 +4,7 @@ public class VacationDay { public int Id { get; set; } public int UserId { get; set; } + public int? TenantId { get; set; } public DateOnly Date { get; set; } public string? Note { get; set; } } diff --git a/timetracker.Shared/WorkDay.cs b/timetracker.Shared/WorkDay.cs index 1330ffc..93751e7 100644 --- a/timetracker.Shared/WorkDay.cs +++ b/timetracker.Shared/WorkDay.cs @@ -4,6 +4,7 @@ public class WorkDay { public int Id { get; set; } public int UserId { get; set; } + public int? TenantId { get; set; } public DateOnly Date { get; set; } public TimeOnly? StartTime { get; set; } public TimeOnly? EndTime { get; set; } diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..79d09ce --- /dev/null +++ b/todo.md @@ -0,0 +1,4 @@ +# TODOs für morgen + +- [ ] **Single-User-Variante bauen:** Eine Möglichkeit implementieren, sich einfach und unabhängig von einem bestimmten Tenant zu registrieren. +- [ ] **Pricing-Modell konzipieren:** Gedanken über ein geeignetes Preismodell machen (z. B. SaaS-Preise, Mandanten-Preismodell).