Initial commit: Lagerbestand Blazor Projekt
This commit is contained in:
commit
cba9414e92
105 files changed
+36431
No files matched your search
@@ -0,0 +1,3 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
@Body
|
||||
@@ -0,0 +1,653 @@
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@inject UserSessionService UserSession
|
||||
@inject IAuthService AuthService
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JS
|
||||
|
||||
@if (IsPublicPage())
|
||||
{
|
||||
<main id="main-content">
|
||||
@Body
|
||||
</main>
|
||||
}
|
||||
else
|
||||
{
|
||||
@switch (state)
|
||||
{
|
||||
case AuthState.Loading:
|
||||
<div class="auth-loading" role="status" aria-label="Lade Anwendung" aria-live="polite">
|
||||
<div class="spinner-border" aria-hidden="true"></div>
|
||||
<p>Lade...</p>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case AuthState.Authenticated:
|
||||
<div class="app-layout">
|
||||
<header class="app-header" role="banner">
|
||||
<div class="header-container">
|
||||
<a href="/" class="header-brand" aria-label="Reifen Späth - Zur Startseite">
|
||||
<span class="brand-name">REIFEN SPÄTH</span>
|
||||
<span class="brand-sub">Lagerverwaltung</span>
|
||||
</a>
|
||||
|
||||
<nav class="header-nav" role="navigation" aria-label="Hauptnavigation">
|
||||
@* Worker and Admin see: Lager, Einlagern, Abholen *@
|
||||
@if (UserSession.IsWorkerOrAdmin)
|
||||
{
|
||||
<a href="/tire-storage" class="nav-item @(IsCurrentPage("/tire-storage") ? "active" : "")" aria-current="@(IsCurrentPage("/tire-storage") ? "page" : null)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
<span>Lager</span>
|
||||
</a>
|
||||
<a href="/tire-checkin" class="nav-item @(IsCurrentPage("/tire-checkin") ? "active" : "")" aria-current="@(IsCurrentPage("/tire-checkin") ? "page" : null)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
|
||||
<path d="M12 5v14M5 12h14"></path>
|
||||
</svg>
|
||||
<span>Einlagern</span>
|
||||
</a>
|
||||
<a href="/tire-checkout" class="nav-item @(IsCurrentPage("/tire-checkout") ? "active" : "")" aria-current="@(IsCurrentPage("/tire-checkout") ? "page" : null)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
<span>Abholen</span>
|
||||
</a>
|
||||
<a href="/dashboard" class="nav-item @(IsCurrentPage("/dashboard") ? "active" : "")" aria-current="@(IsCurrentPage("/dashboard") ? "page" : null)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||
<line x1="9" y1="21" x2="9" y2="9"></line>
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
}
|
||||
@* Admin only sees: Kunden, Plätze, Benutzer, Status *@
|
||||
@if (UserSession.IsAdmin)
|
||||
{
|
||||
<a href="/customers" class="nav-item @(IsCurrentPage("/customers") ? "active" : "")" aria-current="@(IsCurrentPage("/customers") ? "page" : null)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
</svg>
|
||||
<span>Kunden</span>
|
||||
</a>
|
||||
<a href="/storage-locations" class="nav-item @(IsCurrentPage("/storage-locations") ? "active" : "")" aria-current="@(IsCurrentPage("/storage-locations") ? "page" : null)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
</svg>
|
||||
<span>Plätze</span>
|
||||
</a>
|
||||
<a href="/users" class="nav-item @(IsCurrentPage("/users") ? "active" : "")" aria-current="@(IsCurrentPage("/users") ? "page" : null)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
<span>Benutzer</span>
|
||||
</a>
|
||||
<a href="/status" class="nav-item @(IsCurrentPage("/status") ? "active" : "")" aria-current="@(IsCurrentPage("/status") ? "page" : null)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2"></path>
|
||||
</svg>
|
||||
<span>Status</span>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="header-user">
|
||||
<div class="user-dropdown">
|
||||
<button type="button"
|
||||
class="user-dropdown-toggle"
|
||||
@onclick="ToggleUserMenu"
|
||||
@onclick:stopPropagation
|
||||
aria-expanded="@showUserMenu"
|
||||
aria-haspopup="menu"
|
||||
aria-controls="user-menu"
|
||||
aria-label="Benutzermenü öffnen">
|
||||
<div class="user-info">
|
||||
<span class="user-name">@UserSession.DisplayName</span>
|
||||
<span class="user-role">@UserSession.RoleName</span>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="@(showUserMenu ? "rotated" : "")" aria-hidden="true" focusable="false">
|
||||
<path d="M6 9l6 6 6-6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
@if (showUserMenu)
|
||||
{
|
||||
<div class="user-dropdown-menu" id="user-menu" role="menu" aria-label="Benutzeroptionen">
|
||||
<button type="button" class="dropdown-item" @onclick="OpenPasswordModal" role="menuitem">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
Passwort ändern
|
||||
</button>
|
||||
<div class="dropdown-divider" role="separator"></div>
|
||||
<button type="button" class="dropdown-item dropdown-item-danger" @onclick="OpenLogoutModal" role="menuitem">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="mobile-menu-toggle"
|
||||
@onclick="ToggleMobileMenu"
|
||||
aria-expanded="@showMobileMenu"
|
||||
aria-controls="mobile-menu"
|
||||
aria-label="@(showMobileMenu ? "Menü schließen" : "Menü öffnen")">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showMobileMenu)
|
||||
{
|
||||
<nav class="mobile-menu" id="mobile-menu" role="navigation" aria-label="Mobile Navigation">
|
||||
@* Worker and Admin see: Lager, Einlagern, Abholen, Dashboard *@
|
||||
@if (UserSession.IsWorkerOrAdmin)
|
||||
{
|
||||
<a href="/tire-storage" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📊</span> Lager</a>
|
||||
<a href="/tire-checkin" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">➕</span> Einlagern</a>
|
||||
<a href="/tire-checkout" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📤</span> Abholen</a>
|
||||
<a href="/dashboard" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📋</span> Dashboard</a>
|
||||
}
|
||||
@* Admin only sees: Kunden, Plätze, Benutzer, Status *@
|
||||
@if (UserSession.IsAdmin)
|
||||
{
|
||||
<a href="/customers" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">👥</span> Kunden</a>
|
||||
<a href="/storage-locations" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📍</span> Lagerplätze</a>
|
||||
<a href="/users" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">👤</span> Benutzer</a>
|
||||
<a href="/status" class="mobile-nav-item" @onclick="CloseMobileMenu"><span aria-hidden="true">📈</span> Status</a>
|
||||
}
|
||||
<div class="mobile-user-info">
|
||||
<span>@UserSession.DisplayName</span>
|
||||
<div class="mobile-user-actions">
|
||||
<button type="button" class="btn-password-mobile" @onclick="OpenPasswordModal" title="Passwort ändern" aria-label="Passwort ändern">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="btn-logout-mobile" @onclick="OpenLogoutModal">Abmelden</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
</header>
|
||||
|
||||
<main id="main-content" class="app-main" role="main">
|
||||
<ErrorBoundary @ref="errorBoundary">
|
||||
<ChildContent>
|
||||
@Body
|
||||
</ChildContent>
|
||||
<ErrorContent Context="ex">
|
||||
<div class="blazor-error-boundary" role="alert">
|
||||
<p><strong>Ein Fehler ist aufgetreten.</strong></p>
|
||||
<p>Die Seite kann möglicherweise nicht korrekt angezeigt werden. Bitte laden Sie die Anwendung neu.</p>
|
||||
<button type="button" class="btn btn-primary" @onclick="ReloadPage">Neu laden</button>
|
||||
</div>
|
||||
</ErrorContent>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
break;
|
||||
|
||||
case AuthState.NotAuthenticated:
|
||||
<main class="auth-redirect" role="main">
|
||||
<div class="auth-icon" aria-hidden="true">🔒</div>
|
||||
<h1>Anmeldung erforderlich</h1>
|
||||
<p>Sie müssen angemeldet sein, um auf diese Seite zuzugreifen.</p>
|
||||
<a href="/login" class="btn btn-primary">Zum Login</a>
|
||||
</main>
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@* Logout Modal *@
|
||||
@if (showLogoutModal)
|
||||
{
|
||||
<div class="global-modal-backdrop" @onclick="CloseAllModals" aria-hidden="true"></div>
|
||||
<div class="global-modal-container" @onclick="CloseAllModals" role="dialog" aria-modal="true" aria-labelledby="logout-title" aria-describedby="logout-desc">
|
||||
<div class="global-modal-dialog" @onclick:stopPropagation>
|
||||
<div class="global-modal-header">
|
||||
<h2 id="logout-title">Abmelden</h2>
|
||||
<button type="button" class="global-modal-close" @onclick="CloseAllModals" aria-label="Dialog schließen">
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="global-modal-body">
|
||||
<div class="global-modal-icon global-modal-icon-warning" aria-hidden="true">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" focusable="false">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||
<polyline points="16 17 21 12 16 7"></polyline>
|
||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<p id="logout-desc">Möchten Sie sich wirklich abmelden?</p>
|
||||
<p class="text-muted">Sie werden zur Startseite weitergeleitet.</p>
|
||||
</div>
|
||||
<div class="global-modal-footer">
|
||||
<button type="button" class="global-modal-btn global-modal-btn-secondary" @onclick="CloseAllModals">Abbrechen</button>
|
||||
<button type="button" class="global-modal-btn global-modal-btn-danger" @onclick="ExecuteLogout" disabled="@isLoggingOut" aria-busy="@isLoggingOut">
|
||||
@if (isLoggingOut)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Abmeldung läuft...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Abmelden</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Password Change Modal *@
|
||||
@if (showPasswordModal)
|
||||
{
|
||||
<div class="global-modal-backdrop" @onclick="CloseAllModals" aria-hidden="true"></div>
|
||||
<div class="global-modal-container" @onclick="CloseAllModals" role="dialog" aria-modal="true" aria-labelledby="password-title">
|
||||
<div class="global-modal-dialog global-modal-dialog-lg" @onclick:stopPropagation>
|
||||
<div class="global-modal-header">
|
||||
<h2 id="password-title">Passwort ändern</h2>
|
||||
<button type="button" class="global-modal-close" @onclick="CloseAllModals" aria-label="Dialog schließen">
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="global-modal-body">
|
||||
@if (!string.IsNullOrEmpty(passwordError))
|
||||
{
|
||||
<div class="global-modal-alert global-modal-alert-danger" role="alert" aria-live="assertive">@passwordError</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(passwordSuccess))
|
||||
{
|
||||
<div class="global-modal-alert global-modal-alert-success" role="status" aria-live="polite">@passwordSuccess</div>
|
||||
}
|
||||
<div class="global-modal-form-group">
|
||||
<label class="global-modal-form-label" for="current-password">Aktuelles Passwort</label>
|
||||
<input type="password"
|
||||
id="current-password"
|
||||
class="global-modal-form-control"
|
||||
@bind="currentPassword"
|
||||
@bind:event="oninput"
|
||||
placeholder="Aktuelles Passwort eingeben"
|
||||
disabled="@isChangingPassword"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
aria-required="true" />
|
||||
</div>
|
||||
<div class="global-modal-form-group">
|
||||
<label class="global-modal-form-label" for="new-password">Neues Passwort</label>
|
||||
<input type="password"
|
||||
id="new-password"
|
||||
class="global-modal-form-control"
|
||||
@bind="newPassword"
|
||||
@bind:event="oninput"
|
||||
placeholder="Neues Passwort eingeben"
|
||||
disabled="@isChangingPassword"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
aria-required="true"
|
||||
aria-describedby="password-hint" />
|
||||
<small id="password-hint" class="global-modal-form-hint">Mindestens 4 Zeichen</small>
|
||||
</div>
|
||||
<div class="global-modal-form-group">
|
||||
<label class="global-modal-form-label" for="confirm-password">Neues Passwort bestätigen</label>
|
||||
<input type="password"
|
||||
id="confirm-password"
|
||||
class="global-modal-form-control"
|
||||
@bind="confirmPassword"
|
||||
@bind:event="oninput"
|
||||
placeholder="Neues Passwort wiederholen"
|
||||
disabled="@isChangingPassword"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
aria-required="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="global-modal-footer">
|
||||
<button type="button" class="global-modal-btn global-modal-btn-secondary" @onclick="CloseAllModals" disabled="@isChangingPassword">Abbrechen</button>
|
||||
<button type="button" class="global-modal-btn global-modal-btn-primary" @onclick="ChangePassword" disabled="@(isChangingPassword || !CanChangePassword)" aria-busy="@isChangingPassword">
|
||||
@if (isChangingPassword)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Passwort wird geändert...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Passwort ändern</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private enum AuthState { Loading, Authenticated, NotAuthenticated }
|
||||
|
||||
private AuthState state = AuthState.Loading;
|
||||
private bool showMobileMenu;
|
||||
private bool showUserMenu;
|
||||
private bool showLogoutModal;
|
||||
private bool showPasswordModal;
|
||||
private bool isLoggingOut;
|
||||
|
||||
// Password change form
|
||||
private string currentPassword = "";
|
||||
private string newPassword = "";
|
||||
private string confirmPassword = "";
|
||||
private string? passwordError;
|
||||
private string? passwordSuccess;
|
||||
private bool isChangingPassword;
|
||||
|
||||
private bool CanChangePassword =>
|
||||
!string.IsNullOrWhiteSpace(currentPassword) &&
|
||||
!string.IsNullOrWhiteSpace(newPassword) &&
|
||||
newPassword.Length >= 4 &&
|
||||
newPassword == confirmPassword;
|
||||
|
||||
private ErrorBoundary? errorBoundary;
|
||||
private DotNetObjectReference<MainLayout>? dotNetRef;
|
||||
private bool userMenuClickOutsideRegistered;
|
||||
private bool lastAuthMarker;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
UserSession.OnChange += HandleUserChanged;
|
||||
|
||||
if (UserSession.IsAuthenticated)
|
||||
{
|
||||
state = AuthState.Authenticated;
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
dotNetRef = DotNetObjectReference.Create(this);
|
||||
|
||||
if (!IsPublicPage() && state == AuthState.Loading)
|
||||
{
|
||||
await RestoreSessionAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// Keep <body data-authenticated> in sync so app-interop.js can
|
||||
// auto-start/stop the idle watcher.
|
||||
var isAuthed = state == AuthState.Authenticated;
|
||||
if (isAuthed != lastAuthMarker)
|
||||
{
|
||||
lastAuthMarker = isAuthed;
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("document.body.setAttribute", "data-authenticated", isAuthed ? "true" : "false");
|
||||
if (!isAuthed)
|
||||
{
|
||||
await JS.InvokeVoidAsync("appInterop.stopIdleWatcher");
|
||||
}
|
||||
}
|
||||
catch { /* circuit may be mid-reconnect */ }
|
||||
}
|
||||
|
||||
// Register / unregister click-outside for the user dropdown.
|
||||
try
|
||||
{
|
||||
if (showUserMenu && !userMenuClickOutsideRegistered && dotNetRef != null)
|
||||
{
|
||||
await JS.InvokeVoidAsync("appInterop.registerClickOutside", dotNetRef, nameof(CloseUserMenuFromJs), ".user-dropdown");
|
||||
userMenuClickOutsideRegistered = true;
|
||||
}
|
||||
else if (!showUserMenu && userMenuClickOutsideRegistered)
|
||||
{
|
||||
await JS.InvokeVoidAsync("appInterop.unregisterClickOutside");
|
||||
userMenuClickOutsideRegistered = false;
|
||||
}
|
||||
}
|
||||
catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public Task CloseUserMenuFromJs()
|
||||
{
|
||||
if (showUserMenu)
|
||||
{
|
||||
showUserMenu = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ReloadPage()
|
||||
{
|
||||
errorBoundary?.Recover();
|
||||
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
|
||||
}
|
||||
|
||||
private async Task RestoreSessionAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = await JS.InvokeAsync<string?>("localStorage.getItem", "userId");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userId) && Guid.TryParse(userId, out var guid))
|
||||
{
|
||||
var user = await AuthService.GetUserByIdAsync(guid);
|
||||
|
||||
if (user != null && user.IsActive)
|
||||
{
|
||||
UserSession.SetUser(user);
|
||||
state = AuthState.Authenticated;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear invalid session
|
||||
await JS.InvokeVoidAsync("localStorage.removeItem", "userId");
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Continue to not authenticated state
|
||||
}
|
||||
|
||||
state = AuthState.NotAuthenticated;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void HandleUserChanged()
|
||||
{
|
||||
state = UserSession.IsAuthenticated ? AuthState.Authenticated : AuthState.NotAuthenticated;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private bool IsPublicPage()
|
||||
{
|
||||
var uri = Navigation.ToBaseRelativePath(Navigation.Uri).ToLowerInvariant();
|
||||
|
||||
return string.IsNullOrEmpty(uri) ||
|
||||
uri == "/" ||
|
||||
uri.StartsWith("login") ||
|
||||
uri.StartsWith("impressum") ||
|
||||
uri.StartsWith("datenschutz");
|
||||
}
|
||||
|
||||
private bool IsCurrentPage(string path)
|
||||
{
|
||||
var uri = "/" + Navigation.ToBaseRelativePath(Navigation.Uri);
|
||||
return uri.Equals(path, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void ToggleMobileMenu()
|
||||
{
|
||||
showMobileMenu = !showMobileMenu;
|
||||
showUserMenu = false;
|
||||
}
|
||||
|
||||
private void CloseMobileMenu() => showMobileMenu = false;
|
||||
|
||||
private void ToggleUserMenu() => showUserMenu = !showUserMenu;
|
||||
|
||||
private void OpenLogoutModal()
|
||||
{
|
||||
CloseMenus();
|
||||
showLogoutModal = true;
|
||||
}
|
||||
|
||||
private void OpenPasswordModal()
|
||||
{
|
||||
CloseMenus();
|
||||
ResetPasswordForm();
|
||||
showPasswordModal = true;
|
||||
}
|
||||
|
||||
private void CloseMenus()
|
||||
{
|
||||
showUserMenu = false;
|
||||
showMobileMenu = false;
|
||||
}
|
||||
|
||||
private void CloseAllModals()
|
||||
{
|
||||
showLogoutModal = false;
|
||||
showPasswordModal = false;
|
||||
showUserMenu = false;
|
||||
showMobileMenu = false;
|
||||
ResetPasswordForm();
|
||||
}
|
||||
|
||||
private void ResetPasswordForm()
|
||||
{
|
||||
currentPassword = "";
|
||||
newPassword = "";
|
||||
confirmPassword = "";
|
||||
passwordError = null;
|
||||
passwordSuccess = null;
|
||||
isChangingPassword = false;
|
||||
}
|
||||
|
||||
private async Task ChangePassword()
|
||||
{
|
||||
passwordError = null;
|
||||
passwordSuccess = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(currentPassword))
|
||||
{
|
||||
passwordError = "Bitte geben Sie Ihr aktuelles Passwort ein.";
|
||||
return;
|
||||
}
|
||||
|
||||
var validation = AuthService.ValidatePassword(newPassword);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
passwordError = validation.ErrorMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword != confirmPassword)
|
||||
{
|
||||
passwordError = "Die Passwörter stimmen nicht überein.";
|
||||
return;
|
||||
}
|
||||
|
||||
isChangingPassword = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var user = UserSession.CurrentUser;
|
||||
if (user == null)
|
||||
{
|
||||
passwordError = "Benutzer nicht gefunden.";
|
||||
return;
|
||||
}
|
||||
|
||||
var verifiedUser = await AuthService.AuthenticateAsync(user.Username, currentPassword);
|
||||
if (verifiedUser == null)
|
||||
{
|
||||
passwordError = "Das aktuelle Passwort ist falsch.";
|
||||
await PlayFeedback("error");
|
||||
return;
|
||||
}
|
||||
|
||||
var success = await AuthService.ChangePasswordAsync(user.Id, newPassword);
|
||||
if (success)
|
||||
{
|
||||
passwordSuccess = "Passwort wurde erfolgreich geändert!";
|
||||
currentPassword = "";
|
||||
newPassword = "";
|
||||
confirmPassword = "";
|
||||
await PlayFeedback("success");
|
||||
StateHasChanged();
|
||||
|
||||
await Task.Delay(1500);
|
||||
CloseAllModals();
|
||||
}
|
||||
else
|
||||
{
|
||||
passwordError = "Fehler beim Ändern des Passworts.";
|
||||
await PlayFeedback("error");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
passwordError = $"Fehler: {ex.Message}";
|
||||
await PlayFeedback("error");
|
||||
}
|
||||
finally
|
||||
{
|
||||
isChangingPassword = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteLogout()
|
||||
{
|
||||
isLoggingOut = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("localStorage.removeItem", "userId");
|
||||
}
|
||||
catch { }
|
||||
|
||||
UserSession.ClearUser();
|
||||
showLogoutModal = false;
|
||||
Navigation.NavigateTo("/", forceLoad: true);
|
||||
}
|
||||
|
||||
private async Task PlayFeedback(string type)
|
||||
{
|
||||
try
|
||||
{
|
||||
await JS.InvokeVoidAsync("qrScanner.playSound", type);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
UserSession.OnChange -= HandleUserChanged;
|
||||
dotNetRef?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
/* ========================================
|
||||
MainLayout - Apple HIG Admin Interface
|
||||
Uses global CSS variables from app.css
|
||||
======================================== */
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Auth States
|
||||
======================================== */
|
||||
.auth-icon {
|
||||
font-family: var(--font-family-emoji);
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
App Layout
|
||||
======================================== */
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
App Header - Responsive
|
||||
======================================== */
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-sticky);
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
border-bottom: 0.5px solid var(--border-default);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-2);
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.header-container {
|
||||
padding: 0 var(--space-4);
|
||||
gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-container {
|
||||
height: 60px;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.header-container {
|
||||
padding: 0 var(--space-6);
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: opacity var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.header-brand:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.header-brand:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.header-brand .brand-name {
|
||||
font-family: var(--font-family-heading);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: clamp(0.9rem, 2.5vw, 1.2rem);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.header-brand .brand-sub {
|
||||
font-family: var(--font-family-base);
|
||||
font-size: clamp(0.6rem, 1.5vw, 0.7rem);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Header Navigation - Responsive
|
||||
======================================== */
|
||||
.header-nav {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-nav {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.header-nav {
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-family-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: 0.8rem;
|
||||
transition: all var(--duration-fast) var(--ease-out);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.nav-item {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.nav-item {
|
||||
padding: 10px 16px;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
.nav-item {
|
||||
padding: 10px 18px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-secondary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.nav-item svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide nav text on smaller tablets */
|
||||
@media (min-width: 768px) and (max-width: 899px) {
|
||||
.nav-item span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
User Dropdown - Responsive
|
||||
======================================== */
|
||||
.header-user {
|
||||
display: none;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-user {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-dropdown-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 14px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.user-dropdown-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.user-dropdown-toggle:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.user-dropdown-toggle .user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.user-dropdown-toggle .user-name {
|
||||
font-family: var(--font-family-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.user-dropdown-toggle .user-role {
|
||||
font-family: var(--font-family-base);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.user-dropdown-toggle svg {
|
||||
color: var(--text-secondary);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transition: transform var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.user-dropdown-toggle svg.rotated {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.user-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
min-width: 220px;
|
||||
background: var(--surface-primary);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 0.5px solid var(--border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
z-index: var(--z-dropdown);
|
||||
overflow: hidden;
|
||||
animation: dropdownIn var(--duration-normal) var(--ease-spring);
|
||||
}
|
||||
|
||||
@keyframes dropdownIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-family-base);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast) var(--ease-out);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.dropdown-item:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-item svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.dropdown-item-danger {
|
||||
color: var(--color-error-light);
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
border-top: 0.5px solid rgba(255, 255, 255, 0.06);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dropdown-item-danger:hover {
|
||||
background: rgba(220, 38, 38, 0.15);
|
||||
}
|
||||
|
||||
.dropdown-item-danger svg {
|
||||
color: var(--color-error-light);
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 0.5px;
|
||||
background: var(--border-default);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Mobile Menu - Enhanced Responsive
|
||||
======================================== */
|
||||
.mobile-menu-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--touch-target);
|
||||
height: var(--touch-target);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-fast) var(--ease-out);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle:active {
|
||||
transform: scale(0.9);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-menu {
|
||||
background: rgba(0, 0, 0, 0.98);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
padding: var(--space-4);
|
||||
border-top: 0.5px solid var(--border-default);
|
||||
animation: mobileMenuIn var(--duration-normal) var(--ease-out);
|
||||
max-height: calc(100vh - 60px);
|
||||
max-height: calc(100dvh - 60px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@keyframes mobileMenuIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-family-emoji), var(--font-family-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
transition: background var(--duration-fast) var(--ease-out);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.mobile-nav-item {
|
||||
padding: 16px 18px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-nav-item:active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.mobile-user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-6) var(--space-4);
|
||||
margin-top: var(--space-2);
|
||||
border-top: 0.5px solid var(--border-default);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-family-base);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.mobile-user-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Main Content - Responsive
|
||||
======================================== */
|
||||
.app-main {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
Form Controls - Responsive
|
||||
======================================== */
|
||||
.form-group {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Reduced motion: inherited from app.css (section 17) */
|
||||
Whitespace-only changes.
Reference in new issue
Block a user