Files

654 lines
30 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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&ouml;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();
}
}