multi-tenant implementierung
This commit is contained in:
@@ -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 @@
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Elevation="0" Style="background: #0F172A; border-bottom: 1px solid rgba(255, 255, 255, 0.08);">
|
||||
<MudAppBar Elevation="0" Style="background: color-mix(in srgb, var(--mud-palette-appbar-background) 85%, transparent); border-bottom: 1px solid rgba(255, 255, 255, 0.08); color: var(--mud-palette-appbar-text);">
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="ToggleDrawer" Class="d-flex d-md-none mr-2" />
|
||||
@@ -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<TenantInfo>("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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject HttpClient Http
|
||||
<div class="nav-container onboarding-nav-menu">
|
||||
@* --- Brand / Logo Section --- *@
|
||||
<div class="nav-header">
|
||||
@@ -37,6 +38,11 @@
|
||||
<span class="admin-divider-text">Admin</span>
|
||||
<hr class="admin-divider-line" />
|
||||
</div>
|
||||
<MudTooltip Text="Firmen verwalten (SaaS)" Placement="Placement.Right">
|
||||
<MudNavLink Href="superadmin" Icon="@Icons.Material.Filled.Business" Class="custom-nav-link admin-link">
|
||||
Firmen verwalten
|
||||
</MudNavLink>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Benutzerverwaltung" Placement="Placement.Right">
|
||||
<MudNavLink Href="admin/users" Icon="@Icons.Material.Filled.AdminPanelSettings" Class="custom-nav-link admin-link">
|
||||
Benutzerverwaltung
|
||||
@@ -61,7 +67,7 @@
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">@context.User.Identity?.Name</span>
|
||||
<span class="user-status">Online</span>
|
||||
<span class="user-status">@_companyName</span>
|
||||
</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
@@ -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<TenantInfo>("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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
<PageTitle>Benutzerverwaltung – Timetracker</PageTitle>
|
||||
@@ -42,6 +46,7 @@ else
|
||||
<HeaderContent>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Id)">ID</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Username)" InitialDirection="SortDirection.Ascending">Benutzername</MudTableSortLabel></MudTh>
|
||||
<MudTh>Firma / Mandant</MudTh>
|
||||
<MudTh Style="text-align:right">Aktionen</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
@@ -74,6 +79,34 @@ else
|
||||
</MudStack>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
@if (_editUserId == context.Id)
|
||||
{
|
||||
<MudSelect @bind-Value="_editTenantId" Margin="Margin.Dense" T="int?" Label="Firma zuweisen" Variant="Variant.Outlined" Style="max-width:250px">
|
||||
<MudSelectItem T="int?" Value="@((int?)null)">Hauptdomain (System/Global)</MudSelectItem>
|
||||
@foreach (var tenant in _tenants)
|
||||
{
|
||||
<MudSelectItem T="int?" Value="@((int?)tenant.Id)">@tenant.Name (@tenant.Subdomain)</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (context.TenantId.HasValue)
|
||||
{
|
||||
var t = _tenants.FirstOrDefault(x => x.Id == context.TenantId.Value);
|
||||
<MudChip T="string" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small">
|
||||
@(t?.Name ?? $"Firma #{context.TenantId}")
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Default" Variant="Variant.Text" Size="Size.Small">
|
||||
Hauptdomain (SuperUser)
|
||||
</MudChip>
|
||||
}
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd Style="text-align:right">
|
||||
@if (_editUserId == context.Id)
|
||||
{
|
||||
@@ -92,6 +125,12 @@ else
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => StartEdit(context))" />
|
||||
<MudTooltip Text="Passwort zurücksetzen">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.LockReset"
|
||||
Color="Color.Warning"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => OpenResetPasswordDialog(context))" />
|
||||
</MudTooltip>
|
||||
@if (context.Username != "marc")
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
|
||||
@@ -114,9 +153,11 @@ else
|
||||
|
||||
@code {
|
||||
private List<User> _users = [];
|
||||
private List<TenantStats> _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;
|
||||
|
||||
try
|
||||
{
|
||||
_users = await AuthService.GetAllUsersAsync();
|
||||
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("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<List<TenantStats>>("api/superadmin/tenants") ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore background refresh errors
|
||||
}
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
@@ -144,17 +203,23 @@ 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();
|
||||
|
||||
// 1. Rename if username changed
|
||||
if (trimmed != user.Username)
|
||||
{
|
||||
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
|
||||
if (error != null)
|
||||
{
|
||||
@@ -162,9 +227,25 @@ else
|
||||
return;
|
||||
}
|
||||
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<ResetPasswordDialog>
|
||||
{
|
||||
{ x => x.Username, user.Username }
|
||||
};
|
||||
|
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
var dialog = await DialogService.ShowAsync<ResetPasswordDialog>("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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl" Style="background: #1E293B; color: white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Gavel" Style="color: white; font-size: 2.2rem;" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.Gavel" Style="color: var(--mud-palette-primary-text); font-size: 2.2rem;" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color: white; font-weight: 700;">Allgemeine Geschäftsbedingungen (AGB)</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: rgba(255,255,255,0.72);">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight: 700;">Allgemeine Geschäftsbedingungen (AGB)</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72;">
|
||||
Rechtlich absolut unverbindliches Kauderwelsch und Flachwitze
|
||||
</MudText>
|
||||
</MudStack>
|
||||
|
||||
@@ -8,13 +8,12 @@
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: #1E293B; color:white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color:white; font-size:2rem" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.NewReleases" Style="color: var(--mud-palette-primary-text); font-size:2rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Changelog</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">Versionshistorie & Änderungen</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Changelog</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">Versionshistorie & Änderungen</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@@ -21,29 +21,28 @@ else
|
||||
<MudStack Spacing="3">
|
||||
|
||||
@* ── Wochen-Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-header"
|
||||
Style="background: #1E293B; color: white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-header mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Style="color:white" Size="Size.Large" OnClick="PrevWeek" />
|
||||
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevWeek" />
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
|
||||
@_weekLabel
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
|
||||
@_weekSubLabel
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||
@if (!IsCurrentWeek)
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Style="color:white"
|
||||
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
|
||||
OnClick="GoToCurrentWeek" Size="Size.Small">
|
||||
Heute
|
||||
</MudButton>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||
Style="color:white" Size="Size.Large" OnClick="NextWeek" />
|
||||
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextWeek" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
@@ -316,34 +315,34 @@ else
|
||||
|
||||
@* ── Wochensumme ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-summary"
|
||||
Style="background: #0F172A; color:white;">
|
||||
Style="background: var(--mud-palette-primary-darken); color: var(--mud-palette-primary-text);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color:rgba(255,255,255,0.8)" />
|
||||
<MudText Typo="Typo.h6" Style="color:white; font-weight:600">Wochensumme</MudText>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color: var(--mud-palette-primary-text); opacity: 0.8" />
|
||||
<MudText Typo="Typo.h6" Style="color: var(--mud-palette-primary-text); font-weight:600">Wochensumme</MudText>
|
||||
</MudStack>
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Brutto</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekGross)</MudText>
|
||||
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Brutto</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekGross)</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Pausen</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekBreaks)</MudText>
|
||||
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Pausen</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekBreaks)</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Netto</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color:#90CAF9; font-weight:700">@FormatTs(WeekNet)</MudText>
|
||||
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Netto</MudText>
|
||||
<MudText Typo="Typo.h5" Color="Color.Secondary" Style="font-weight:700">@FormatTs(WeekNet)</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
<MudItem xs="6" sm="3">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Gleitzeit</MudText>
|
||||
<MudText Typo="Typo.h5" Style="@($"color:{(WeekOvertime >= TimeSpan.Zero ? "#A5D6A7" : "#FFCC80")}; font-weight:700")">
|
||||
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Gleitzeit</MudText>
|
||||
<MudText Typo="Typo.h5" Color="@(WeekOvertime >= TimeSpan.Zero ? Color.Success : Color.Warning)" Style="font-weight:700">
|
||||
@FormatTs(WeekOvertime, sign: true)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
|
||||
@@ -1,23 +1,99 @@
|
||||
@page "/login"
|
||||
@rendermode InteractiveWebAssembly
|
||||
@attribute [AllowAnonymous]
|
||||
@using System.Net.Http.Json
|
||||
@inject IAuthService AuthService
|
||||
@inject NavigationManager Nav
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject HttpClient Http
|
||||
|
||||
<PageTitle>Anmelden – Timetracker</PageTitle>
|
||||
<PageTitle>@GetPageTitle()</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
|
||||
@if (_tenantInfo == null)
|
||||
{
|
||||
@* Loading indicator while resolving Tenant *@
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16 text-center">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Class="mt-4" Color="Color.Secondary">Lade Timetracker…</MudText>
|
||||
</MudContainer>
|
||||
}
|
||||
else if (_tenantInfo.IsTenant && !_tenantInfo.IsApproved)
|
||||
{
|
||||
@* Pending Approval Screen *@
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccessTime" Style="font-size:4rem; color:#EAB308" />
|
||||
<MudText Typo="Typo.h4" Style="font-weight:700; color:#EAB308">Timetracker</MudText>
|
||||
</MudStack>
|
||||
|
||||
<MudPaper Elevation="4" Class="pa-8 rounded-xl text-center" Style="width:100%; border-top: 4px solid var(--mud-palette-warning);">
|
||||
<MudIcon Icon="@Icons.Material.Filled.HourglassEmpty"
|
||||
Style="font-size: 5rem; animation: pulse 2s infinite;"
|
||||
Color="Color.Warning"
|
||||
Class="mb-4" />
|
||||
<MudText Typo="Typo.h5" Style="font-weight: 700;" Class="mb-2">Freischaltung ausstehend</MudText>
|
||||
<MudText Typo="Typo.body1" Color="Color.Secondary" Class="mb-6">
|
||||
Die Firma <strong>@_tenantInfo.Name</strong> wurde erfolgreich registriert.
|
||||
Der Zugang wird derzeit von unserem SuperAdmin geprüft und in Kürze freigeschaltet.
|
||||
</MudText>
|
||||
<MudDivider Class="my-4" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Bitte laden Sie diese Seite in Kürze neu, sobald Ihr Account freigegeben wurde.
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
<style>
|
||||
@@keyframes pulse {
|
||||
0% { opacity: 0.6; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.05); }
|
||||
100% { opacity: 0.6; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Main login/registration container *@
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
||||
|
||||
@* ── Logo / Header ── *@
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccessTime"
|
||||
Style="font-size:4rem; color:#0EA5E9" />
|
||||
<MudText Typo="Typo.h4" Style="font-weight:700; color:#0EA5E9">Timetracker</MudText>
|
||||
<MudText Typo="Typo.h4" Style="font-weight:700; color:#0EA5E9">
|
||||
@(_tenantInfo.IsTenant ? _tenantInfo.Name : "Timetracker SaaS")
|
||||
</MudText>
|
||||
@if (!_tenantInfo.IsTenant)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Zentrales Portal</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
<MudPaper Elevation="4" Class="pa-6 rounded-xl" Style="width:100%">
|
||||
@if (_firmRegisteredSuccessfully)
|
||||
{
|
||||
@* Success feedback after firm registration *@
|
||||
<MudStack Spacing="4" AlignItems="AlignItems.Center" Class="text-center pa-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" Style="font-size:5rem;" />
|
||||
<MudText Typo="Typo.h5" Style="font-weight:700;">Registrierung erfolgreich!</MudText>
|
||||
<MudText Typo="Typo.body1" Color="Color.Secondary">
|
||||
Ihre Firma <strong>@_firmModel.CompanyName</strong> wurde erfolgreich registriert.
|
||||
</MudText>
|
||||
<MudAlert Severity="Severity.Info" Class="rounded-lg">
|
||||
Ihr Zugang muss nun durch den SuperAdmin freigegeben werden. Danach können Sie sich unter Ihrer Subdomain anmelden:
|
||||
<br /><br />
|
||||
<strong>@GetTenantUrl()</strong>
|
||||
</MudAlert>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4" OnClick="ResetRegistrationFlow">
|
||||
Zurück zum Login
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Tab Navigation ── *@
|
||||
<MudStack Row="true" Justify="Justify.Center" Class="mb-4">
|
||||
<MudButton OnClick="@(() => SetTab(0))"
|
||||
@@ -30,7 +106,7 @@
|
||||
Variant="@(_activeTab == 1 ? Variant.Filled : Variant.Text)"
|
||||
Color="Color.Primary"
|
||||
Style="min-width: 120px; border-radius: 20px;">
|
||||
Registrieren
|
||||
@(_tenantInfo.IsTenant ? "Registrieren" : "Firma registrieren")
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@@ -82,7 +158,9 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Register Form ── *@
|
||||
@if (_tenantInfo.IsTenant)
|
||||
{
|
||||
@* ── Employee Register Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
@@ -131,19 +209,104 @@
|
||||
</EditForm>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Firm Register Form ── *@
|
||||
<MudStack Spacing="3">
|
||||
@if (_error != null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||
}
|
||||
<EditForm Model="@_firmModel" OnValidSubmit="HandleRegisterFirm">
|
||||
<DataAnnotationsValidator />
|
||||
<MudStack Spacing="3">
|
||||
<MudTextField T="string"
|
||||
Label="Firmenname (z.B. Acme GmbH)"
|
||||
Variant="Variant.Outlined"
|
||||
@bind-Value="_firmModel.CompanyName"
|
||||
Required="true" />
|
||||
|
||||
<MudTextField T="string"
|
||||
Label="Subdomain"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Nur Kleinbuchstaben, Zahlen & Bindestriche (z.B. acme)"
|
||||
@bind-Value="_firmModel.Subdomain"
|
||||
Required="true"
|
||||
Adornment="Adornment.End"
|
||||
AdornmentText="@GetDomainSuffix()" />
|
||||
|
||||
<MudDivider Class="my-2" />
|
||||
<MudText Typo="Typo.subtitle2" Style="font-weight:700;">Administrator-Konto</MudText>
|
||||
|
||||
<MudTextField T="string"
|
||||
Label="Benutzername"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||
@bind-Value="_firmModel.AdminUsername"
|
||||
Required="true"
|
||||
HelperText="Mindestens 3 Zeichen" />
|
||||
|
||||
<MudTextField T="string"
|
||||
Label="Passwort"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
@bind-Value="_firmModel.AdminPassword"
|
||||
Required="true"
|
||||
HelperText="Mindestens 6 Zeichen" />
|
||||
|
||||
<MudTextField T="string"
|
||||
Label="Passwort wiederholen"
|
||||
Variant="Variant.Outlined"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||
InputType="InputType.Password"
|
||||
@bind-Value="_firmModel.ConfirmPassword"
|
||||
Required="true" />
|
||||
|
||||
<MudCheckBox @bind-Value="_acceptAgb" Color="Color.Secondary" Class="mt-1">
|
||||
<MudText Typo="Typo.body2">Ich akzeptiere die <a href="/agb" target="_blank" style="color: var(--mud-palette-secondary); text-decoration: underline; font-weight: 600;">AGB</a>.</MudText>
|
||||
</MudCheckBox>
|
||||
|
||||
<MudButton ButtonType="ButtonType.Submit"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Secondary"
|
||||
FullWidth="true"
|
||||
Size="Size.Large"
|
||||
StartIcon="@Icons.Material.Filled.Business"
|
||||
Class="mt-2"
|
||||
Disabled="_loading || !_acceptAgb">
|
||||
@if (_loading)
|
||||
{
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||
}
|
||||
Firma registrieren
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</EditForm>
|
||||
</MudStack>
|
||||
}
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@code {
|
||||
private TenantInfo? _tenantInfo;
|
||||
private int _activeTab = 0;
|
||||
private string? _error;
|
||||
private bool _loading;
|
||||
private string _honeypot = "";
|
||||
private bool _acceptAgb;
|
||||
private bool _firmRegisteredSuccessfully;
|
||||
|
||||
private readonly AuthModel _loginModel = new();
|
||||
private readonly AuthModel _registerModel = new();
|
||||
private readonly FirmModel _firmModel = new();
|
||||
|
||||
[SupplyParameterFromQuery(Name = "error")]
|
||||
public string? ErrorParam { get; set; }
|
||||
@@ -151,6 +314,20 @@
|
||||
[SupplyParameterFromQuery(Name = "tab")]
|
||||
public string? TabParam { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_tenantInfo = await Http.GetFromJsonAsync<TenantInfo>("api/tenant/current");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Fehler beim Abrufen der TenantInfo: {ex.Message}");
|
||||
// Fallback
|
||||
_tenantInfo = new TenantInfo { IsTenant = false };
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_error = ErrorParam switch
|
||||
@@ -168,6 +345,35 @@
|
||||
_error = null;
|
||||
}
|
||||
|
||||
private string GetPageTitle()
|
||||
{
|
||||
if (_tenantInfo == null) return "Lade…";
|
||||
return _tenantInfo.IsTenant ? $"Anmelden bei {_tenantInfo.Name} – Timetracker" : "Anmelden – Timetracker SaaS";
|
||||
}
|
||||
|
||||
private string GetDomainSuffix()
|
||||
{
|
||||
var uri = new Uri(Nav.BaseUri);
|
||||
var host = uri.Host;
|
||||
return $".{host}";
|
||||
}
|
||||
|
||||
private string GetTenantUrl()
|
||||
{
|
||||
var uri = new Uri(Nav.BaseUri);
|
||||
var scheme = uri.Scheme;
|
||||
var port = uri.Port == 80 || uri.Port == 443 ? "" : $":{uri.Port}";
|
||||
var host = uri.Host;
|
||||
return $"{scheme}://{_firmModel.Subdomain.ToLower().Trim()}.{host}{port}";
|
||||
}
|
||||
|
||||
private void ResetRegistrationFlow()
|
||||
{
|
||||
_firmRegisteredSuccessfully = false;
|
||||
_activeTab = 0;
|
||||
_error = null;
|
||||
}
|
||||
|
||||
private async Task HandleLogin()
|
||||
{
|
||||
_loading = true;
|
||||
@@ -177,7 +383,7 @@
|
||||
var user = await AuthService.LoginAsync(_loginModel.Username, _loginModel.Password);
|
||||
if (user != null)
|
||||
{
|
||||
Nav.NavigateTo("/", forceLoad: true); // forceLoad forces state update/re-render of the root app
|
||||
Nav.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -226,9 +432,62 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRegisterFirm()
|
||||
{
|
||||
if (!_acceptAgb)
|
||||
{
|
||||
_error = "Du musst die AGB akzeptieren.";
|
||||
return;
|
||||
}
|
||||
if (_firmModel.AdminPassword != _firmModel.ConfirmPassword)
|
||||
{
|
||||
_error = "Passwörter stimmen nicht überein.";
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
_error = null;
|
||||
try
|
||||
{
|
||||
var req = new RegisterFirmRequest(
|
||||
_firmModel.CompanyName,
|
||||
_firmModel.Subdomain,
|
||||
_firmModel.AdminUsername,
|
||||
_firmModel.AdminPassword
|
||||
);
|
||||
|
||||
var (tenant, error) = await AuthService.RegisterFirmAsync(req);
|
||||
if (tenant != null)
|
||||
{
|
||||
_firmRegisteredSuccessfully = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_error = error ?? "Registrierung der Firma fehlgeschlagen.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = $"Registrierungs Fehler: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private class AuthModel
|
||||
{
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
}
|
||||
|
||||
private class FirmModel
|
||||
{
|
||||
public string CompanyName { get; set; } = "";
|
||||
public string Subdomain { get; set; } = "";
|
||||
public string AdminUsername { get; set; } = "";
|
||||
public string AdminPassword { get; set; } = "";
|
||||
public string ConfirmPassword { get; set; } = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,29 +19,28 @@ else
|
||||
<MudStack Spacing="3">
|
||||
|
||||
@* ── Monats-Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: #1E293B; color: white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
||||
Style="color:white" Size="Size.Large" OnClick="PrevMonth" />
|
||||
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="PrevMonth" />
|
||||
<MudStack Spacing="0" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700; letter-spacing:0.5px">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700; letter-spacing:0.5px">
|
||||
@_deCulture.DateTimeFormat.GetMonthName(_month) @_year
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
|
||||
@_subLabel
|
||||
</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||
@if (!IsCurrentMonth)
|
||||
{
|
||||
<MudButton Variant="Variant.Text" Style="color:white"
|
||||
<MudButton Variant="Variant.Text" Style="color: var(--mud-palette-primary-text)"
|
||||
OnClick="GoToCurrentMonth" Size="Size.Small">
|
||||
Heute
|
||||
</MudButton>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
||||
Style="color:white" Size="Size.Large" OnClick="NextMonth" />
|
||||
Style="color: var(--mud-palette-primary-text)" Size="Size.Large" OnClick="NextMonth" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
@@ -95,7 +94,7 @@ else
|
||||
|
||||
@* ── Monatszusammenfassung ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, rgba(14,165,233,0.08) 0%, rgba(14,165,233,0.02) 100%); border-left: 6px solid #0EA5E9;">
|
||||
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);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CalendarViewMonth" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:700">Monatszusammenfassung</MudText>
|
||||
|
||||
@@ -22,13 +22,12 @@ else
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: #1E293B; color:white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccountCircle" Style="color:white; font-size:2.5rem" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.AccountCircle" Style="color: var(--mud-palette-primary-text); font-size:2.5rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Mein Profil</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Mein Profil</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72;">
|
||||
Persönliche Details verwalten und Account sichern
|
||||
</MudText>
|
||||
</MudStack>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
@namespace timetracker.Client.Components.Pages
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<MudText Class="mb-4">Geben Sie das neue Passwort für den Benutzer <strong>@Username</strong> ein.</MudText>
|
||||
<MudTextField @bind-Value="_newPassword"
|
||||
Label="Neues Passwort"
|
||||
InputType="InputType.Password"
|
||||
Variant="Variant.Outlined"
|
||||
AutoFocus="true"
|
||||
Required="true"
|
||||
HelperText="Mindestens 6 Zeichen" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Abbrechen</MudButton>
|
||||
<MudButton Color="Color.Warning" Variant="Variant.Filled" OnClick="Submit" Disabled="@(string.IsNullOrEmpty(_newPassword) || _newPassword.Length < 6)">Passwort zurücksetzen</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@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();
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject NavigationManager Nav
|
||||
@inject HttpClient Http
|
||||
|
||||
|
||||
<PageTitle>Einstellungen – Timetracker</PageTitle>
|
||||
@@ -22,13 +23,12 @@ else
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
||||
Style="background: #1E293B; color:white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color:white; font-size:2rem" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.Settings" Style="color: var(--mud-palette-primary-text); font-size:2rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Einstellungen</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Einstellungen</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
|
||||
Arbeitszeit, Arbeitstage und Urlaub konfigurieren
|
||||
</MudText>
|
||||
</MudStack>
|
||||
@@ -217,6 +217,52 @@ else
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@if (_isTenantAdmin)
|
||||
{
|
||||
@* ── Firmen-Einstellungen (Branding) ── *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="3" Class="rounded-xl h-100">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Business" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Style="font-weight:600">Firmen-Einstellungen</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudStack Spacing="4">
|
||||
<MudTextField @bind-Value="_tenantName"
|
||||
Label="Firmenname"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="Der offizielle Name deiner Firma" />
|
||||
|
||||
<MudStack Row="true" Spacing="4">
|
||||
<MudColorPicker @bind-Text="_tenantPrimaryColor"
|
||||
Label="Primärfarbe"
|
||||
Variant="Variant.Outlined"
|
||||
Style="@($"color: {_tenantPrimaryColor};")"
|
||||
ColorPickerMode="ColorPickerMode.HEX" />
|
||||
|
||||
<MudColorPicker @bind-Text="_tenantSecondaryColor"
|
||||
Label="Sekundärfarbe"
|
||||
Variant="Variant.Outlined"
|
||||
Style="@($"color: {_tenantSecondaryColor};")"
|
||||
ColorPickerMode="ColorPickerMode.HEX" />
|
||||
</MudStack>
|
||||
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Save"
|
||||
OnClick="SaveTenantSettings"
|
||||
Style="max-width:250px;">
|
||||
Firma speichern
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
</MudGrid>
|
||||
|
||||
@* ── 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<TenantInfo>("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);
|
||||
|
||||
@@ -20,12 +20,12 @@ else
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header ── *@
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl" Style="background: #1E293B; color: white;">
|
||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
||||
<MudIcon Icon="@Icons.Material.Filled.BarChart" Style="color:white; font-size:2.2rem" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.BarChart" Style="color: var(--mud-palette-primary-text); font-size:2.2rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Statistiken</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">Auswertung deiner Arbeitsleistung und Gleitzeitkonto</MudText>
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Statistiken</MudText>
|
||||
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">Auswertung deiner Arbeitsleistung und Gleitzeitkonto</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
@@ -46,7 +46,7 @@ else
|
||||
<div style="position:relative; display:inline-flex;">
|
||||
<MudProgressCircular Value="@pct" Color="Color.Secondary" Size="Size.Large" StrokeWidth="6" Style="height:120px; width:120px;" />
|
||||
<div style="position:absolute; top:0; left:0; bottom:0; right:0; display:flex; align-items:center; justify-content:center; flex-direction:column;">
|
||||
<MudText Typo="Typo.h5" Style="font-weight:800; color:#0F172A;">@displayPct%</MudText>
|
||||
<MudText Typo="Typo.h5" Style="font-weight:800; color: var(--mud-palette-text-primary);">@displayPct%</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Erreicht</MudText>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,7 +54,7 @@ else
|
||||
<MudStack Spacing="1" Style="width:100%;" Class="mt-2">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Arbeitszeit (Ist):</MudText>
|
||||
<MudText Typo="Typo.body2" Style="font-weight:700; color:#0F172A">@FormatHours(_weekWorkedHours) Std.</MudText>
|
||||
<MudText Typo="Typo.body2" Style="font-weight:700; color: var(--mud-palette-text-primary)">@FormatHours(_weekWorkedHours) Std.</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Wochensoll (Soll):</MudText>
|
||||
@@ -68,7 +68,7 @@ else
|
||||
|
||||
@* ── Card 2: Wochensaldo ── *@
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudCard Elevation="3" Class="rounded-xl" Style="height:100%; border-left: 6px solid #0EA5E9;">
|
||||
<MudCard Elevation="3" Class="rounded-xl" Style="height:100%; border-left: 6px solid var(--mud-palette-primary);">
|
||||
<MudCardContent>
|
||||
<MudStack Spacing="2">
|
||||
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Wochensaldo</MudText>
|
||||
@@ -115,7 +115,7 @@ else
|
||||
<MudCard Elevation="3" Class="rounded-xl">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6" Style="font-weight:700; color:#0F172A">Arbeitszeitverteilung diese Woche</MudText>
|
||||
<MudText Typo="Typo.h6" Style="font-weight:700; color: var(--mud-palette-text-primary)">Arbeitszeitverteilung diese Woche</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">Geleistete Nettoarbeitsstunden pro Wochentag</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
@@ -148,8 +148,8 @@ else
|
||||
{
|
||||
double val = (_maxHoursValue / 4.0) * i;
|
||||
double yPos = plotBottom - (val / _maxHoursValue) * plotHeight;
|
||||
<line x1="@plotLeft" y1="@yPos" x2="@plotRight" y2="@yPos" stroke="#E2E8F0" stroke-width="1" stroke-dasharray="2" />
|
||||
@((MarkupString)$"<text x=\"{plotLeft - 8}\" y=\"{yPos + 4}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"500\" text-anchor=\"end\">{val:F1}h</text>")
|
||||
<line x1="@F(plotLeft)" y1="@F(yPos)" x2="@F(plotRight)" y2="@F(yPos)" stroke="#E2E8F0" stroke-width="1" stroke-dasharray="2" />
|
||||
@((MarkupString)$"<text x=\"{F(plotLeft - 8)}\" y=\"{F(yPos + 4)}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"500\" text-anchor=\"end\">{val:F1}h</text>")
|
||||
}
|
||||
|
||||
<!-- Bars and Labels -->
|
||||
@@ -163,7 +163,7 @@ else
|
||||
string fillCol = "#94A3B8"; // Default light slate
|
||||
if (d.IsToday)
|
||||
{
|
||||
fillCol = "#0EA5E9"; // Sky Blue for today
|
||||
fillCol = "var(--mud-palette-secondary)"; // Brand color for today
|
||||
}
|
||||
else if (d.WorkedHours >= d.TargetHours && d.TargetHours > 0)
|
||||
{
|
||||
@@ -183,7 +183,7 @@ else
|
||||
}
|
||||
|
||||
<!-- Render Rect with hover tooltip -->
|
||||
<rect x="@xPos" y="@(d.WorkedHours > 0 ? yPos : plotBottom - 4)" width="@barWidth" height="@(d.WorkedHours > 0 ? bHeight : 4)"
|
||||
<rect x="@F(xPos)" y="@F(d.WorkedHours > 0 ? yPos : plotBottom - 4)" width="@F(barWidth)" height="@F(d.WorkedHours > 0 ? bHeight : 4)"
|
||||
rx="6" fill="@fillCol" class="chart-bar">
|
||||
<title>@d.DayName: @FormatHours(d.WorkedHours) Std. (Soll: @FormatHours(d.TargetHours) Std.)</title>
|
||||
</rect>
|
||||
@@ -191,19 +191,19 @@ else
|
||||
<!-- Value Label above bar -->
|
||||
@if (d.WorkedHours > 0)
|
||||
{
|
||||
@((MarkupString)$"<text x=\"{xPos + barWidth / 2}\" y=\"{yPos - 6}\" fill=\"#0F172A\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{FormatHours(d.WorkedHours)}</text>")
|
||||
@((MarkupString)$"<text x=\"{F(xPos + barWidth / 2)}\" y=\"{F(yPos - 6)}\" fill=\"var(--mud-palette-text-primary)\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{FormatHours(d.WorkedHours)}</text>")
|
||||
}
|
||||
|
||||
<!-- Wochentag Text -->
|
||||
@((MarkupString)$"<text x=\"{xPos + barWidth / 2}\" y=\"{plotBottom + 20}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{d.DayShortName}</text>")
|
||||
@((MarkupString)$"<text x=\"{F(xPos + barWidth / 2)}\" y=\"{F(plotBottom + 20)}\" fill=\"#64748B\" font-size=\"11\" font-weight=\"700\" text-anchor=\"middle\">{d.DayShortName}</text>")
|
||||
}
|
||||
|
||||
<!-- Sollzeit Target Line (Only if target hours > 0) -->
|
||||
@if (_settings.DailyTargetHours > 0)
|
||||
{
|
||||
double yTarget = plotBottom - (_settings.DailyTargetHours / _maxHoursValue) * plotHeight;
|
||||
<line x1="@plotLeft" y1="@yTarget" x2="@plotRight" y2="@yTarget" stroke="#EF4444" stroke-width="2" stroke-dasharray="4" style="opacity: 0.75;" />
|
||||
@((MarkupString)$"<text x=\"{plotRight - 10}\" y=\"{yTarget - 6}\" fill=\"#EF4444\" font-size=\"10\" font-weight=\"700\" text-anchor=\"end\">Soll ({_settings.DailyTargetHours} h)</text>")
|
||||
<line x1="@F(plotLeft)" y1="@F(yTarget)" x2="@F(plotRight)" y2="@F(yTarget)" stroke="#EF4444" stroke-width="2" stroke-dasharray="4" style="opacity: 0.75;" />
|
||||
@((MarkupString)$"<text x=\"{F(plotRight - 10)}\" y=\"{F(yTarget - 6)}\" fill=\"#EF4444\" font-size=\"10\" font-weight=\"700\" text-anchor=\"end\">Soll ({_settings.DailyTargetHours} h)</text>")
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
@@ -269,6 +269,7 @@ else
|
||||
private Dictionary<DateOnly, string> _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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
<PageTitle>Firmenverwaltung – Timetracker SaaS</PageTitle>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<MudStack AlignItems="AlignItems.Center" Class="mt-16" Spacing="3">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Color="Color.Secondary">Lade Firmen und Mandanten…</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudStack Spacing="4">
|
||||
|
||||
@* ── Header Card ── *@
|
||||
<MudPaper Elevation="4" Class="pa-6 rounded-xl"
|
||||
Style="background: linear-gradient(135deg, var(--mud-palette-primary-darken) 0%, var(--mud-palette-primary) 100%); color: var(--mud-palette-primary-text);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Business" Style="color: var(--mud-palette-secondary); font-size:3rem" />
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:800">SuperAdmin Dashboard</MudText>
|
||||
<MudText Typo="Typo.body2" Style="color: var(--mud-palette-primary-text); opacity: 0.72">
|
||||
@_tenants.Count registrierte Firmen im System
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@* ── Tenants Table ── *@
|
||||
<MudCard Elevation="3" Class="rounded-xl overflow-hidden">
|
||||
<MudCardContent Class="pa-0">
|
||||
<MudTable Items="_tenants" Hover="true" Striped="true" Elevation="0" SortLabel="Sortieren">
|
||||
<HeaderContent>
|
||||
<MudTh Style="width: 50px;" />
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Id)">ID</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Name)">Name</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.Subdomain)">Subdomain</MudTableSortLabel></MudTh>
|
||||
<MudTh><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.CreatedAt)">Erstellt am</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="text-align:center"><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.UserCount)">Benutzer</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="text-align:center"><MudTableSortLabel SortBy="new Func<TenantStats, object>(t => t.WorkDaysCount)">Arbeitstage</MudTableSortLabel></MudTh>
|
||||
<MudTh Style="text-align:center">Status</MudTh>
|
||||
<MudTh Style="text-align:right">Aktionen</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@(context.ShowDetails ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => context.ShowDetails = !context.ShowDetails)" />
|
||||
</MudTd>
|
||||
<MudTd DataLabel="ID">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@context.Id</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Name">
|
||||
<MudText Typo="Typo.body1" Style="font-weight:600">@context.Name</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Subdomain">
|
||||
<MudChip T="string" Color="Color.Primary" Variant="Variant.Outlined" Size="Size.Small">
|
||||
@context.Subdomain
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Erstellt am">
|
||||
<MudText Typo="Typo.body2">@context.CreatedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm")</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Benutzer" Style="text-align:center">
|
||||
<MudBadge Content="@context.UserCount" Color="Color.Default" Overlap="false" Class="mx-2" />
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Arbeitstage" Style="text-align:center">
|
||||
<MudText Typo="Typo.body2">@context.WorkDaysCount</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Status" Style="text-align:center">
|
||||
@if (context.IsApproved)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Success" Size="Size.Small" Icon="@Icons.Material.Filled.Check">Aktiv</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Warning" Size="Size.Small" Icon="@Icons.Material.Filled.HourglassEmpty">Ausstehend</MudChip>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd Style="text-align:right">
|
||||
@if (!context.IsApproved)
|
||||
{
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Success"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.CheckCircle"
|
||||
OnClick="@(() => ApproveTenant(context))"
|
||||
Class="mr-1">
|
||||
Freigeben
|
||||
</MudButton>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Warning"
|
||||
Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Filled.Block"
|
||||
OnClick="@(() => ToggleActiveTenant(context))"
|
||||
Class="mr-1">
|
||||
Sperren
|
||||
</MudButton>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
Color="Color.Error"
|
||||
Size="Size.Small"
|
||||
OnClick="@(() => DeleteTenant(context))" />
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
<ChildRowContent>
|
||||
@if (context.ShowDetails)
|
||||
{
|
||||
<MudTr>
|
||||
<td colspan="9" style="background-color: var(--mud-palette-background-grey); padding: 16px;">
|
||||
<MudPaper Elevation="1" Class="pa-4 rounded-lg">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-3" Style="font-weight: 700;">
|
||||
Benutzer für Firma: @context.Name
|
||||
</MudText>
|
||||
@if (context.Users == null || context.Users.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Keine Benutzer in dieser Firma registriert.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="2">
|
||||
@foreach (var user in context.Users)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudPaper Outlined="true" Class="pa-3 rounded-lg d-flex align-center justify-space-between" Style="background: white;">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudAvatar Color="user.IsTenantAdmin ? Color.Primary : Color.Default" Size="Size.Small">
|
||||
@user.Username.Substring(0, Math.Min(2, user.Username.Length)).ToUpper()
|
||||
</MudAvatar>
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.body2" Style="font-weight: 600;">@user.Username</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">ID: @user.Id</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
@if (user.IsTenantAdmin)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Text" Icon="@Icons.Material.Filled.AdminPanelSettings">Admin</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Default" Size="Size.Small" Variant="Variant.Text" Icon="@Icons.Material.Filled.Person">Mitarbeiter</MudChip>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
</MudPaper>
|
||||
</td>
|
||||
</MudTr>
|
||||
}
|
||||
</ChildRowContent>
|
||||
</MudTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool _loading = true;
|
||||
private List<TenantStats> _tenants = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadTenants();
|
||||
}
|
||||
|
||||
private async Task LoadTenants()
|
||||
{
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string?> 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<string?> 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNotificationService notifier) : IAuthService
|
||||
public class AuthService(
|
||||
IDbContextFactory<TimetrackerDbContext> factory,
|
||||
UserNotificationService notifier,
|
||||
ITenantProvider tenantProvider) : IAuthService
|
||||
{
|
||||
public async Task<User?> LoginAsync(string username, string password)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
// Global query filter automatically restricts lookup to current tenant context
|
||||
var user = await db.Users
|
||||
.FirstOrDefaultAsync(u => u.Username == username);
|
||||
if (user == null) return null;
|
||||
@@ -19,7 +27,9 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
||||
public async Task<List<User>> GetAllUsersAsync()
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
// Superadmin bypasses query filters to manage all users globally
|
||||
return await db.Users
|
||||
.IgnoreQueryFilters()
|
||||
.OrderBy(u => u.Username)
|
||||
.ToListAsync();
|
||||
}
|
||||
@@ -27,7 +37,7 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
||||
public async Task DeleteUserAsync(int userId)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user != null)
|
||||
{
|
||||
db.Users.Remove(user);
|
||||
@@ -43,12 +53,16 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
||||
return "Benutzername muss mindestens 3 Zeichen lang sein.";
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
if (await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId))
|
||||
return "Benutzername bereits vergeben.";
|
||||
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
// Find user ignoring filters so we can rename global admin too if needed
|
||||
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user == null) return "Benutzer nicht gefunden.";
|
||||
|
||||
// Check if name is taken within the same tenant context
|
||||
var isTaken = await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId && u.TenantId == user.TenantId);
|
||||
if (isTaken)
|
||||
return "Benutzername in dieser Firma bereits vergeben.";
|
||||
|
||||
user.Username = newUsername;
|
||||
await db.SaveChangesAsync();
|
||||
await notifier.NotifyUsersChangedAsync();
|
||||
@@ -61,7 +75,7 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
||||
return "Passwort muss mindestens 6 Zeichen lang sein.";
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var user = await db.Users.FindAsync(userId);
|
||||
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user == null) return "Benutzer nicht gefunden.";
|
||||
|
||||
if (!VerifyPassword(currentPassword, user.PasswordHash, user.PasswordSalt))
|
||||
@@ -75,24 +89,164 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null) {
|
||||
public async Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || username.Length < 3)
|
||||
return (null, "Benutzername muss mindestens 3 Zeichen lang sein.");
|
||||
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
|
||||
return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
|
||||
|
||||
var tenantId = tenantProvider.TenantId;
|
||||
if (tenantId == null)
|
||||
{
|
||||
return (null, "Mitarbeiter-Registrierung ist nur über die Firmen-Subdomain möglich.");
|
||||
}
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
// Filter handles checking uniqueness within the same tenant
|
||||
if (await db.Users.AnyAsync(u => u.Username == username))
|
||||
return (null, "Benutzername bereits vergeben.");
|
||||
return (null, "Benutzername in dieser Firma bereits vergeben.");
|
||||
|
||||
var (hash, salt) = HashPassword(password);
|
||||
var user = new User { Username = username, PasswordHash = hash, PasswordSalt = salt };
|
||||
var user = new User
|
||||
{
|
||||
Username = username,
|
||||
PasswordHash = hash,
|
||||
PasswordSalt = salt,
|
||||
TenantId = tenantId.Value
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
await notifier.NotifyUsersChangedAsync();
|
||||
return (user, null);
|
||||
}
|
||||
|
||||
public async Task<(Tenant? Tenant, string? Error)> RegisterFirmAsync(RegisterFirmRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.CompanyName) || req.CompanyName.Length < 3)
|
||||
return (null, "Firmenname muss mindestens 3 Zeichen lang sein.");
|
||||
if (string.IsNullOrWhiteSpace(req.Subdomain) || req.Subdomain.Length < 3)
|
||||
return (null, "Subdomain muss mindestens 3 Zeichen lang sein.");
|
||||
if (string.IsNullOrWhiteSpace(req.AdminUsername) || req.AdminUsername.Length < 3)
|
||||
return (null, "Administrator-Benutzername muss mindestens 3 Zeichen lang sein.");
|
||||
if (string.IsNullOrWhiteSpace(req.AdminPassword) || req.AdminPassword.Length < 6)
|
||||
return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
|
||||
|
||||
var subdomain = req.Subdomain.ToLower().Trim();
|
||||
if (!subdomain.All(c => char.IsLetterOrDigit(c) || c == '-'))
|
||||
{
|
||||
return (null, "Subdomain darf nur Buchstaben, Zahlen und Bindestriche enthalten.");
|
||||
}
|
||||
|
||||
var reservedSubdomains = new[] { "www", "api", "admin", "superadmin", "localhost", "mail", "portal", "app", "dev", "test" };
|
||||
if (reservedSubdomains.Contains(subdomain))
|
||||
{
|
||||
return (null, "Diese Subdomain ist reserviert und kann nicht verwendet werden.");
|
||||
}
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
|
||||
// Tenant checks are global, no filters applied to Tenants table
|
||||
var exists = await db.Tenants.AnyAsync(t => t.Subdomain == subdomain);
|
||||
if (exists)
|
||||
{
|
||||
return (null, "Diese Subdomain ist bereits vergeben.");
|
||||
}
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var tenant = new Tenant
|
||||
{
|
||||
Name = req.CompanyName,
|
||||
Subdomain = subdomain,
|
||||
IsApproved = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
db.Tenants.Add(tenant);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var (hash, salt) = HashPassword(req.AdminPassword);
|
||||
var user = new User
|
||||
{
|
||||
Username = req.AdminUsername,
|
||||
PasswordHash = hash,
|
||||
PasswordSalt = salt,
|
||||
TenantId = tenant.Id,
|
||||
IsTenantAdmin = true
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
await notifier.NotifyUsersChangedAsync();
|
||||
return (tenant, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
return (null, $"Registrierungsfehler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> AssignTenantAsync(int userId, int? tenantId)
|
||||
{
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
|
||||
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user == null) return "Benutzer nicht gefunden.";
|
||||
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
var tenantExists = await db.Tenants.AnyAsync(t => t.Id == tenantId.Value);
|
||||
if (!tenantExists) return "Firma existiert nicht.";
|
||||
}
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
user.TenantId = tenantId;
|
||||
|
||||
var settings = await db.AppSettings.IgnoreQueryFilters().Where(s => s.UserId == userId).ToListAsync();
|
||||
foreach (var s in settings) s.TenantId = tenantId;
|
||||
|
||||
var workDays = await db.WorkDays.IgnoreQueryFilters().Where(w => w.UserId == userId).ToListAsync();
|
||||
foreach (var w in workDays) w.TenantId = tenantId;
|
||||
|
||||
var vacationDays = await db.VacationDays.IgnoreQueryFilters().Where(v => v.UserId == userId).ToListAsync();
|
||||
foreach (var v in vacationDays) v.TenantId = tenantId;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
await notifier.NotifyUsersChangedAsync();
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
return $"Fehler beim Verschieben des Benutzers: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> ResetPasswordAsync(int userId, string newPassword)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newPassword) || newPassword.Length < 6)
|
||||
return "Passwort muss mindestens 6 Zeichen lang sein.";
|
||||
|
||||
await using var db = await factory.CreateDbContextAsync();
|
||||
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
|
||||
if (user == null) return "Benutzer nicht gefunden.";
|
||||
|
||||
var (hash, salt) = HashPassword(newPassword);
|
||||
user.PasswordHash = hash;
|
||||
user.PasswordSalt = salt;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (string hash, string salt) HashPassword(string password)
|
||||
{
|
||||
var saltBytes = RandomNumberGenerator.GetBytes(32);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Threading.Tasks;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public interface ITenantProvider
|
||||
{
|
||||
int? TenantId { get; }
|
||||
Task<Tenant?> GetCurrentTenantAsync();
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using timetracker.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TimetrackerDbContext))]
|
||||
[Migration("20260624204954_AddMultiTenancy")]
|
||||
partial class AddMultiTenancy
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("DailyTargetHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateOnly?>("FlexTimeStartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("FlexTimeStartingBalanceHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("GermanState")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinimumBreakMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VacationDaysPerYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkFriday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkMonday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSaturday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSunday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkThursday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkTuesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkWednesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("WorkDayId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkDayId");
|
||||
|
||||
b.ToTable("BreakEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Counties")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PublicHolidays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsApproved")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subdomain")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Subdomain")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tenants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.Tenant", "Tenant")
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId");
|
||||
|
||||
b.Navigation("Tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMultiTenancy : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TenantId",
|
||||
table: "WorkDays",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TenantId",
|
||||
table: "VacationDays",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TenantId",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TenantId",
|
||||
table: "AppSettings",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Tenants",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Subdomain = table.Column<string>(type: "TEXT", nullable: false),
|
||||
IsApproved = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Tenants", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_TenantId",
|
||||
table: "Users",
|
||||
column: "TenantId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Tenants_Subdomain",
|
||||
table: "Tenants",
|
||||
column: "Subdomain",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Users_Tenants_TenantId",
|
||||
table: "Users",
|
||||
column: "TenantId",
|
||||
principalTable: "Tenants",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Users_Tenants_TenantId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Tenants");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Users_TenantId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenantId",
|
||||
table: "WorkDays");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenantId",
|
||||
table: "VacationDays");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenantId",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TenantId",
|
||||
table: "AppSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using timetracker.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(TimetrackerDbContext))]
|
||||
[Migration("20260624210436_AddTenantBranding")]
|
||||
partial class AddTenantBranding
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("DailyTargetHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateOnly?>("FlexTimeStartDate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("FlexTimeStartingBalanceHours")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("GermanState")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MinimumBreakMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("VacationDaysPerYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkFriday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkMonday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSaturday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkSunday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkThursday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkTuesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("WorkWednesday")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("WorkDayId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("WorkDayId");
|
||||
|
||||
b.ToTable("BreakEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Counties")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PublicHolidays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsApproved")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PrimaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SecondaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subdomain")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Subdomain")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tenants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsTenantAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("EndTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.Tenant", "Tenant")
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId");
|
||||
|
||||
b.Navigation("Tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace timetracker.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTenantBranding : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsTenantAdmin",
|
||||
table: "Users",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "PrimaryColor",
|
||||
table: "Tenants",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "SecondaryColor",
|
||||
table: "Tenants",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsTenantAdmin",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PrimaryColor",
|
||||
table: "Tenants");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SecondaryColor",
|
||||
table: "Tenants");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ namespace timetracker.Data.Migrations
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.AppSettings", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -38,6 +38,9 @@ namespace timetracker.Data.Migrations
|
||||
b.Property<int>("MinimumBreakMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -70,7 +73,7 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -92,7 +95,7 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("BreakEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.PublicHoliday", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -113,12 +116,51 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("PublicHolidays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.User", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsApproved")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PrimaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SecondaryColor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subdomain")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Subdomain")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tenants");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsTenantAdmin")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
@@ -127,16 +169,21 @@ namespace timetracker.Data.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.VacationDay", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -148,6 +195,9 @@ namespace timetracker.Data.Migrations
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -156,7 +206,7 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("VacationDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -171,6 +221,9 @@ namespace timetracker.Data.Migrations
|
||||
b.Property<TimeOnly?>("StartTime")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TenantId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -179,9 +232,9 @@ namespace timetracker.Data.Migrations
|
||||
b.ToTable("WorkDays");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Data.WorkDay", "WorkDay")
|
||||
b.HasOne("timetracker.Shared.WorkDay", "WorkDay")
|
||||
.WithMany("Breaks")
|
||||
.HasForeignKey("WorkDayId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
@@ -190,7 +243,16 @@ namespace timetracker.Data.Migrations
|
||||
b.Navigation("WorkDay");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
|
||||
modelBuilder.Entity("timetracker.Shared.User", b =>
|
||||
{
|
||||
b.HasOne("timetracker.Shared.Tenant", "Tenant")
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId");
|
||||
|
||||
b.Navigation("Tenant");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||
{
|
||||
b.Navigation("Breaks");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class TenantProvider : ITenantProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IDbContextFactory<TimetrackerDbContext> _dbContextFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public TenantProvider(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IDbContextFactory<TimetrackerDbContext> dbContextFactory,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public int? TenantId
|
||||
{
|
||||
get
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext == null) return null;
|
||||
|
||||
if (httpContext.Items.TryGetValue("TenantId", out var cachedId))
|
||||
{
|
||||
return (int?)cachedId;
|
||||
}
|
||||
|
||||
// Resolve and cache in HttpContext.Items
|
||||
ResolveTenantAsync().GetAwaiter().GetResult();
|
||||
|
||||
if (httpContext.Items.TryGetValue("TenantId", out cachedId))
|
||||
{
|
||||
return (int?)cachedId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Tenant?> GetCurrentTenantAsync()
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext == null) return null;
|
||||
|
||||
if (httpContext.Items.TryGetValue("CurrentTenant", out var cachedTenant))
|
||||
{
|
||||
return (Tenant?)cachedTenant;
|
||||
}
|
||||
|
||||
await ResolveTenantAsync();
|
||||
|
||||
if (httpContext.Items.TryGetValue("CurrentTenant", out cachedTenant))
|
||||
{
|
||||
return (Tenant?)cachedTenant;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task ResolveTenantAsync()
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
if (httpContext == null) return;
|
||||
|
||||
// Prevent double resolution in the same request
|
||||
if (httpContext.Items.ContainsKey("TenantResolved")) return;
|
||||
httpContext.Items["TenantResolved"] = true;
|
||||
|
||||
var host = httpContext.Request.Host.Host;
|
||||
var subdomain = GetSubdomain(host);
|
||||
|
||||
if (!string.IsNullOrEmpty(subdomain))
|
||||
{
|
||||
await using var db = await _dbContextFactory.CreateDbContextAsync();
|
||||
var tenant = await db.Tenants
|
||||
.FirstOrDefaultAsync(t => t.Subdomain.ToLower() == subdomain.ToLower());
|
||||
|
||||
if (tenant != null)
|
||||
{
|
||||
httpContext.Items["CurrentTenant"] = tenant;
|
||||
httpContext.Items["TenantId"] = tenant.Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetSubdomain(string host)
|
||||
{
|
||||
var baseDomain = _configuration["TenantSettings:BaseDomain"] ?? "localhost";
|
||||
|
||||
// Ignore port if included in host
|
||||
var hostName = host.Split(':')[0];
|
||||
|
||||
if (hostName.Equals(baseDomain, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null; // Main domain
|
||||
}
|
||||
|
||||
if (hostName.EndsWith("." + baseDomain, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return hostName.Substring(0, hostName.Length - baseDomain.Length - 1);
|
||||
}
|
||||
|
||||
// Fallback for localhost testing
|
||||
if (baseDomain.Equals("localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parts = hostName.Split('.');
|
||||
if (parts.Length > 1 && !hostName.Equals("localhost", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,72 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using timetracker.Shared;
|
||||
|
||||
namespace timetracker.Data;
|
||||
|
||||
public class TimetrackerDbContext(DbContextOptions<TimetrackerDbContext> options) : DbContext(options)
|
||||
public class TimetrackerDbContext : DbContext
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private ITenantProvider? _tenantProvider;
|
||||
private ITenantProvider TenantProvider => _tenantProvider ??= _serviceProvider.GetRequiredService<ITenantProvider>();
|
||||
|
||||
public TimetrackerDbContext(
|
||||
DbContextOptions<TimetrackerDbContext> options,
|
||||
IServiceProvider serviceProvider) : base(options)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
}
|
||||
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<WorkDay> WorkDays => Set<WorkDay>();
|
||||
public DbSet<BreakEntry> BreakEntries => Set<BreakEntry>();
|
||||
public DbSet<AppSettings> AppSettings => Set<AppSettings>();
|
||||
public DbSet<VacationDay> VacationDays => Set<VacationDay>();
|
||||
public DbSet<PublicHoliday> PublicHolidays => Set<PublicHoliday>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Subdomain index unique
|
||||
modelBuilder.Entity<Tenant>()
|
||||
.HasIndex(t => t.Subdomain)
|
||||
.IsUnique();
|
||||
|
||||
// Global Query Filters (scopes database views by resolved TenantId)
|
||||
modelBuilder.Entity<User>().HasQueryFilter(u => u.TenantId == TenantProvider.TenantId);
|
||||
modelBuilder.Entity<WorkDay>().HasQueryFilter(w => w.TenantId == TenantProvider.TenantId);
|
||||
modelBuilder.Entity<VacationDay>().HasQueryFilter(v => v.TenantId == TenantProvider.TenantId);
|
||||
modelBuilder.Entity<AppSettings>().HasQueryFilter(s => s.TenantId == TenantProvider.TenantId);
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantId = TenantProvider.TenantId;
|
||||
|
||||
if (tenantId.HasValue)
|
||||
{
|
||||
foreach (var entry in ChangeTracker.Entries())
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
var tenantIdProp = entry.Entity.GetType().GetProperty("TenantId");
|
||||
if (tenantIdProp != null && tenantIdProp.CanWrite)
|
||||
{
|
||||
var currentValue = tenantIdProp.GetValue(entry.Entity);
|
||||
if (currentValue == null || (currentValue is int val && val == 0))
|
||||
{
|
||||
tenantIdProp.SetValue(entry.Entity, tenantId.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
+248
-10
@@ -47,6 +47,8 @@ builder.Services.AddAuthorization(options =>
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
builder.Services.AddTransient<ITenantProvider, TenantProvider>();
|
||||
|
||||
// Add SignalR
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddSingleton<UserNotificationService>();
|
||||
@@ -63,6 +65,31 @@ builder.Services.AddRazorComponents()
|
||||
|
||||
builder.Services.AddMudServices();
|
||||
builder.Services.AddHttpClient<HolidayService>();
|
||||
builder.Services.AddScoped(sp =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var navigationManager = sp.GetService<Microsoft.AspNetCore.Components.NavigationManager>();
|
||||
if (navigationManager != null && !string.IsNullOrEmpty(navigationManager.BaseUri))
|
||||
{
|
||||
return new System.Net.Http.HttpClient { BaseAddress = new Uri(navigationManager.BaseUri) };
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// NavigationManager is registered but not initialized in this request scope
|
||||
}
|
||||
|
||||
var httpContextAccessor = sp.GetService<Microsoft.AspNetCore.Http.IHttpContextAccessor>();
|
||||
var request = httpContextAccessor?.HttpContext?.Request;
|
||||
if (request != null)
|
||||
{
|
||||
var baseUri = $"{request.Scheme}://{request.Host}{request.PathBase}/";
|
||||
return new System.Net.Http.HttpClient { BaseAddress = new Uri(baseUri) };
|
||||
}
|
||||
|
||||
return new System.Net.Http.HttpClient();
|
||||
});
|
||||
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
@@ -162,54 +189,93 @@ app.MapGet("/api/auth/me", (HttpContext ctx) =>
|
||||
{
|
||||
var idClaim = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var name = ctx.User.Identity.Name ?? "";
|
||||
var isTenantAdmin = ctx.User.FindFirst("IsTenantAdmin")?.Value == "true";
|
||||
if (int.TryParse(idClaim, out var id))
|
||||
{
|
||||
return Results.Ok(new UserInfo { Id = id, Username = name });
|
||||
return Results.Ok(new UserInfo { Id = id, Username = name, IsTenantAdmin = isTenantAdmin });
|
||||
}
|
||||
}
|
||||
return Results.Unauthorized();
|
||||
});
|
||||
|
||||
app.MapPost("/api/auth/login", async (HttpContext ctx, [FromBody] LoginRequest req, IAuthService authService) =>
|
||||
app.MapPost("/api/auth/login", async (HttpContext ctx, [FromBody] LoginRequest req, IAuthService authService, ITenantProvider tenantProvider) =>
|
||||
{
|
||||
var tenant = await tenantProvider.GetCurrentTenantAsync();
|
||||
if (tenant != null && !tenant.IsApproved)
|
||||
{
|
||||
return Results.BadRequest("Diese Firma ist noch nicht freigeschaltet.");
|
||||
}
|
||||
|
||||
var user = await authService.LoginAsync(req.Username, req.Password);
|
||||
if (user == null)
|
||||
return Results.BadRequest("Benutzername oder Passwort falsch.");
|
||||
|
||||
var claims = new[] {
|
||||
if (tenant != null)
|
||||
{
|
||||
if (user.TenantId != tenant.Id)
|
||||
{
|
||||
return Results.BadRequest("Benutzer gehört nicht zu dieser Firma.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (user.Username != "marc")
|
||||
{
|
||||
return Results.BadRequest("Anmeldung auf der Hauptdomain nur für Administratoren.");
|
||||
}
|
||||
}
|
||||
|
||||
var claims = new List<Claim> {
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username)
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("IsTenantAdmin", user.IsTenantAdmin ? "true" : "false")
|
||||
};
|
||||
if (user.TenantId.HasValue)
|
||||
{
|
||||
claims.Add(new Claim("TenantId", user.TenantId.Value.ToString()));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity),
|
||||
new AuthenticationProperties { IsPersistent = true });
|
||||
|
||||
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username });
|
||||
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username, IsTenantAdmin = user.IsTenantAdmin });
|
||||
}).RequireRateLimiting("auth-limit");
|
||||
|
||||
app.MapPost("/api/auth/register", async (HttpContext ctx, [FromBody] RegisterRequest req, IAuthService authService) =>
|
||||
app.MapPost("/api/auth/register", async (HttpContext ctx, [FromBody] RegisterRequest req, IAuthService authService, ITenantProvider tenantProvider) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(req.Honeypot))
|
||||
{
|
||||
// Silently reject bots
|
||||
return Results.BadRequest("Registrierung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
var tenant = await tenantProvider.GetCurrentTenantAsync();
|
||||
if (tenant != null && !tenant.IsApproved)
|
||||
{
|
||||
return Results.BadRequest("Registrierung blockiert: Firma noch nicht freigeschaltet.");
|
||||
}
|
||||
|
||||
var (user, error) = await authService.RegisterAsync(req.Username, req.Password);
|
||||
if (user == null)
|
||||
return Results.BadRequest(error ?? "Registrierung fehlgeschlagen.");
|
||||
|
||||
var claims = new[] {
|
||||
var claims = new List<Claim> {
|
||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new Claim(ClaimTypes.Name, user.Username)
|
||||
new Claim(ClaimTypes.Name, user.Username),
|
||||
new Claim("IsTenantAdmin", user.IsTenantAdmin ? "true" : "false")
|
||||
};
|
||||
if (user.TenantId.HasValue)
|
||||
{
|
||||
claims.Add(new Claim("TenantId", user.TenantId.Value.ToString()));
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity),
|
||||
new AuthenticationProperties { IsPersistent = true });
|
||||
|
||||
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username });
|
||||
return Results.Ok(new UserInfo { Id = user.Id, Username = user.Username, IsTenantAdmin = user.IsTenantAdmin });
|
||||
}).RequireRateLimiting("auth-limit");
|
||||
|
||||
app.MapGet("/auth/logout", async (HttpContext ctx) =>
|
||||
@@ -253,9 +319,57 @@ usersApi.MapPut("/{userId:int}/change-password", async (int userId, [FromBody] C
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
usersApi.MapPut("/{userId:int}/assign-tenant", async (int userId, [FromBody] AssignTenantRequest req, IAuthService authService) =>
|
||||
{
|
||||
var error = await authService.AssignTenantAsync(userId, req.TenantId);
|
||||
if (error != null)
|
||||
{
|
||||
return Results.BadRequest(error);
|
||||
}
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
usersApi.MapPut("/{userId:int}/reset-password", async (int userId, [FromBody] ResetPasswordRequest req, IAuthService authService) =>
|
||||
{
|
||||
var error = await authService.ResetPasswordAsync(userId, req.NewPassword);
|
||||
if (error != null)
|
||||
{
|
||||
return Results.BadRequest(error);
|
||||
}
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
// ── Timetracker-API-Endpoints (Protected) ─────────────────────────────────────
|
||||
var trackerApi = app.MapGroup("/api/tracker").RequireAuthorization();
|
||||
|
||||
trackerApi.MapPost("/tenant/settings", async (ClaimsPrincipal claimsPrincipal, [FromBody] TenantSettingsDto dto, ITenantProvider tenantProvider, TimetrackerDbContext db) =>
|
||||
{
|
||||
var isAdmin = claimsPrincipal.FindFirst("IsTenantAdmin")?.Value == "true";
|
||||
if (!isAdmin) return Results.Forbid();
|
||||
|
||||
var tenant = await tenantProvider.GetCurrentTenantAsync();
|
||||
if (tenant == null) return Results.BadRequest("Keine Firma aktiv.");
|
||||
|
||||
var dbTenant = await db.Tenants.FindAsync(tenant.Id);
|
||||
if (dbTenant == null) return Results.NotFound("Firma nicht gefunden.");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dto.Name))
|
||||
{
|
||||
dbTenant.Name = dto.Name.Trim();
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(dto.PrimaryColor))
|
||||
{
|
||||
dbTenant.PrimaryColor = dto.PrimaryColor.Trim();
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(dto.SecondaryColor))
|
||||
{
|
||||
dbTenant.SecondaryColor = dto.SecondaryColor.Trim();
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
trackerApi.MapGet("/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService) =>
|
||||
{
|
||||
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
@@ -366,6 +480,130 @@ holidaysApi.MapDelete("/{id:int}", async (int id, IHolidayService holidayService
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.MapGet("/api/tenant/current", async (ITenantProvider tenantProvider) =>
|
||||
{
|
||||
var tenant = await tenantProvider.GetCurrentTenantAsync();
|
||||
if (tenant == null)
|
||||
{
|
||||
return Results.Ok(new TenantInfo { IsTenant = false });
|
||||
}
|
||||
return Results.Ok(new TenantInfo
|
||||
{
|
||||
IsTenant = true,
|
||||
Name = tenant.Name,
|
||||
Subdomain = tenant.Subdomain,
|
||||
IsApproved = tenant.IsApproved,
|
||||
PrimaryColor = tenant.PrimaryColor,
|
||||
SecondaryColor = tenant.SecondaryColor
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/api/auth/register-firm", async ([FromBody] RegisterFirmRequest req, IAuthService authService) =>
|
||||
{
|
||||
var (tenant, error) = await authService.RegisterFirmAsync(req);
|
||||
if (tenant == null)
|
||||
return Results.BadRequest(error ?? "Registrierung der Firma fehlgeschlagen.");
|
||||
|
||||
return Results.Ok(new { Success = true });
|
||||
}).RequireRateLimiting("auth-limit");
|
||||
|
||||
// ── SuperAdmin-API-Endpoints (Protected) ───────────────────────────────────────────
|
||||
var superAdminApi = app.MapGroup("/api/superadmin").RequireAuthorization("AdminOnly");
|
||||
|
||||
superAdminApi.MapGet("/tenants", async (TimetrackerDbContext db) =>
|
||||
{
|
||||
var tenants = await db.Tenants
|
||||
.OrderByDescending(t => t.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
var stats = new List<object>();
|
||||
foreach (var t in tenants)
|
||||
{
|
||||
var userCount = await db.Users.IgnoreQueryFilters().CountAsync(u => u.TenantId == t.Id);
|
||||
var workDaysCount = await db.WorkDays.IgnoreQueryFilters().CountAsync(w => w.TenantId == t.Id);
|
||||
var tenantUsers = await db.Users.IgnoreQueryFilters()
|
||||
.Where(u => u.TenantId == t.Id)
|
||||
.Select(u => new { u.Id, u.Username, u.IsTenantAdmin })
|
||||
.ToListAsync();
|
||||
|
||||
stats.Add(new
|
||||
{
|
||||
t.Id,
|
||||
t.Name,
|
||||
t.Subdomain,
|
||||
t.IsApproved,
|
||||
t.CreatedAt,
|
||||
UserCount = userCount,
|
||||
WorkDaysCount = workDaysCount,
|
||||
Users = tenantUsers
|
||||
});
|
||||
}
|
||||
return Results.Ok(stats);
|
||||
});
|
||||
|
||||
superAdminApi.MapPost("/tenants/{id:int}/approve", async (int id, TimetrackerDbContext db) =>
|
||||
{
|
||||
var tenant = await db.Tenants.FindAsync(id);
|
||||
if (tenant == null) return Results.NotFound();
|
||||
tenant.IsApproved = true;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
superAdminApi.MapPost("/tenants/{id:int}/toggle-active", async (int id, TimetrackerDbContext db) =>
|
||||
{
|
||||
var tenant = await db.Tenants.FindAsync(id);
|
||||
if (tenant == null) return Results.NotFound();
|
||||
tenant.IsApproved = !tenant.IsApproved;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
superAdminApi.MapDelete("/tenants/{id:int}", async (int id, TimetrackerDbContext db, UserNotificationService notifier) =>
|
||||
{
|
||||
var tenant = await db.Tenants.FindAsync(id);
|
||||
if (tenant == null) return Results.NotFound();
|
||||
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
var userIds = await db.Users.IgnoreQueryFilters()
|
||||
.Where(u => u.TenantId == id)
|
||||
.Select(u => u.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var workDays = await db.WorkDays.IgnoreQueryFilters().Where(w => w.TenantId == id).ToListAsync();
|
||||
db.WorkDays.RemoveRange(workDays);
|
||||
|
||||
var vacationDays = await db.VacationDays.IgnoreQueryFilters().Where(v => v.TenantId == id).ToListAsync();
|
||||
db.VacationDays.RemoveRange(vacationDays);
|
||||
|
||||
var settings = await db.AppSettings.IgnoreQueryFilters().Where(s => s.TenantId == id).ToListAsync();
|
||||
db.AppSettings.RemoveRange(settings);
|
||||
|
||||
var users = await db.Users.IgnoreQueryFilters().Where(u => u.TenantId == id).ToListAsync();
|
||||
db.Users.RemoveRange(users);
|
||||
|
||||
db.Tenants.Remove(tenant);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
|
||||
foreach (var uid in userIds)
|
||||
{
|
||||
await notifier.NotifyUserDeletedAsync(uid);
|
||||
}
|
||||
await notifier.NotifyUsersChangedAsync();
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
return Results.BadRequest($"Fehler beim Löschen der Firma: {ex.Message}");
|
||||
}
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── Models for Request Bodies ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace timetracker.Shared;
|
||||
|
||||
public record AssignTenantRequest(int? TenantId);
|
||||
@@ -8,4 +8,7 @@ public interface IAuthService
|
||||
Task<string?> RenameUserAsync(int userId, string newUsername);
|
||||
Task<string?> 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<string?> AssignTenantAsync(int userId, int? tenantId);
|
||||
Task<string?> ResetPasswordAsync(int userId, string newPassword);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace timetracker.Shared;
|
||||
|
||||
public record RegisterFirmRequest(
|
||||
string CompanyName,
|
||||
string Subdomain,
|
||||
string AdminUsername,
|
||||
string AdminPassword
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace timetracker.Shared;
|
||||
|
||||
public record ResetPasswordRequest(string NewPassword);
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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<TenantUserDto> 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ public class UserInfo
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Username { get; set; } = "";
|
||||
public bool IsTenantAdmin { get; set; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user