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..de9ac32 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 @@ -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) { @@ -136,4 +159,12 @@ return parts[0][..Math.Min(2, parts[0].Length)].ToUpper(); return $"{parts[0][0]}{parts[1][0]}".ToUpper(); } + + private string GetUserStatus(string? username) + { + if (string.IsNullOrWhiteSpace(username)) return ""; + if (username.Equals("marc", StringComparison.OrdinalIgnoreCase)) return "SuperAdmin"; + if (_companyName == "Hauptdomain") return "Einzelnutzer"; + return _companyName; + } } \ No newline at end of file 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..3033be3 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 + { + + @(context.Username == "marc" ? "SuperAdmin" : "Einzelnutzer") + + } + } + @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..6a278e1 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 @@ -66,6 +65,9 @@ new("Neu", "Userprofil angelegt"), new("Upgrade", "Früheste Gehenszeit wird angezeigt"), new("Upgrade", "Pausen werden dynamisch berechnet"), + new("Neu", "Excel-Export"), + new("Neu", "Multi-Tenant Integration"), + new("Upgrade", "Verbesserte Single-User Ansicht") ], Highlighted: true), new("1.4", "08.06.2026", true, [ diff --git a/timetracker.Client/Components/Pages/Home.razor b/timetracker.Client/Components/Pages/Home.razor index 5dd8e16..eb7cb23 100644 --- a/timetracker.Client/Components/Pages/Home.razor +++ b/timetracker.Client/Components/Pages/Home.razor @@ -6,6 +6,7 @@ @inject ISnackbar Snackbar @inject AuthenticationStateProvider AuthStateProvider @inject IJSRuntime JSRuntime +@inject NavigationManager NavigationManager KW @_kw – Wochenübersicht – Timetracker @@ -21,29 +22,32 @@ 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 +320,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) @@ -492,6 +496,10 @@ else private async Task PrevWeek() { _monday = _monday.AddDays(-7); await LoadWeek(); } private async Task NextWeek() { _monday = _monday.AddDays(7); await LoadWeek(); } private async Task GoToCurrentWeek() { _monday = GetMonday(DateOnly.FromDateTime(DateTime.Today)); await LoadWeek(); } + private void ExportWeekExcel() + { + NavigationManager.NavigateTo($"api/tracker/export/week?monday={_monday:yyyy-MM-dd}", forceLoad: true); + } private static DateOnly GetMonday(DateOnly date) { diff --git a/timetracker.Client/Components/Pages/Login.razor b/timetracker.Client/Components/Pages/Login.razor index 9ee1838..5ab214b 100644 --- a/timetracker.Client/Components/Pages/Login.razor +++ b/timetracker.Client/Components/Pages/Login.razor @@ -1,149 +1,395 @@ @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) + +} +else +{ + @* Main login/registration container *@ + + + + @* ── 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 { + @* ── Header / Navigation ── *@ + @if (_activeTab == 2) { - @_error + @* ── Back Button for Firm Registration ── *@ + + + Firma registrieren + + } - - - - - + - @if (_loading) - { - - } + Style="min-width: 120px; border-radius: 20px;"> Anmelden - - -
- } - else - { - @* ── Register Form ── *@ - - @if (_error != null) - { - @_error - } - - - - - - - - Ich akzeptiere die AGB. - - - - @if (_loading) - { - - } - Konto erstellen + + Registrieren - - - } -
-
-
+ + } + + @if (_activeTab == 0) + { + @* ── Login Form ── *@ + + @if (_error != null) + { + @_error + } + + + + + + @if (_loading) + { + + } + Anmelden + + + + + } + else if (_activeTab == 1) + { + @if (_tenantInfo.IsTenant) + { + @* ── Employee Register Form ── *@ + + @if (_error != null) + { + @_error + } + + + + + + + + Ich akzeptiere die AGB. + + + + @if (_loading) + { + + } + Konto erstellen + + + + + } + else + { + @* ── Single User Register Form ── *@ + + @if (_error != null) + { + @_error + } + + + + + + + + + Ich akzeptiere die AGB. + + + + @if (_loading) + { + + } + Konto erstellen + + + + + } + } + else if (_activeTab == 2) + { + @* ── Firm Register Form ── *@ + + @if (_error != null) + { + @_error + } + + + + + + + + + Administrator-Konto + + + + + + + + + Ich akzeptiere die AGB. + + + + @if (_loading) + { + + } + Firma registrieren + + + + + } + + @if (!_tenantInfo.IsTenant && _activeTab != 2) + { + + + + Sie möchten Timetracker für Ihr Unternehmen nutzen? + + + Eigene 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 string _confirmPassword = ""; 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 +397,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 +428,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 +466,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 +515,100 @@ } } + private async Task HandleSingleUserRegister() + { + if (!_acceptAgb) + { + _error = "Du musst die AGB akzeptieren."; + return; + } + if (_registerModel.Password != _confirmPassword) + { + _error = "Passwörter stimmen nicht überein."; + return; + } + + _loading = true; + _error = null; + try + { + var (user, error) = await AuthService.RegisterAsync(_registerModel.Username, _registerModel.Password, _honeypot); + if (user != null) + { + await JSRuntime.InvokeVoidAsync("localStorage.setItem", "showOnboarding", "true"); + Nav.NavigateTo("/", forceLoad: true); + } + else + { + _error = error ?? "Registrierung fehlgeschlagen."; + } + } + catch (Exception ex) + { + _error = $"Registrierungs Fehler: {ex.Message}"; + } + finally + { + _loading = false; + } + } + + 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..193750e 100644 --- a/timetracker.Client/Components/Pages/Month.razor +++ b/timetracker.Client/Components/Pages/Month.razor @@ -4,6 +4,7 @@ @inject ITimetrackerService TrackerService @inject IHolidayService HolidayService @inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager NavigationManager @_deCulture.DateTimeFormat.GetMonthName(_month) @_year – Monatsübersicht – Timetracker @@ -19,29 +20,32 @@ 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 +99,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 @@ -269,6 +273,11 @@ else await LoadMonth(); } + private void ExportMonthExcel() + { + NavigationManager.NavigateTo($"api/tracker/export/month?year={_year}&month={_month}", forceLoad: true); + } + private static string FormatTs(TimeSpan ts, bool sign = false) { var neg = ts < TimeSpan.Zero; 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..0136ecb 100644 --- a/timetracker.Client/Components/Pages/Stats.razor +++ b/timetracker.Client/Components/Pages/Stats.razor @@ -3,6 +3,7 @@ @attribute [Authorize] @inject ITimetrackerService TrackerService @inject AuthenticationStateProvider AuthStateProvider +@inject NavigationManager NavigationManager @using System.Security.Claims @using timetracker.Shared @@ -20,12 +21,12 @@ else @* ── Header ── *@ - + - + - Statistiken - Auswertung deiner Arbeitsleistung und Gleitzeitkonto + Statistiken + Auswertung deiner Arbeitsleistung und Gleitzeitkonto @@ -46,7 +47,7 @@ else
- @displayPct% + @displayPct% Erreicht
@@ -54,7 +55,7 @@ else Arbeitszeit (Ist): - @FormatHours(_weekWorkedHours) Std. + @FormatHours(_weekWorkedHours) Std. Wochensoll (Soll): @@ -68,7 +69,7 @@ else @* ── Card 2: Wochensaldo ── *@ - + Wochensaldo @@ -115,7 +116,7 @@ else - Arbeitszeitverteilung diese Woche + Arbeitszeitverteilung diese Woche Geleistete Nettoarbeitsstunden pro Wochentag @@ -148,8 +149,8 @@ else { double val = (_maxHoursValue / 4.0) * i; double yPos = plotBottom - (val / _maxHoursValue) * plotHeight; - - @((MarkupString)$"{val:F1}h") + + @((MarkupString)$"{val:F1}h") } @@ -163,7 +164,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 +184,7 @@ else } - @d.DayName: @FormatHours(d.WorkedHours) Std. (Soll: @FormatHours(d.TargetHours) Std.) @@ -191,25 +192,94 @@ 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)") } + + @* ── Excel Export ── *@ + + + + + Excel-Datenexport + Lade deine erfassten Arbeitszeiten in verschiedenen Zeiträumen als formatierte Excel-Datei herunter. + + + + + @* Wochenexport *@ + + + Wochenexport + + + Woche exportieren + + + + + @* Monatsexport *@ + + + Monatsexport + + + @for (int m = 1; m <= 12; m++) + { + var monthVal = m; + @_deCulture.DateTimeFormat.GetMonthName(monthVal) + } + + + @for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++) + { + var yearVal = y; + @yearVal + } + + + + Monat exportieren + + + + + @* Jahresexport *@ + + + Jahresexport + + @for (int y = DateTime.Today.Year - 5; y <= DateTime.Today.Year + 1; y++) + { + var yearVal = y; + @yearVal + } + + + Jahr exportieren + + + + + + + @* ── Diagnostics Panel ── *@ @@ -263,12 +333,19 @@ else private double _weekTargetHours; private double _weekOvertimeHours; private double _maxHoursValue = 10.0; + + // Excel Export variables + private DateTime? _exportWeekDate = DateTime.Today; + private int _exportMonth = DateTime.Today.Month; + private int _exportMonthYear = DateTime.Today.Year; + private int _exportYear = DateTime.Today.Year; // Debug variables private int _rawDbDaysCount = 0; 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() { @@ -372,6 +449,25 @@ else return $"{prefix}{(int)abs.TotalHours}:{abs.Minutes:D2}"; } + private void ExportWeek() + { + if (_exportWeekDate.HasValue) + { + var date = DateOnly.FromDateTime(_exportWeekDate.Value); + NavigationManager.NavigateTo($"api/tracker/export/week?monday={date:yyyy-MM-dd}", forceLoad: true); + } + } + + private void ExportMonth() + { + NavigationManager.NavigateTo($"api/tracker/export/month?year={_exportMonthYear}&month={_exportMonth}", forceLoad: true); + } + + private void ExportYear() + { + NavigationManager.NavigateTo($"api/tracker/export/year?year={_exportYear}", forceLoad: true); + } + private sealed class DayStat { public DateOnly Date { get; set; } 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..c7b6574 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 && username.Equals("marc", StringComparison.OrdinalIgnoreCase)) + { + return (null, "Dieser Benutzername ist reserviert."); + } + await using var db = await factory.CreateDbContextAsync(); + // Filter handles checking uniqueness within the same tenant context if (await db.Users.AnyAsync(u => u.Username == username)) - return (null, "Benutzername bereits vergeben."); + return (null, tenantId.HasValue ? "Benutzername in dieser Firma bereits vergeben." : "Benutzername 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 + }; 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/ExcelExporter.cs b/timetracker.Server/Data/ExcelExporter.cs new file mode 100644 index 0000000..71b4a8e --- /dev/null +++ b/timetracker.Server/Data/ExcelExporter.cs @@ -0,0 +1,278 @@ +using ClosedXML.Excel; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using timetracker.Shared; + +namespace timetracker.Data; + +public static class ExcelExporter +{ + public static async Task GenerateExcelExportAsync( + int userId, + DateOnly startDate, + DateOnly endDate, + string title, + ITimetrackerService trackerService, + TimetrackerDbContext db) + { + var settings = await trackerService.GetSettingsAsync(userId); + + var workDays = await db.WorkDays + .Include(w => w.Breaks) + .Where(w => w.UserId == userId && w.Date >= startDate && w.Date <= endDate) + .ToDictionaryAsync(w => w.Date); + + var holidaysList = await db.PublicHolidays + .Where(h => h.Date >= startDate && h.Date <= endDate) + .ToListAsync(); + + var state = settings.GermanState; + var holidayMap = holidaysList + .Where(h => string.IsNullOrEmpty(h.Counties) || (!string.IsNullOrEmpty(state) && h.Counties.Split(',').Contains(state))) + .ToDictionary(h => h.Date, h => h.Name); + + var vacationMap = await db.VacationDays + .Where(v => v.UserId == userId && v.Date >= startDate && v.Date <= endDate) + .ToDictionaryAsync(v => v.Date, v => v.Note ?? "Urlaub"); + + var user = await db.Users.FindAsync(userId); + var userName = user != null ? user.Username : "Mitarbeiter"; + + var rows = new List(); + + for (var date = startDate; date <= endDate; date = date.AddDays(1)) + { + bool isWorkDay = settings.IsWorkDay(date.DayOfWeek); + bool isHoliday = holidayMap.ContainsKey(date); + bool isVacation = vacationMap.ContainsKey(date); + + double targetHours = (isWorkDay && !isHoliday && !isVacation) ? settings.DailyTargetHours : 0.0; + + string status = "Wochenende"; + if (isHoliday) status = "Feiertag"; + else if (isVacation) status = "Urlaub"; + else if (isWorkDay) status = "Arbeitstag"; + + TimeSpan? startTime = null; + TimeSpan? endTime = null; + TimeSpan breakTime = TimeSpan.Zero; + double actualHours = 0.0; + + string note = ""; + if (isHoliday) note = holidayMap[date]; + else if (isVacation) note = vacationMap[date]; + + if (workDays.TryGetValue(date, out var wd)) + { + if (wd.StartTime.HasValue) startTime = wd.StartTime.Value.ToTimeSpan(); + if (wd.EndTime.HasValue) endTime = wd.EndTime.Value.ToTimeSpan(); + + if (startTime.HasValue && endTime.HasValue) + { + var gross = endTime.Value - startTime.Value; + if (gross > TimeSpan.Zero) + { + var breakTotal = wd.Breaks + .Where(b => b.StartTime.HasValue && b.EndTime.HasValue && b.EndTime > b.StartTime) + .Aggregate(TimeSpan.Zero, (s, b) => + s + (b.EndTime!.Value.ToTimeSpan() - b.StartTime!.Value.ToTimeSpan())); + + var minMinutes = gross.TotalHours >= 9.0 ? 45 : 30; + var minBreak = TimeSpan.FromMinutes(Math.Max(minMinutes, settings.MinimumBreakMinutes)); + var finalBreak = breakTotal > minBreak ? breakTotal : minBreak; + + breakTime = finalBreak; + actualHours = (gross - finalBreak).TotalHours; + if (actualHours < 0) actualHours = 0.0; + } + } + } + + rows.Add(new ExportRow + { + Date = date, + Status = status, + StartTime = startTime, + EndTime = endTime, + BreakTime = breakTime, + ActualHours = actualHours, + TargetHours = targetHours, + DiffHours = actualHours - targetHours, + Note = note + }); + } + + return ExportToExcel(title, userName, startDate, endDate, rows); + } + + public static byte[] ExportToExcel( + string title, + string userName, + DateOnly startDate, + DateOnly endDate, + List rows) + { + using var workbook = new XLWorkbook(); + var worksheet = workbook.Worksheets.Add("Arbeitszeiten"); + + // Gitterlinien explizit einblenden + worksheet.ShowGridLines = true; + + // Title Block + worksheet.Cell("A1").Value = title; + worksheet.Cell("A1").Style.Font.Bold = true; + worksheet.Cell("A1").Style.Font.FontSize = 16; + worksheet.Cell("A1").Style.Font.FontColor = XLColor.FromHtml("#0F172A"); // Slate 900 + + // Info Block + worksheet.Cell("A2").Value = $"Mitarbeiter: {userName}"; + worksheet.Cell("A2").Style.Font.FontSize = 11; + worksheet.Cell("A2").Style.Font.Italic = true; + + worksheet.Cell("A3").Value = $"Zeitraum: {startDate:dd.MM.yyyy} bis {endDate:dd.MM.yyyy}"; + worksheet.Cell("A3").Style.Font.FontSize = 11; + worksheet.Cell("A3").Style.Font.Italic = true; + + // Tabellenüberschriften (Zeile 5) + string[] headers = { + "Datum", "Wochentag", "Status", "Beginn", "Ende", + "Pause", "Netto (Ist)", "Soll-Stunden", "Differenz (Gleitzeit)", "Notiz / Feiertag / Urlaub" + }; + + for (int i = 0; i < headers.Length; i++) + { + var cell = worksheet.Cell(5, i + 1); + cell.Value = headers[i]; + cell.Style.Font.Bold = true; + cell.Style.Font.FontColor = XLColor.White; + cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#0EA5E9"); // Primary color (Sky 500) + cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center; + } + + // Datenzeilen + int currentRow = 6; + var deCulture = new CultureInfo("de-DE"); + foreach (var r in rows) + { + worksheet.Cell(currentRow, 1).Value = r.Date.ToString("dd.MM.yyyy"); + worksheet.Cell(currentRow, 2).Value = r.Date.ToString("dddd", deCulture); + worksheet.Cell(currentRow, 3).Value = r.Status; + + if (r.StartTime.HasValue) + { + worksheet.Cell(currentRow, 4).Value = r.StartTime.Value.ToString(@"hh\:mm"); + } + else + { + worksheet.Cell(currentRow, 4).Value = "—"; + } + + if (r.EndTime.HasValue) + { + worksheet.Cell(currentRow, 5).Value = r.EndTime.Value.ToString(@"hh\:mm"); + } + else + { + worksheet.Cell(currentRow, 5).Value = "—"; + } + + worksheet.Cell(currentRow, 6).Value = r.BreakTime.TotalMinutes > 0 ? r.BreakTime.ToString(@"hh\:mm") : "—"; + + worksheet.Cell(currentRow, 7).Value = r.ActualHours; + worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00"; + + worksheet.Cell(currentRow, 8).Value = r.TargetHours; + worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00"; + + worksheet.Cell(currentRow, 9).Value = r.DiffHours; + worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00"; + + if (r.DiffHours > 0) + { + worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#16A34A"); // Green 600 + worksheet.Cell(currentRow, 9).Style.Font.Bold = true; + } + else if (r.DiffHours < 0) + { + worksheet.Cell(currentRow, 9).Style.Font.FontColor = XLColor.FromHtml("#DC2626"); // Red 600 + worksheet.Cell(currentRow, 9).Style.Font.Bold = true; + } + + worksheet.Cell(currentRow, 10).Value = r.Note; + + // Zebra-Streifen + if (currentRow % 2 == 0) + { + for (int col = 1; col <= headers.Length; col++) + { + worksheet.Cell(currentRow, col).Style.Fill.BackgroundColor = XLColor.FromHtml("#F8FAFC"); // Slate 50 + } + } + + // Rahmenlinien + for (int col = 1; col <= headers.Length; col++) + { + worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Thin; + worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.FromHtml("#E2E8F0"); + } + + currentRow++; + } + + // Summenzeile + currentRow++; + + worksheet.Cell(currentRow, 6).Value = "Gesamt:"; + worksheet.Cell(currentRow, 6).Style.Font.Bold = true; + worksheet.Cell(currentRow, 6).Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Right; + + var actualSumRange = $"G6:G{currentRow - 2}"; + worksheet.Cell(currentRow, 7).FormulaA1 = $"SUM({actualSumRange})"; + worksheet.Cell(currentRow, 7).Style.Font.Bold = true; + worksheet.Cell(currentRow, 7).Style.NumberFormat.Format = "0.00"; + + var targetSumRange = $"H6:H{currentRow - 2}"; + worksheet.Cell(currentRow, 8).FormulaA1 = $"SUM({targetSumRange})"; + worksheet.Cell(currentRow, 8).Style.Font.Bold = true; + worksheet.Cell(currentRow, 8).Style.NumberFormat.Format = "0.00"; + + var diffSumRange = $"I6:I{currentRow - 2}"; + worksheet.Cell(currentRow, 9).FormulaA1 = $"SUM({diffSumRange})"; + worksheet.Cell(currentRow, 9).Style.Font.Bold = true; + worksheet.Cell(currentRow, 9).Style.NumberFormat.Format = "+0.00;-0.00;0.00"; + + // Summenrahmen + for (int col = 6; col <= 9; col++) + { + worksheet.Cell(currentRow, col).Style.Border.TopBorder = XLBorderStyleValues.Thin; + worksheet.Cell(currentRow, col).Style.Border.TopBorderColor = XLColor.Black; + worksheet.Cell(currentRow, col).Style.Border.BottomBorder = XLBorderStyleValues.Double; + worksheet.Cell(currentRow, col).Style.Border.BottomBorderColor = XLColor.Black; + } + + // Spaltenbreiten automatisch anpassen + worksheet.Columns().AdjustToContents(); + + using var memoryStream = new MemoryStream(); + workbook.SaveAs(memoryStream); + return memoryStream.ToArray(); + } +} + +public class ExportRow +{ + public DateOnly Date { get; set; } + public string Status { get; set; } = ""; + public TimeSpan? StartTime { get; set; } + public TimeSpan? EndTime { get; set; } + public TimeSpan BreakTime { get; set; } + public double ActualHours { get; set; } + public double TargetHours { get; set; } + public double DiffHours { get; set; } + public string Note { get; set; } = ""; +} 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..c9c05fa 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" && user.TenantId != null) + { + return Results.BadRequest("Benutzer gehört zu einer Firma. Bitte melden Sie sich über Ihre Firmen-Subdomain an."); + } + } + + var claims = new List { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), - new Claim(ClaimTypes.Name, user.Username) + new Claim(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; @@ -345,6 +459,83 @@ trackerApi.MapGet("/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery] return Results.Ok(days); }); +trackerApi.MapGet("/export/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService, TimetrackerDbContext db) => +{ + var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); + + if (DateOnly.TryParse(monday, out var date)) + { + var mondayDate = date; + int diff = ((int)mondayDate.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7; + mondayDate = mondayDate.AddDays(-diff); + var sundayDate = mondayDate.AddDays(6); + + var fileBytes = await ExcelExporter.GenerateExcelExportAsync( + userId, + mondayDate, + sundayDate, + $"Wochenbericht KW {System.Globalization.ISOWeek.GetWeekOfYear(new DateTime(mondayDate.Year, mondayDate.Month, mondayDate.Day))}", + trackerService, + db); + + return Results.File( + fileBytes, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + $"Wochenbericht_KW_{System.Globalization.ISOWeek.GetWeekOfYear(new DateTime(mondayDate.Year, mondayDate.Month, mondayDate.Day))}_{mondayDate:yyyyMMdd}.xlsx"); + } + return Results.BadRequest("Ungültiges Datum."); +}); + +trackerApi.MapGet("/export/month", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, [FromQuery] int month, ITimetrackerService trackerService, TimetrackerDbContext db) => +{ + var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); + + if (month < 1 || month > 12) return Results.BadRequest("Ungültiger Monat."); + + var startDate = new DateOnly(year, month, 1); + var endDate = startDate.AddMonths(1).AddDays(-1); + + var deCulture = new System.Globalization.CultureInfo("de-DE"); + var monthName = deCulture.DateTimeFormat.GetMonthName(month); + + var fileBytes = await ExcelExporter.GenerateExcelExportAsync( + userId, + startDate, + endDate, + $"Monatsbericht {monthName} {year}", + trackerService, + db); + + return Results.File( + fileBytes, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + $"Monatsbericht_{monthName}_{year}.xlsx"); +}); + +trackerApi.MapGet("/export/year", async (ClaimsPrincipal claimsPrincipal, [FromQuery] int year, ITimetrackerService trackerService, TimetrackerDbContext db) => +{ + var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (!int.TryParse(idClaim, out var userId)) return Results.Unauthorized(); + + var startDate = new DateOnly(year, 1, 1); + var endDate = new DateOnly(year, 12, 31); + + var fileBytes = await ExcelExporter.GenerateExcelExportAsync( + userId, + startDate, + endDate, + $"Jahresbericht {year}", + trackerService, + db); + + return Results.File( + fileBytes, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + $"Jahresbericht_{year}.xlsx"); +}); + // ── Holiday-API-Endpoints (Protected) ───────────────────────────────────────── var holidaysApi = app.MapGroup("/api/holidays").RequireAuthorization(); @@ -366,6 +557,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.Server/timetracker.Server.csproj b/timetracker.Server/timetracker.Server.csproj index 930c641..55c132d 100644 --- a/timetracker.Server/timetracker.Server.csproj +++ b/timetracker.Server/timetracker.Server.csproj @@ -8,6 +8,7 @@ + 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).