Files
ReifenSpaeth/Lagerbestand/Components/Pages/Login.razor
T

399 lines
15 KiB
Plaintext

@page "/login"
@layout EmptyLayout
@inject IAuthService AuthService
@inject ITwoFactorService TwoFactorService
@inject ISiteSettingsService SiteSettingsService
@inject UserSessionService UserSession
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Login - Reifen Späth Lagerverwaltung</PageTitle>
<SeoMetaTags
Title="Login - Reifen Späth Lagerverwaltung"
Description="Anmeldung zur Reifen Späth Lagerverwaltung. Zugang für Mitarbeiter und Administratoren."
Robots="noindex, nofollow"
CanonicalUrl="https://www.reifenspaeth.de/login" />
<div class="login-page">
<main class="login-container" role="main">
<header class="login-header">
<h1 class="login-brand">REIFEN SPÄTH</h1>
<p class="login-subtitle">Lagerverwaltung</p>
</header>
<section class="login-card" aria-labelledby="login-title">
@if (!showTwoFactorStep)
{
<h2 id="login-title" class="login-title">Anmelden</h2>
<form @onsubmit="HandleLogin" @onsubmit:preventDefault>
<div class="form-group">
<label class="form-label" for="username">Benutzername</label>
<input type="text"
id="username"
class="form-control @(!string.IsNullOrEmpty(errorMessage) ? "is-invalid" : "")"
@bind="username"
@bind:event="oninput"
@onfocus="ClearError"
placeholder="Benutzername eingeben"
autocomplete="username"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
disabled="@isLoading"
maxlength="50"
required
aria-required="true"
aria-invalid="@(!string.IsNullOrEmpty(errorMessage))"
aria-describedby="@(!string.IsNullOrEmpty(errorMessage) ? "login-error" : null)" />
</div>
<div class="form-group">
<label class="form-label" for="password">Passwort</label>
<input type="password"
id="password"
class="form-control @(!string.IsNullOrEmpty(errorMessage) ? "is-invalid" : "")"
@bind="password"
@bind:event="oninput"
@onfocus="ClearError"
placeholder="Passwort eingeben"
autocomplete="current-password"
disabled="@isLoading"
required
aria-required="true"
aria-invalid="@(!string.IsNullOrEmpty(errorMessage))"
aria-describedby="@(!string.IsNullOrEmpty(errorMessage) ? "login-error" : null)" />
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div id="login-error" class="alert alert-danger" role="alert" aria-live="assertive">
<svg width="16" height="16" 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>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span>@errorMessage</span>
</div>
}
<button type="submit"
class="btn btn-login"
disabled="@(isLoading || !CanLogin)"
aria-busy="@isLoading">
@if (isLoading)
{
<span class="spinner-sm" aria-hidden="true"></span>
<span>Anmelden...</span>
}
else
{
<span>Anmelden</span>
}
</button>
</form>
}
else
{
@* 2FA Code Entry Step *@
<h2 id="login-title" class="login-title">Zwei-Faktor-Authentifizierung</h2>
<p class="tfa-description">Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein.</p>
<form @onsubmit="HandleTwoFactorSubmit" @onsubmit:preventDefault>
<div class="form-group">
<label class="form-label" for="tfa-code">Authentifizierungscode</label>
<input type="text"
id="tfa-code"
class="form-control tfa-code-input @(!string.IsNullOrEmpty(errorMessage) ? "is-invalid" : "")"
@bind="twoFactorCode"
@bind:event="oninput"
@onfocus="ClearError"
placeholder="000000"
autocomplete="one-time-code"
inputmode="numeric"
pattern="[0-9]{6}"
maxlength="6"
disabled="@isLoading"
required
aria-required="true" />
</div>
@if (emailFallbackAvailable)
{
<div class="tfa-fallback">
<button type="button" class="btn-link" @onclick="SendEmailFallbackCode" disabled="@isSendingEmail">
@if (isSendingEmail)
{
<span class="spinner-sm" aria-hidden="true"></span>
}
Code per E-Mail senden
</button>
@if (!string.IsNullOrEmpty(emailFallbackMessage))
{
<span class="tfa-email-status @emailFallbackClass">@emailFallbackMessage</span>
}
</div>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div id="login-error" class="alert alert-danger" role="alert" aria-live="assertive">
<svg width="16" height="16" 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>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
<span>@errorMessage</span>
</div>
}
<button type="submit"
class="btn btn-login"
disabled="@(isLoading || twoFactorCode.Length != 6)"
aria-busy="@isLoading">
@if (isLoading)
{
<span class="spinner-sm" aria-hidden="true"></span>
<span>Überprüfen...</span>
}
else
{
<span>Bestätigen</span>
}
</button>
<button type="button" class="btn btn-back" @onclick="BackToLogin">
? Zurück
</button>
</form>
}
<footer class="login-footer">
<a href="/" class="back-link">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
Zurück zur Startseite
</a>
</footer>
</section>
</main>
</div>
@code {
private string username = "";
private string password = "";
private bool isLoading;
private string? errorMessage;
// 2FA state
private bool showTwoFactorStep;
private string twoFactorCode = "";
private AppUser? pendingUser;
private bool emailFallbackAvailable;
private bool isSendingEmail;
private string? emailFallbackMessage;
private string emailFallbackClass = "";
private string? pendingEmailCode;
private DateTime? emailCodeExpiry;
private bool CanLogin =>
!string.IsNullOrWhiteSpace(username) &&
!string.IsNullOrWhiteSpace(password);
protected override void OnInitialized()
{
if (UserSession.IsAuthenticated)
{
Navigation.NavigateTo("/tire-storage");
}
}
private void ClearError()
{
if (!string.IsNullOrEmpty(errorMessage))
{
errorMessage = null;
StateHasChanged();
}
}
private async Task HandleLogin()
{
if (isLoading || !CanLogin) return;
errorMessage = null;
isLoading = true;
StateHasChanged();
try
{
await Task.Delay(250);
var user = await AuthService.LoginAsync(username.Trim(), password);
if (user != null)
{
// Check if 2FA is required
var tfSettings = await SiteSettingsService.GetTwoFactorSettingsAsync();
if (tfSettings.IsEnabled && user.TwoFactorEnabled && !string.IsNullOrEmpty(user.TwoFactorSecret))
{
pendingUser = user;
showTwoFactorStep = true;
twoFactorCode = "";
emailFallbackAvailable = tfSettings.EmailFallbackEnabled;
emailFallbackMessage = null;
pendingEmailCode = null;
}
else
{
await CompleteLogin(user);
}
}
else
{
errorMessage = "Ungültiger Benutzername oder Passwort.";
password = "";
try
{
await JS.InvokeVoidAsync("qrScanner.playSound", "error");
}
catch { }
}
}
catch (Exception)
{
errorMessage = "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.";
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task HandleTwoFactorSubmit()
{
if (isLoading || pendingUser == null || twoFactorCode.Length != 6) return;
errorMessage = null;
isLoading = true;
StateHasChanged();
try
{
await Task.Delay(200);
bool valid = false;
// Check email fallback code first
if (pendingEmailCode != null && emailCodeExpiry.HasValue && DateTime.UtcNow < emailCodeExpiry.Value)
{
if (System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(pendingEmailCode),
System.Text.Encoding.UTF8.GetBytes(twoFactorCode)))
{
valid = true;
}
}
// Check TOTP code
if (!valid && !string.IsNullOrEmpty(pendingUser.TwoFactorSecret))
{
valid = TwoFactorService.ValidateTotpCode(pendingUser.TwoFactorSecret, twoFactorCode);
}
if (valid)
{
await CompleteLogin(pendingUser);
}
else
{
errorMessage = "Ungültiger Code. Bitte versuchen Sie es erneut.";
twoFactorCode = "";
try { await JS.InvokeVoidAsync("qrScanner.playSound", "error"); } catch { }
}
}
catch (Exception)
{
errorMessage = "Ein Fehler ist aufgetreten.";
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task SendEmailFallbackCode()
{
if (pendingUser == null || isSendingEmail) return;
isSendingEmail = true;
emailFallbackMessage = null;
StateHasChanged();
try
{
var smtpSettings = await SiteSettingsService.GetSmtpSettingsAsync();
if (!smtpSettings.IsEnabled || !smtpSettings.IsConfigured)
{
emailFallbackMessage = "E-Mail-Versand nicht konfiguriert.";
emailFallbackClass = "text-danger";
return;
}
var code = TwoFactorService.GenerateEmailCode();
var sent = await TwoFactorService.SendEmailCodeAsync(smtpSettings.FromEmail, code, pendingUser.Username);
if (sent)
{
pendingEmailCode = code;
emailCodeExpiry = DateTime.UtcNow.AddMinutes(5);
emailFallbackMessage = "Code wurde gesendet.";
emailFallbackClass = "text-success";
}
else
{
emailFallbackMessage = "Fehler beim E-Mail-Versand.";
emailFallbackClass = "text-danger";
}
}
catch
{
emailFallbackMessage = "Fehler beim E-Mail-Versand.";
emailFallbackClass = "text-danger";
}
finally
{
isSendingEmail = false;
StateHasChanged();
}
}
private async Task CompleteLogin(AppUser user)
{
try
{
await JS.InvokeVoidAsync("localStorage.setItem", "userId", user.Id.ToString());
}
catch { }
UserSession.SetUser(user);
Navigation.NavigateTo("/tire-storage");
}
private void BackToLogin()
{
showTwoFactorStep = false;
twoFactorCode = "";
pendingUser = null;
errorMessage = null;
password = "";
pendingEmailCode = null;
emailCodeExpiry = null;
emailFallbackMessage = null;
}
}