399 lines
15 KiB
Plaintext
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;
|
|
}
|
|
}
|