multi-tenant implementierung
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject IUserNotificationService UserNotificationService
|
@inject IUserNotificationService UserNotificationService
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
|
@inject HttpClient Http
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
@using System.Security.Claims
|
@using System.Security.Claims
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
<MudSnackbarProvider />
|
<MudSnackbarProvider />
|
||||||
|
|
||||||
<MudLayout>
|
<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>
|
<AuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="ToggleDrawer" Class="d-flex d-md-none mr-2" />
|
<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 _drawerOpen = true;
|
||||||
private bool _isDarkMode;
|
private bool _isDarkMode;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
UserNotificationService.OnUserDeleted += HandleUserDeleted;
|
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)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::deep .modern-drawer {
|
::deep .modern-drawer {
|
||||||
|
background-color: var(--mud-palette-drawer-background) !important;
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.08) !important;
|
border-right: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.1) !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;
|
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
|
|
||||||
/* Glassmorphism for the appbar */
|
/* Glassmorphism for the appbar */
|
||||||
::deep .mud-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;
|
backdrop-filter: blur(12px) !important;
|
||||||
-webkit-backdrop-filter: blur(12px) !important;
|
-webkit-backdrop-filter: blur(12px) !important;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
|
@inject HttpClient Http
|
||||||
<div class="nav-container onboarding-nav-menu">
|
<div class="nav-container onboarding-nav-menu">
|
||||||
@* --- Brand / Logo Section --- *@
|
@* --- Brand / Logo Section --- *@
|
||||||
<div class="nav-header">
|
<div class="nav-header">
|
||||||
@@ -37,6 +38,11 @@
|
|||||||
<span class="admin-divider-text">Admin</span>
|
<span class="admin-divider-text">Admin</span>
|
||||||
<hr class="admin-divider-line" />
|
<hr class="admin-divider-line" />
|
||||||
</div>
|
</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">
|
<MudTooltip Text="Benutzerverwaltung" Placement="Placement.Right">
|
||||||
<MudNavLink Href="admin/users" Icon="@Icons.Material.Filled.AdminPanelSettings" Class="custom-nav-link admin-link">
|
<MudNavLink Href="admin/users" Icon="@Icons.Material.Filled.AdminPanelSettings" Class="custom-nav-link admin-link">
|
||||||
Benutzerverwaltung
|
Benutzerverwaltung
|
||||||
@@ -61,7 +67,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="user-name">@context.User.Identity?.Name</span>
|
<span class="user-name">@context.User.Identity?.Name</span>
|
||||||
<span class="user-status">Online</span>
|
<span class="user-status">@_companyName</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -93,6 +99,23 @@
|
|||||||
[Parameter] public EventCallback OnToggleDrawer { get; set; }
|
[Parameter] public EventCallback OnToggleDrawer { get; set; }
|
||||||
|
|
||||||
private bool _showVersionBadge;
|
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)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::deep .logo-icon {
|
::deep .logo-icon {
|
||||||
color: #0EA5E9 !important; /* Sky 500 */
|
color: var(--mud-palette-secondary) !important;
|
||||||
font-size: 1.6rem !important;
|
font-size: 1.6rem !important;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
.brand-text {
|
.brand-text {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #FFFFFF;
|
color: var(--mud-palette-appbar-text);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
::deep .custom-nav-link,
|
::deep .custom-nav-link,
|
||||||
::deep .custom-nav-link .mud-nav-link-text,
|
::deep .custom-nav-link .mud-nav-link-text,
|
||||||
::deep .custom-nav-link .mud-nav-link-icon-default {
|
::deep .custom-nav-link .mud-nav-link-icon-default {
|
||||||
color: #94A3B8 !important; /* Slate 400 */
|
color: var(--mud-palette-drawer-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* NavLink hover */
|
/* NavLink hover */
|
||||||
@@ -115,15 +115,15 @@
|
|||||||
::deep .custom-nav-link:hover,
|
::deep .custom-nav-link:hover,
|
||||||
::deep .custom-nav-link:hover .mud-nav-link-text,
|
::deep .custom-nav-link:hover .mud-nav-link-text,
|
||||||
::deep .custom-nav-link:hover .mud-nav-link-icon-default {
|
::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) */
|
/* NavLink active state (Fixes active color contrast issue) */
|
||||||
::deep .custom-nav-link.active,
|
::deep .custom-nav-link.active,
|
||||||
::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;
|
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,
|
::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-text,
|
||||||
::deep .custom-nav-link.active .mud-nav-link-icon-default,
|
::deep .custom-nav-link.active .mud-nav-link-icon-default,
|
||||||
::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 */
|
/* Admin link color overrides & active state */
|
||||||
@@ -208,7 +208,7 @@
|
|||||||
::deep .user-avatar {
|
::deep .user-avatar {
|
||||||
width: 36px !important;
|
width: 36px !important;
|
||||||
height: 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;
|
color: #FFFFFF !important;
|
||||||
font-weight: 700 !important;
|
font-weight: 700 !important;
|
||||||
font-size: 0.875rem !important;
|
font-size: 0.875rem !important;
|
||||||
@@ -222,7 +222,7 @@
|
|||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
background-color: #10B981; /* Emerald 500 */
|
background-color: #10B981; /* Emerald 500 */
|
||||||
border: 2px solid #0F172A;
|
border: 2px solid var(--mud-palette-drawer-background);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
.user-name {
|
.user-name {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #FFFFFF;
|
color: var(--mud-palette-appbar-text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
@page "/admin/users"
|
@page "/admin/users"
|
||||||
@rendermode InteractiveWebAssembly
|
@rendermode InteractiveWebAssembly
|
||||||
@attribute [Authorize(Policy = "AdminOnly")]
|
@attribute [Authorize(Policy = "AdminOnly")]
|
||||||
|
@using System.Net.Http.Json
|
||||||
|
@using System.Security.Claims
|
||||||
@inject IAuthService AuthService
|
@inject IAuthService AuthService
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject IUserNotificationService UserNotificationService
|
@inject IUserNotificationService UserNotificationService
|
||||||
|
@inject HttpClient Http
|
||||||
|
@inject IDialogService DialogService
|
||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
<PageTitle>Benutzerverwaltung – Timetracker</PageTitle>
|
<PageTitle>Benutzerverwaltung – Timetracker</PageTitle>
|
||||||
@@ -42,6 +46,7 @@ else
|
|||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<MudTh><MudTableSortLabel SortBy="new Func<User, object>(u => u.Id)">ID</MudTableSortLabel></MudTh>
|
<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><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>
|
<MudTh Style="text-align:right">Aktionen</MudTh>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
<RowTemplate>
|
<RowTemplate>
|
||||||
@@ -74,6 +79,34 @@ else
|
|||||||
</MudStack>
|
</MudStack>
|
||||||
}
|
}
|
||||||
</MudTd>
|
</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">
|
<MudTd Style="text-align:right">
|
||||||
@if (_editUserId == context.Id)
|
@if (_editUserId == context.Id)
|
||||||
{
|
{
|
||||||
@@ -92,6 +125,12 @@ else
|
|||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
Size="Size.Small"
|
Size="Size.Small"
|
||||||
OnClick="@(() => StartEdit(context))" />
|
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")
|
@if (context.Username != "marc")
|
||||||
{
|
{
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
|
<MudIconButton Icon="@Icons.Material.Filled.DeleteOutline"
|
||||||
@@ -114,9 +153,11 @@ else
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
private List<User> _users = [];
|
private List<User> _users = [];
|
||||||
|
private List<TenantStats> _tenants = [];
|
||||||
private bool _loading = true;
|
private bool _loading = true;
|
||||||
private int? _editUserId;
|
private int? _editUserId;
|
||||||
private string _editUsername = "";
|
private string _editUsername = "";
|
||||||
|
private int? _editTenantId;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -125,13 +166,31 @@ else
|
|||||||
if (claim == null) return;
|
if (claim == null) return;
|
||||||
|
|
||||||
UserNotificationService.OnUsersChanged += RefreshUsers;
|
UserNotificationService.OnUsersChanged += RefreshUsers;
|
||||||
_users = await AuthService.GetAllUsersAsync();
|
|
||||||
|
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;
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RefreshUsers()
|
private async Task RefreshUsers()
|
||||||
{
|
{
|
||||||
_users = await AuthService.GetAllUsersAsync();
|
_users = await AuthService.GetAllUsersAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_tenants = await Http.GetFromJsonAsync<List<TenantStats>>("api/superadmin/tenants") ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore background refresh errors
|
||||||
|
}
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,27 +203,49 @@ else
|
|||||||
{
|
{
|
||||||
_editUserId = user.Id;
|
_editUserId = user.Id;
|
||||||
_editUsername = user.Username;
|
_editUsername = user.Username;
|
||||||
|
_editTenantId = user.TenantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CancelEdit()
|
private void CancelEdit()
|
||||||
{
|
{
|
||||||
_editUserId = null;
|
_editUserId = null;
|
||||||
_editUsername = "";
|
_editUsername = "";
|
||||||
|
_editTenantId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SaveRename(User user)
|
private async Task SaveRename(User user)
|
||||||
{
|
{
|
||||||
var trimmed = _editUsername.Trim();
|
var trimmed = _editUsername.Trim();
|
||||||
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
|
|
||||||
if (error != null)
|
// 1. Rename if username changed
|
||||||
|
if (trimmed != user.Username)
|
||||||
{
|
{
|
||||||
Snackbar.Add(error, Severity.Error);
|
var error = await AuthService.RenameUserAsync(user.Id, trimmed);
|
||||||
return;
|
if (error != null)
|
||||||
|
{
|
||||||
|
Snackbar.Add(error, Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
user.Username = trimmed;
|
||||||
}
|
}
|
||||||
user.Username = trimmed;
|
|
||||||
|
// 2. Move to tenant if tenant ID changed
|
||||||
|
if (_editTenantId != user.TenantId)
|
||||||
|
{
|
||||||
|
var error = await AuthService.AssignTenantAsync(user.Id, _editTenantId);
|
||||||
|
if (error != null)
|
||||||
|
{
|
||||||
|
Snackbar.Add(error, Severity.Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
user.TenantId = _editTenantId;
|
||||||
|
}
|
||||||
|
|
||||||
_editUserId = null;
|
_editUserId = null;
|
||||||
_editUsername = "";
|
_editUsername = "";
|
||||||
Snackbar.Add($"Benutzer umbenannt zu \"{trimmed}\".", Severity.Success);
|
_editTenantId = null;
|
||||||
|
|
||||||
|
Snackbar.Add("Benutzerdaten erfolgreich aktualisiert.", Severity.Success);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task DeleteUser(User user)
|
private async Task DeleteUser(User user)
|
||||||
@@ -173,4 +254,29 @@ else
|
|||||||
_users.Remove(user);
|
_users.Remove(user);
|
||||||
Snackbar.Add($"Benutzer \"{user.Username}\" gelöscht.", Severity.Info);
|
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">
|
<MudStack Spacing="4">
|
||||||
|
|
||||||
@* ── Header ── *@
|
@* ── 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">
|
<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">
|
<MudStack Spacing="0">
|
||||||
<MudText Typo="Typo.h5" Style="color: white; font-weight: 700;">Allgemeine Geschäftsbedingungen (AGB)</MudText>
|
<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: rgba(255,255,255,0.72);">
|
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72;">
|
||||||
Rechtlich absolut unverbindliches Kauderwelsch und Flachwitze
|
Rechtlich absolut unverbindliches Kauderwelsch und Flachwitze
|
||||||
</MudText>
|
</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|||||||
@@ -8,13 +8,12 @@
|
|||||||
<MudStack Spacing="4">
|
<MudStack Spacing="4">
|
||||||
|
|
||||||
@* ── Header ── *@
|
@* ── Header ── *@
|
||||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||||
Style="background: #1E293B; color:white;">
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
<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">
|
<MudStack Spacing="0">
|
||||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Changelog</MudText>
|
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Changelog</MudText>
|
||||||
<MudText Typo="Typo.caption" Style="color:rgba(255,255,255,0.72)">Versionshistorie & Änderungen</MudText>
|
<MudText Typo="Typo.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">Versionshistorie & Änderungen</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|||||||
@@ -21,29 +21,28 @@ else
|
|||||||
<MudStack Spacing="3">
|
<MudStack Spacing="3">
|
||||||
|
|
||||||
@* ── Wochen-Header ── *@
|
@* ── Wochen-Header ── *@
|
||||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-header"
|
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-header mud-theme-primary">
|
||||||
Style="background: #1E293B; color: white;">
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
<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">
|
<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
|
@_weekLabel
|
||||||
</MudText>
|
</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
|
@_weekSubLabel
|
||||||
</MudText>
|
</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||||
@if (!IsCurrentWeek)
|
@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">
|
OnClick="GoToCurrentWeek" Size="Size.Small">
|
||||||
Heute
|
Heute
|
||||||
</MudButton>
|
</MudButton>
|
||||||
}
|
}
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
<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>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -316,34 +315,34 @@ else
|
|||||||
|
|
||||||
@* ── Wochensumme ── *@
|
@* ── Wochensumme ── *@
|
||||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl onboarding-week-summary"
|
<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">
|
<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)" />
|
<MudIcon Icon="@Icons.Material.Filled.Summarize" Style="color: var(--mud-palette-primary-text); opacity: 0.8" />
|
||||||
<MudText Typo="Typo.h6" Style="color:white; font-weight:600">Wochensumme</MudText>
|
<MudText Typo="Typo.h6" Style="color: var(--mud-palette-primary-text); font-weight:600">Wochensumme</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
<MudGrid Spacing="3">
|
<MudGrid Spacing="3">
|
||||||
<MudItem xs="6" sm="3">
|
<MudItem xs="6" sm="3">
|
||||||
<MudStack Spacing="0">
|
<MudStack Spacing="0">
|
||||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Brutto</MudText>
|
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Brutto</MudText>
|
||||||
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekGross)</MudText>
|
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekGross)</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="6" sm="3">
|
<MudItem xs="6" sm="3">
|
||||||
<MudStack Spacing="0">
|
<MudStack Spacing="0">
|
||||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Pausen</MudText>
|
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Pausen</MudText>
|
||||||
<MudText Typo="Typo.h5" Style="color:white">@FormatTs(WeekBreaks)</MudText>
|
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text)">@FormatTs(WeekBreaks)</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="6" sm="3">
|
<MudItem xs="6" sm="3">
|
||||||
<MudStack Spacing="0">
|
<MudStack Spacing="0">
|
||||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Netto</MudText>
|
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Netto</MudText>
|
||||||
<MudText Typo="Typo.h5" Style="color:#90CAF9; font-weight:700">@FormatTs(WeekNet)</MudText>
|
<MudText Typo="Typo.h5" Color="Color.Secondary" Style="font-weight:700">@FormatTs(WeekNet)</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="6" sm="3">
|
<MudItem xs="6" sm="3">
|
||||||
<MudStack Spacing="0">
|
<MudStack Spacing="0">
|
||||||
<MudText Typo="Typo.overline" Style="color:rgba(255,255,255,0.6)">Gleitzeit</MudText>
|
<MudText Typo="Typo.overline" Style="color: var(--mud-palette-primary-text); opacity: 0.6">Gleitzeit</MudText>
|
||||||
<MudText Typo="Typo.h5" Style="@($"color:{(WeekOvertime >= TimeSpan.Zero ? "#A5D6A7" : "#FFCC80")}; font-weight:700")">
|
<MudText Typo="Typo.h5" Color="@(WeekOvertime >= TimeSpan.Zero ? Color.Success : Color.Warning)" Style="font-weight:700">
|
||||||
@FormatTs(WeekOvertime, sign: true)
|
@FormatTs(WeekOvertime, sign: true)
|
||||||
</MudText>
|
</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|||||||
@@ -1,149 +1,312 @@
|
|||||||
@page "/login"
|
@page "/login"
|
||||||
@rendermode InteractiveWebAssembly
|
@rendermode InteractiveWebAssembly
|
||||||
@attribute [AllowAnonymous]
|
@attribute [AllowAnonymous]
|
||||||
|
@using System.Net.Http.Json
|
||||||
@inject IAuthService AuthService
|
@inject IAuthService AuthService
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
|
@inject HttpClient Http
|
||||||
|
|
||||||
<PageTitle>Anmelden – Timetracker</PageTitle>
|
<PageTitle>@GetPageTitle()</PageTitle>
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
|
@if (_tenantInfo == null)
|
||||||
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
{
|
||||||
|
@* Loading indicator while resolving Tenant *@
|
||||||
@* ── Logo / Header ── *@
|
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16 text-center">
|
||||||
<MudStack AlignItems="AlignItems.Center" Spacing="1">
|
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||||
<MudIcon Icon="@Icons.Material.Filled.AccessTime"
|
<MudText Class="mt-4" Color="Color.Secondary">Lade Timetracker…</MudText>
|
||||||
Style="font-size:4rem; color:#0EA5E9" />
|
</MudContainer>
|
||||||
<MudText Typo="Typo.h4" Style="font-weight:700; color:#0EA5E9">Timetracker</MudText>
|
}
|
||||||
</MudStack>
|
else if (_tenantInfo.IsTenant && !_tenantInfo.IsApproved)
|
||||||
|
{
|
||||||
<MudPaper Elevation="4" Class="pa-6 rounded-xl" Style="width:100%">
|
@* Pending Approval Screen *@
|
||||||
@* ── Tab Navigation ── *@
|
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
|
||||||
<MudStack Row="true" Justify="Justify.Center" Class="mb-4">
|
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
||||||
<MudButton OnClick="@(() => SetTab(0))"
|
<MudStack AlignItems="AlignItems.Center" Spacing="1">
|
||||||
Variant="@(_activeTab == 0 ? Variant.Filled : Variant.Text)"
|
<MudIcon Icon="@Icons.Material.Filled.AccessTime" Style="font-size:4rem; color:#EAB308" />
|
||||||
Color="Color.Primary"
|
<MudText Typo="Typo.h4" Style="font-weight:700; color:#EAB308">Timetracker</MudText>
|
||||||
Style="min-width: 120px; border-radius: 20px;">
|
|
||||||
Anmelden
|
|
||||||
</MudButton>
|
|
||||||
<MudButton OnClick="@(() => SetTab(1))"
|
|
||||||
Variant="@(_activeTab == 1 ? Variant.Filled : Variant.Text)"
|
|
||||||
Color="Color.Primary"
|
|
||||||
Style="min-width: 120px; border-radius: 20px;">
|
|
||||||
Registrieren
|
|
||||||
</MudButton>
|
|
||||||
</MudStack>
|
</MudStack>
|
||||||
|
|
||||||
<MudDivider Class="mb-6" />
|
<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>
|
||||||
|
|
||||||
@if (_activeTab == 0)
|
<style>
|
||||||
{
|
@@keyframes pulse {
|
||||||
@* ── Login Form ── *@
|
0% { opacity: 0.6; transform: scale(1); }
|
||||||
<MudStack Spacing="3">
|
50% { opacity: 1; transform: scale(1.05); }
|
||||||
@if (_error != null)
|
100% { opacity: 0.6; transform: scale(1); }
|
||||||
{
|
}
|
||||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
</style>
|
||||||
}
|
}
|
||||||
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin">
|
else
|
||||||
<MudStack Spacing="3">
|
{
|
||||||
<MudTextField T="string"
|
@* Main login/registration container *@
|
||||||
Label="Benutzername"
|
<MudContainer MaxWidth="MaxWidth.Small" Class="mt-16">
|
||||||
Variant="Variant.Outlined"
|
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
||||||
Adornment="Adornment.Start"
|
|
||||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
|
||||||
@bind-Value="_loginModel.Username"
|
|
||||||
Required="true"
|
|
||||||
AutoFocus="true" />
|
|
||||||
<MudTextField T="string"
|
|
||||||
Label="Passwort"
|
|
||||||
Variant="Variant.Outlined"
|
|
||||||
Adornment="Adornment.Start"
|
|
||||||
AdornmentIcon="@Icons.Material.Filled.Lock"
|
|
||||||
InputType="InputType.Password"
|
|
||||||
@bind-Value="_loginModel.Password"
|
|
||||||
Required="true" />
|
|
||||||
<MudButton ButtonType="ButtonType.Submit"
|
|
||||||
Variant="Variant.Filled"
|
|
||||||
Color="Color.Primary"
|
|
||||||
FullWidth="true"
|
|
||||||
Size="Size.Large"
|
|
||||||
StartIcon="@Icons.Material.Filled.Login"
|
|
||||||
Class="mt-2"
|
|
||||||
Disabled="_loading">
|
|
||||||
@if (_loading)
|
|
||||||
{
|
|
||||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
|
||||||
}
|
|
||||||
Anmelden
|
|
||||||
</MudButton>
|
|
||||||
</MudStack>
|
|
||||||
</EditForm>
|
|
||||||
</MudStack>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@* ── Register Form ── *@
|
|
||||||
<MudStack Spacing="3">
|
|
||||||
@if (_error != null)
|
|
||||||
{
|
|
||||||
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
|
||||||
}
|
|
||||||
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister">
|
|
||||||
<MudStack Spacing="3">
|
|
||||||
<MudTextField T="string"
|
|
||||||
Label="Benutzername"
|
|
||||||
Variant="Variant.Outlined"
|
|
||||||
Adornment="Adornment.Start"
|
|
||||||
AdornmentIcon="@Icons.Material.Filled.Person"
|
|
||||||
@bind-Value="_registerModel.Username"
|
|
||||||
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="_registerModel.Password"
|
|
||||||
Required="true"
|
|
||||||
HelperText="Mindestens 6 Zeichen" />
|
|
||||||
<input type="text" style="display: none;" tabindex="-1" autocomplete="off" @bind="_honeypot" />
|
|
||||||
|
|
||||||
<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"
|
@* ── Logo / Header ── *@
|
||||||
Variant="Variant.Filled"
|
<MudStack AlignItems="AlignItems.Center" Spacing="1">
|
||||||
Color="Color.Secondary"
|
<MudIcon Icon="@Icons.Material.Filled.AccessTime"
|
||||||
FullWidth="true"
|
Style="font-size:4rem; color:#0EA5E9" />
|
||||||
Size="Size.Large"
|
<MudText Typo="Typo.h4" Style="font-weight:700; color:#0EA5E9">
|
||||||
StartIcon="@Icons.Material.Filled.PersonAdd"
|
@(_tenantInfo.IsTenant ? _tenantInfo.Name : "Timetracker SaaS")
|
||||||
Class="mt-2"
|
</MudText>
|
||||||
Disabled="_loading || !_acceptAgb">
|
@if (!_tenantInfo.IsTenant)
|
||||||
@if (_loading)
|
{
|
||||||
{
|
<MudText Typo="Typo.caption" Color="Color.Secondary">Zentrales Portal</MudText>
|
||||||
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
}
|
||||||
}
|
</MudStack>
|
||||||
Konto erstellen
|
|
||||||
</MudButton>
|
<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))"
|
||||||
|
Variant="@(_activeTab == 0 ? Variant.Filled : Variant.Text)"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Style="min-width: 120px; border-radius: 20px;">
|
||||||
|
Anmelden
|
||||||
|
</MudButton>
|
||||||
|
<MudButton OnClick="@(() => SetTab(1))"
|
||||||
|
Variant="@(_activeTab == 1 ? Variant.Filled : Variant.Text)"
|
||||||
|
Color="Color.Primary"
|
||||||
|
Style="min-width: 120px; border-radius: 20px;">
|
||||||
|
@(_tenantInfo.IsTenant ? "Registrieren" : "Firma registrieren")
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
|
||||||
|
<MudDivider Class="mb-6" />
|
||||||
|
|
||||||
|
@if (_activeTab == 0)
|
||||||
|
{
|
||||||
|
@* ── Login Form ── *@
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
@if (_error != null)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||||
|
}
|
||||||
|
<EditForm Model="@_loginModel" OnValidSubmit="HandleLogin">
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudTextField T="string"
|
||||||
|
Label="Benutzername"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Adornment="Adornment.Start"
|
||||||
|
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||||
|
@bind-Value="_loginModel.Username"
|
||||||
|
Required="true"
|
||||||
|
AutoFocus="true" />
|
||||||
|
<MudTextField T="string"
|
||||||
|
Label="Passwort"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Adornment="Adornment.Start"
|
||||||
|
AdornmentIcon="@Icons.Material.Filled.Lock"
|
||||||
|
InputType="InputType.Password"
|
||||||
|
@bind-Value="_loginModel.Password"
|
||||||
|
Required="true" />
|
||||||
|
<MudButton ButtonType="ButtonType.Submit"
|
||||||
|
Variant="Variant.Filled"
|
||||||
|
Color="Color.Primary"
|
||||||
|
FullWidth="true"
|
||||||
|
Size="Size.Large"
|
||||||
|
StartIcon="@Icons.Material.Filled.Login"
|
||||||
|
Class="mt-2"
|
||||||
|
Disabled="_loading">
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||||
|
}
|
||||||
|
Anmelden
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</EditForm>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</EditForm>
|
}
|
||||||
</MudStack>
|
else
|
||||||
}
|
{
|
||||||
</MudPaper>
|
@if (_tenantInfo.IsTenant)
|
||||||
</MudStack>
|
{
|
||||||
</MudContainer>
|
@* ── Employee Register Form ── *@
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
@if (_error != null)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Error" Dense="true">@_error</MudAlert>
|
||||||
|
}
|
||||||
|
<EditForm Model="@_registerModel" OnValidSubmit="HandleRegister">
|
||||||
|
<MudStack Spacing="3">
|
||||||
|
<MudTextField T="string"
|
||||||
|
Label="Benutzername"
|
||||||
|
Variant="Variant.Outlined"
|
||||||
|
Adornment="Adornment.Start"
|
||||||
|
AdornmentIcon="@Icons.Material.Filled.Person"
|
||||||
|
@bind-Value="_registerModel.Username"
|
||||||
|
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="_registerModel.Password"
|
||||||
|
Required="true"
|
||||||
|
HelperText="Mindestens 6 Zeichen" />
|
||||||
|
<input type="text" style="display: none;" tabindex="-1" autocomplete="off" @bind="_honeypot" />
|
||||||
|
|
||||||
|
<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.PersonAdd"
|
||||||
|
Class="mt-2"
|
||||||
|
Disabled="_loading || !_acceptAgb">
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="mr-2" />
|
||||||
|
}
|
||||||
|
Konto erstellen
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
private TenantInfo? _tenantInfo;
|
||||||
private int _activeTab = 0;
|
private int _activeTab = 0;
|
||||||
private string? _error;
|
private string? _error;
|
||||||
private bool _loading;
|
private bool _loading;
|
||||||
private string _honeypot = "";
|
private string _honeypot = "";
|
||||||
private bool _acceptAgb;
|
private bool _acceptAgb;
|
||||||
|
private bool _firmRegisteredSuccessfully;
|
||||||
|
|
||||||
private readonly AuthModel _loginModel = new();
|
private readonly AuthModel _loginModel = new();
|
||||||
private readonly AuthModel _registerModel = new();
|
private readonly AuthModel _registerModel = new();
|
||||||
|
private readonly FirmModel _firmModel = new();
|
||||||
|
|
||||||
[SupplyParameterFromQuery(Name = "error")]
|
[SupplyParameterFromQuery(Name = "error")]
|
||||||
public string? ErrorParam { get; set; }
|
public string? ErrorParam { get; set; }
|
||||||
@@ -151,6 +314,20 @@
|
|||||||
[SupplyParameterFromQuery(Name = "tab")]
|
[SupplyParameterFromQuery(Name = "tab")]
|
||||||
public string? TabParam { get; set; }
|
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()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
_error = ErrorParam switch
|
_error = ErrorParam switch
|
||||||
@@ -168,6 +345,35 @@
|
|||||||
_error = null;
|
_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()
|
private async Task HandleLogin()
|
||||||
{
|
{
|
||||||
_loading = true;
|
_loading = true;
|
||||||
@@ -177,7 +383,7 @@
|
|||||||
var user = await AuthService.LoginAsync(_loginModel.Username, _loginModel.Password);
|
var user = await AuthService.LoginAsync(_loginModel.Username, _loginModel.Password);
|
||||||
if (user != null)
|
if (user != null)
|
||||||
{
|
{
|
||||||
Nav.NavigateTo("/", forceLoad: true); // forceLoad forces state update/re-render of the root app
|
Nav.NavigateTo("/", forceLoad: true);
|
||||||
}
|
}
|
||||||
else
|
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
|
private class AuthModel
|
||||||
{
|
{
|
||||||
public string Username { get; set; } = "";
|
public string Username { get; set; } = "";
|
||||||
public string Password { 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">
|
<MudStack Spacing="3">
|
||||||
|
|
||||||
@* ── Monats-Header ── *@
|
@* ── Monats-Header ── *@
|
||||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||||
Style="background: #1E293B; color: white;">
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronLeft"
|
<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">
|
<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
|
@_deCulture.DateTimeFormat.GetMonthName(_month) @_year
|
||||||
</MudText>
|
</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
|
@_subLabel
|
||||||
</MudText>
|
</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="0">
|
||||||
@if (!IsCurrentMonth)
|
@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">
|
OnClick="GoToCurrentMonth" Size="Size.Small">
|
||||||
Heute
|
Heute
|
||||||
</MudButton>
|
</MudButton>
|
||||||
}
|
}
|
||||||
<MudIconButton Icon="@Icons.Material.Filled.ChevronRight"
|
<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>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -95,7 +94,7 @@ else
|
|||||||
|
|
||||||
@* ── Monatszusammenfassung ── *@
|
@* ── Monatszusammenfassung ── *@
|
||||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
<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">
|
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2" Class="mb-4">
|
||||||
<MudIcon Icon="@Icons.Material.Filled.CalendarViewMonth" Color="Color.Primary" />
|
<MudIcon Icon="@Icons.Material.Filled.CalendarViewMonth" Color="Color.Primary" />
|
||||||
<MudText Typo="Typo.h6" Style="font-weight:700">Monatszusammenfassung</MudText>
|
<MudText Typo="Typo.h6" Style="font-weight:700">Monatszusammenfassung</MudText>
|
||||||
|
|||||||
@@ -22,13 +22,12 @@ else
|
|||||||
<MudStack Spacing="4">
|
<MudStack Spacing="4">
|
||||||
|
|
||||||
@* ── Header ── *@
|
@* ── Header ── *@
|
||||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||||
Style="background: #1E293B; color:white;">
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
<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">
|
<MudStack Spacing="0">
|
||||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Mein Profil</MudText>
|
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Mein Profil</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;">
|
||||||
Persönliche Details verwalten und Account sichern
|
Persönliche Details verwalten und Account sichern
|
||||||
</MudText>
|
</MudText>
|
||||||
</MudStack>
|
</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 AuthenticationStateProvider AuthStateProvider
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
|
@inject HttpClient Http
|
||||||
|
|
||||||
|
|
||||||
<PageTitle>Einstellungen – Timetracker</PageTitle>
|
<PageTitle>Einstellungen – Timetracker</PageTitle>
|
||||||
@@ -22,13 +23,12 @@ else
|
|||||||
<MudStack Spacing="4">
|
<MudStack Spacing="4">
|
||||||
|
|
||||||
@* ── Header ── *@
|
@* ── Header ── *@
|
||||||
<MudPaper Elevation="4" Class="pa-5 rounded-xl"
|
<MudPaper Elevation="4" Class="pa-5 rounded-xl mud-theme-primary">
|
||||||
Style="background: #1E293B; color:white;">
|
|
||||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
|
<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">
|
<MudStack Spacing="0">
|
||||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Einstellungen</MudText>
|
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); font-weight:700">Einstellungen</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">
|
||||||
Arbeitszeit, Arbeitstage und Urlaub konfigurieren
|
Arbeitszeit, Arbeitstage und Urlaub konfigurieren
|
||||||
</MudText>
|
</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
@@ -217,6 +217,52 @@ else
|
|||||||
</MudCard>
|
</MudCard>
|
||||||
</MudItem>
|
</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>
|
</MudGrid>
|
||||||
|
|
||||||
@* ── Speichern-Button ── *@
|
@* ── Speichern-Button ── *@
|
||||||
@@ -467,6 +513,11 @@ else
|
|||||||
|
|
||||||
private AppSettings? _settings;
|
private AppSettings? _settings;
|
||||||
private int _userId;
|
private int _userId;
|
||||||
|
private bool _isTenantAdmin;
|
||||||
|
private TenantInfo? _tenantInfo;
|
||||||
|
private string _tenantName = "";
|
||||||
|
private string _tenantPrimaryColor = "#0EA5E9";
|
||||||
|
private string _tenantSecondaryColor = "#0EA5E9";
|
||||||
private DateTime? _flexStartDate
|
private DateTime? _flexStartDate
|
||||||
{
|
{
|
||||||
get => _settings?.FlexTimeStartDate?.ToDateTime(TimeOnly.MinValue);
|
get => _settings?.FlexTimeStartDate?.ToDateTime(TimeOnly.MinValue);
|
||||||
@@ -526,6 +577,25 @@ else
|
|||||||
_userId = int.Parse(claim.Value);
|
_userId = int.Parse(claim.Value);
|
||||||
_settings = await TrackerService.GetSettingsAsync(_userId);
|
_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 loadVacationsTask = LoadVacations();
|
||||||
var loadHolidaysTask = HolidayService.GetHolidaysAsync(_holYear, _settings.GermanState);
|
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)
|
private static string FormatHours(double hours)
|
||||||
{
|
{
|
||||||
var ts = TimeSpan.FromHours(hours);
|
var ts = TimeSpan.FromHours(hours);
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ else
|
|||||||
<MudStack Spacing="4">
|
<MudStack Spacing="4">
|
||||||
|
|
||||||
@* ── Header ── *@
|
@* ── 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">
|
<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">
|
<MudStack Spacing="0">
|
||||||
<MudText Typo="Typo.h5" Style="color:white; font-weight:700">Statistiken</MudText>
|
<MudText Typo="Typo.h5" Style="color: var(--mud-palette-primary-text); 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.caption" Style="color: var(--mud-palette-primary-text); opacity: 0.72">Auswertung deiner Arbeitsleistung und Gleitzeitkonto</MudText>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudStack>
|
</MudStack>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
@@ -46,7 +46,7 @@ else
|
|||||||
<div style="position:relative; display:inline-flex;">
|
<div style="position:relative; display:inline-flex;">
|
||||||
<MudProgressCircular Value="@pct" Color="Color.Secondary" Size="Size.Large" StrokeWidth="6" Style="height:120px; width:120px;" />
|
<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;">
|
<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>
|
<MudText Typo="Typo.caption" Color="Color.Secondary">Erreicht</MudText>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +54,7 @@ else
|
|||||||
<MudStack Spacing="1" Style="width:100%;" Class="mt-2">
|
<MudStack Spacing="1" Style="width:100%;" Class="mt-2">
|
||||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Arbeitszeit (Ist):</MudText>
|
<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>
|
||||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Wochensoll (Soll):</MudText>
|
<MudText Typo="Typo.body2" Color="Color.Secondary">Wochensoll (Soll):</MudText>
|
||||||
@@ -68,7 +68,7 @@ else
|
|||||||
|
|
||||||
@* ── Card 2: Wochensaldo ── *@
|
@* ── Card 2: Wochensaldo ── *@
|
||||||
<MudItem xs="12" sm="6" md="4">
|
<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>
|
<MudCardContent>
|
||||||
<MudStack Spacing="2">
|
<MudStack Spacing="2">
|
||||||
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Wochensaldo</MudText>
|
<MudText Typo="Typo.subtitle1" Style="font-weight:700; color:#475569">Wochensaldo</MudText>
|
||||||
@@ -115,7 +115,7 @@ else
|
|||||||
<MudCard Elevation="3" Class="rounded-xl">
|
<MudCard Elevation="3" Class="rounded-xl">
|
||||||
<MudCardHeader>
|
<MudCardHeader>
|
||||||
<CardHeaderContent>
|
<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>
|
<MudText Typo="Typo.caption" Color="Color.Secondary">Geleistete Nettoarbeitsstunden pro Wochentag</MudText>
|
||||||
</CardHeaderContent>
|
</CardHeaderContent>
|
||||||
</MudCardHeader>
|
</MudCardHeader>
|
||||||
@@ -148,8 +148,8 @@ else
|
|||||||
{
|
{
|
||||||
double val = (_maxHoursValue / 4.0) * i;
|
double val = (_maxHoursValue / 4.0) * i;
|
||||||
double yPos = plotBottom - (val / _maxHoursValue) * plotHeight;
|
double yPos = plotBottom - (val / _maxHoursValue) * plotHeight;
|
||||||
<line x1="@plotLeft" y1="@yPos" x2="@plotRight" y2="@yPos" stroke="#E2E8F0" stroke-width="1" stroke-dasharray="2" />
|
<line x1="@F(plotLeft)" y1="@F(yPos)" x2="@F(plotRight)" y2="@F(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>")
|
@((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 -->
|
<!-- Bars and Labels -->
|
||||||
@@ -163,7 +163,7 @@ else
|
|||||||
string fillCol = "#94A3B8"; // Default light slate
|
string fillCol = "#94A3B8"; // Default light slate
|
||||||
if (d.IsToday)
|
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)
|
else if (d.WorkedHours >= d.TargetHours && d.TargetHours > 0)
|
||||||
{
|
{
|
||||||
@@ -183,7 +183,7 @@ else
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- Render Rect with hover tooltip -->
|
<!-- 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">
|
rx="6" fill="@fillCol" class="chart-bar">
|
||||||
<title>@d.DayName: @FormatHours(d.WorkedHours) Std. (Soll: @FormatHours(d.TargetHours) Std.)</title>
|
<title>@d.DayName: @FormatHours(d.WorkedHours) Std. (Soll: @FormatHours(d.TargetHours) Std.)</title>
|
||||||
</rect>
|
</rect>
|
||||||
@@ -191,19 +191,19 @@ else
|
|||||||
<!-- Value Label above bar -->
|
<!-- Value Label above bar -->
|
||||||
@if (d.WorkedHours > 0)
|
@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 -->
|
<!-- 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) -->
|
<!-- Sollzeit Target Line (Only if target hours > 0) -->
|
||||||
@if (_settings.DailyTargetHours > 0)
|
@if (_settings.DailyTargetHours > 0)
|
||||||
{
|
{
|
||||||
double yTarget = plotBottom - (_settings.DailyTargetHours / _maxHoursValue) * plotHeight;
|
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;" />
|
<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=\"{plotRight - 10}\" y=\"{yTarget - 6}\" fill=\"#EF4444\" font-size=\"10\" font-weight=\"700\" text-anchor=\"end\">Soll ({_settings.DailyTargetHours} h)</text>")
|
@((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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,6 +269,7 @@ else
|
|||||||
private Dictionary<DateOnly, string> _dbMatchStatus = new();
|
private Dictionary<DateOnly, string> _dbMatchStatus = new();
|
||||||
|
|
||||||
private static readonly System.Globalization.CultureInfo _deCulture = new("de-DE");
|
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()
|
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;
|
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[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()),
|
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");
|
var identity = new ClaimsIdentity(claims, "Cookie");
|
||||||
_currentUser = new ClaimsPrincipal(identity);
|
_currentUser = new ClaimsPrincipal(identity);
|
||||||
@@ -58,7 +60,9 @@ public class HostAuthenticationStateProvider : AuthenticationStateProvider
|
|||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim(ClaimTypes.NameIdentifier, userInfo.Id.ToString()),
|
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");
|
var identity = new ClaimsIdentity(claims, "Cookie");
|
||||||
_currentUser = new ClaimsPrincipal(identity);
|
_currentUser = new ClaimsPrincipal(identity);
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using timetracker.Shared;
|
using timetracker.Shared;
|
||||||
|
|
||||||
namespace timetracker.Data;
|
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)
|
public async Task<User?> LoginAsync(string username, string password)
|
||||||
{
|
{
|
||||||
await using var db = await factory.CreateDbContextAsync();
|
await using var db = await factory.CreateDbContextAsync();
|
||||||
|
// Global query filter automatically restricts lookup to current tenant context
|
||||||
var user = await db.Users
|
var user = await db.Users
|
||||||
.FirstOrDefaultAsync(u => u.Username == username);
|
.FirstOrDefaultAsync(u => u.Username == username);
|
||||||
if (user == null) return null;
|
if (user == null) return null;
|
||||||
@@ -19,7 +27,9 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
|||||||
public async Task<List<User>> GetAllUsersAsync()
|
public async Task<List<User>> GetAllUsersAsync()
|
||||||
{
|
{
|
||||||
await using var db = await factory.CreateDbContextAsync();
|
await using var db = await factory.CreateDbContextAsync();
|
||||||
|
// Superadmin bypasses query filters to manage all users globally
|
||||||
return await db.Users
|
return await db.Users
|
||||||
|
.IgnoreQueryFilters()
|
||||||
.OrderBy(u => u.Username)
|
.OrderBy(u => u.Username)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
@@ -27,7 +37,7 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
|||||||
public async Task DeleteUserAsync(int userId)
|
public async Task DeleteUserAsync(int userId)
|
||||||
{
|
{
|
||||||
await using var db = await factory.CreateDbContextAsync();
|
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)
|
if (user != null)
|
||||||
{
|
{
|
||||||
db.Users.Remove(user);
|
db.Users.Remove(user);
|
||||||
@@ -43,12 +53,16 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
|||||||
return "Benutzername muss mindestens 3 Zeichen lang sein.";
|
return "Benutzername muss mindestens 3 Zeichen lang sein.";
|
||||||
|
|
||||||
await using var db = await factory.CreateDbContextAsync();
|
await using var db = await factory.CreateDbContextAsync();
|
||||||
if (await db.Users.AnyAsync(u => u.Username == newUsername && u.Id != userId))
|
|
||||||
return "Benutzername bereits vergeben.";
|
// Find user ignoring filters so we can rename global admin too if needed
|
||||||
|
var user = await db.Users.IgnoreQueryFilters().FirstOrDefaultAsync(u => u.Id == userId);
|
||||||
var user = await db.Users.FindAsync(userId);
|
|
||||||
if (user == null) return "Benutzer nicht gefunden.";
|
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;
|
user.Username = newUsername;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await notifier.NotifyUsersChangedAsync();
|
await notifier.NotifyUsersChangedAsync();
|
||||||
@@ -61,7 +75,7 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
|||||||
return "Passwort muss mindestens 6 Zeichen lang sein.";
|
return "Passwort muss mindestens 6 Zeichen lang sein.";
|
||||||
|
|
||||||
await using var db = await factory.CreateDbContextAsync();
|
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 (user == null) return "Benutzer nicht gefunden.";
|
||||||
|
|
||||||
if (!VerifyPassword(currentPassword, user.PasswordHash, user.PasswordSalt))
|
if (!VerifyPassword(currentPassword, user.PasswordHash, user.PasswordSalt))
|
||||||
@@ -75,24 +89,164 @@ public class AuthService(IDbContextFactory<TimetrackerDbContext> factory, UserNo
|
|||||||
return null;
|
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)
|
if (string.IsNullOrWhiteSpace(username) || username.Length < 3)
|
||||||
return (null, "Benutzername muss mindestens 3 Zeichen lang sein.");
|
return (null, "Benutzername muss mindestens 3 Zeichen lang sein.");
|
||||||
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
|
if (string.IsNullOrWhiteSpace(password) || password.Length < 6)
|
||||||
return (null, "Passwort muss mindestens 6 Zeichen lang sein.");
|
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();
|
await using var db = await factory.CreateDbContextAsync();
|
||||||
|
// Filter handles checking uniqueness within the same tenant
|
||||||
if (await db.Users.AnyAsync(u => u.Username == username))
|
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 (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);
|
db.Users.Add(user);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await notifier.NotifyUsersChangedAsync();
|
await notifier.NotifyUsersChangedAsync();
|
||||||
return (user, null);
|
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)
|
private static (string hash, string salt) HashPassword(string password)
|
||||||
{
|
{
|
||||||
var saltBytes = RandomNumberGenerator.GetBytes(32);
|
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
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.8");
|
||||||
|
|
||||||
modelBuilder.Entity("timetracker.Data.AppSettings", b =>
|
modelBuilder.Entity("timetracker.Shared.AppSettings", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -38,6 +38,9 @@ namespace timetracker.Data.Migrations
|
|||||||
b.Property<int>("MinimumBreakMinutes")
|
b.Property<int>("MinimumBreakMinutes")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("TenantId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
b.Property<int>("UserId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -70,7 +73,7 @@ namespace timetracker.Data.Migrations
|
|||||||
b.ToTable("AppSettings");
|
b.ToTable("AppSettings");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("timetracker.Data.BreakEntry", b =>
|
modelBuilder.Entity("timetracker.Shared.BreakEntry", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -92,7 +95,7 @@ namespace timetracker.Data.Migrations
|
|||||||
b.ToTable("BreakEntries");
|
b.ToTable("BreakEntries");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("timetracker.Data.PublicHoliday", b =>
|
modelBuilder.Entity("timetracker.Shared.PublicHoliday", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -113,12 +116,51 @@ namespace timetracker.Data.Migrations
|
|||||||
b.ToTable("PublicHolidays");
|
b.ToTable("PublicHolidays");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("timetracker.Data.User", b =>
|
modelBuilder.Entity("timetracker.Shared.Tenant", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("INTEGER");
|
.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")
|
b.Property<string>("PasswordHash")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
@@ -127,16 +169,21 @@ namespace timetracker.Data.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("TenantId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Username")
|
b.Property<string>("Username")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TenantId");
|
||||||
|
|
||||||
b.ToTable("Users");
|
b.ToTable("Users");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("timetracker.Data.VacationDay", b =>
|
modelBuilder.Entity("timetracker.Shared.VacationDay", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -148,6 +195,9 @@ namespace timetracker.Data.Migrations
|
|||||||
b.Property<string>("Note")
|
b.Property<string>("Note")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("TenantId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
b.Property<int>("UserId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -156,7 +206,7 @@ namespace timetracker.Data.Migrations
|
|||||||
b.ToTable("VacationDays");
|
b.ToTable("VacationDays");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("timetracker.Data.WorkDay", b =>
|
modelBuilder.Entity("timetracker.Shared.WorkDay", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -171,6 +221,9 @@ namespace timetracker.Data.Migrations
|
|||||||
b.Property<TimeOnly?>("StartTime")
|
b.Property<TimeOnly?>("StartTime")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("TenantId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<int>("UserId")
|
b.Property<int>("UserId")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -179,9 +232,9 @@ namespace timetracker.Data.Migrations
|
|||||||
b.ToTable("WorkDays");
|
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")
|
.WithMany("Breaks")
|
||||||
.HasForeignKey("WorkDayId")
|
.HasForeignKey("WorkDayId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
@@ -190,7 +243,16 @@ namespace timetracker.Data.Migrations
|
|||||||
b.Navigation("WorkDay");
|
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");
|
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.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using timetracker.Shared;
|
using timetracker.Shared;
|
||||||
|
|
||||||
namespace timetracker.Data;
|
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<User> Users => Set<User>();
|
||||||
public DbSet<WorkDay> WorkDays => Set<WorkDay>();
|
public DbSet<WorkDay> WorkDays => Set<WorkDay>();
|
||||||
public DbSet<BreakEntry> BreakEntries => Set<BreakEntry>();
|
public DbSet<BreakEntry> BreakEntries => Set<BreakEntry>();
|
||||||
public DbSet<AppSettings> AppSettings => Set<AppSettings>();
|
public DbSet<AppSettings> AppSettings => Set<AppSettings>();
|
||||||
public DbSet<VacationDay> VacationDays => Set<VacationDay>();
|
public DbSet<VacationDay> VacationDays => Set<VacationDay>();
|
||||||
public DbSet<PublicHoliday> PublicHolidays => Set<PublicHoliday>();
|
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.AddCascadingAuthenticationState();
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
|
builder.Services.AddTransient<ITenantProvider, TenantProvider>();
|
||||||
|
|
||||||
// Add SignalR
|
// Add SignalR
|
||||||
builder.Services.AddSignalR();
|
builder.Services.AddSignalR();
|
||||||
builder.Services.AddSingleton<UserNotificationService>();
|
builder.Services.AddSingleton<UserNotificationService>();
|
||||||
@@ -63,6 +65,31 @@ builder.Services.AddRazorComponents()
|
|||||||
|
|
||||||
builder.Services.AddMudServices();
|
builder.Services.AddMudServices();
|
||||||
builder.Services.AddHttpClient<HolidayService>();
|
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 =>
|
builder.Services.AddRateLimiter(options =>
|
||||||
{
|
{
|
||||||
@@ -162,54 +189,93 @@ app.MapGet("/api/auth/me", (HttpContext ctx) =>
|
|||||||
{
|
{
|
||||||
var idClaim = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
var idClaim = ctx.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
var name = ctx.User.Identity.Name ?? "";
|
var name = ctx.User.Identity.Name ?? "";
|
||||||
|
var isTenantAdmin = ctx.User.FindFirst("IsTenantAdmin")?.Value == "true";
|
||||||
if (int.TryParse(idClaim, out var id))
|
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();
|
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);
|
var user = await authService.LoginAsync(req.Username, req.Password);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return Results.BadRequest("Benutzername oder Passwort falsch.");
|
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.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);
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
new ClaimsPrincipal(identity),
|
new ClaimsPrincipal(identity),
|
||||||
new AuthenticationProperties { IsPersistent = true });
|
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");
|
}).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))
|
if (!string.IsNullOrEmpty(req.Honeypot))
|
||||||
{
|
{
|
||||||
// Silently reject bots
|
|
||||||
return Results.BadRequest("Registrierung fehlgeschlagen.");
|
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);
|
var (user, error) = await authService.RegisterAsync(req.Username, req.Password);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return Results.BadRequest(error ?? "Registrierung fehlgeschlagen.");
|
return Results.BadRequest(error ?? "Registrierung fehlgeschlagen.");
|
||||||
|
|
||||||
var claims = new[] {
|
var claims = new List<Claim> {
|
||||||
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
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);
|
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
await ctx.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
new ClaimsPrincipal(identity),
|
new ClaimsPrincipal(identity),
|
||||||
new AuthenticationProperties { IsPersistent = true });
|
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");
|
}).RequireRateLimiting("auth-limit");
|
||||||
|
|
||||||
app.MapGet("/auth/logout", async (HttpContext ctx) =>
|
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();
|
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) ─────────────────────────────────────
|
// ── Timetracker-API-Endpoints (Protected) ─────────────────────────────────────
|
||||||
var trackerApi = app.MapGroup("/api/tracker").RequireAuthorization();
|
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) =>
|
trackerApi.MapGet("/week", async (ClaimsPrincipal claimsPrincipal, [FromQuery] string monday, ITimetrackerService trackerService) =>
|
||||||
{
|
{
|
||||||
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
var idClaim = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
@@ -366,6 +480,130 @@ holidaysApi.MapDelete("/{id:int}", async (int id, IHolidayService holidayService
|
|||||||
return Results.NoContent();
|
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();
|
app.Run();
|
||||||
|
|
||||||
// ── Models for Request Bodies ──────────────────────────────────────────────────
|
// ── Models for Request Bodies ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ public class AppSettings
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int UserId { get; set; }
|
public int UserId { get; set; }
|
||||||
|
public int? TenantId { get; set; }
|
||||||
public double DailyTargetHours { get; set; } = 7.5;
|
public double DailyTargetHours { get; set; } = 7.5;
|
||||||
public int MinimumBreakMinutes { get; set; } = 30;
|
public int MinimumBreakMinutes { get; set; } = 30;
|
||||||
public int VacationDaysPerYear { 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?> RenameUserAsync(int userId, string newUsername);
|
||||||
Task<string?> ChangePasswordAsync(int userId, string currentPassword, string newPassword);
|
Task<string?> ChangePasswordAsync(int userId, string currentPassword, string newPassword);
|
||||||
Task<(User? User, string? Error)> RegisterAsync(string username, string password, string? honeypot = null);
|
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 Username { get; set; } = "";
|
||||||
public string PasswordHash { get; set; } = "";
|
public string PasswordHash { get; set; } = "";
|
||||||
public string PasswordSalt { 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 int Id { get; set; }
|
||||||
public string Username { 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 Id { get; set; }
|
||||||
public int UserId { get; set; }
|
public int UserId { get; set; }
|
||||||
|
public int? TenantId { get; set; }
|
||||||
public DateOnly Date { get; set; }
|
public DateOnly Date { get; set; }
|
||||||
public string? Note { get; set; }
|
public string? Note { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ public class WorkDay
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int UserId { get; set; }
|
public int UserId { get; set; }
|
||||||
|
public int? TenantId { get; set; }
|
||||||
public DateOnly Date { get; set; }
|
public DateOnly Date { get; set; }
|
||||||
public TimeOnly? StartTime { get; set; }
|
public TimeOnly? StartTime { get; set; }
|
||||||
public TimeOnly? EndTime { 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