Add reusable UI components and toast notification system
- Introduce ActionPanel, EnvelopeInfoCard, TfaForm, ConfirmDialog, StatusPage, and Toast components for modular, presentational UI - Add ToastService for pub/sub toast notifications; register in DI - Refactor AccessCodeForm for improved UX and parameterization - Enhance MainLayout with Toast integration and better error handling - Standardize and extend app.css for new components and responsive design - All new components are "dumb" (no service/API knowledge), using EventCallbacks for parent interaction - ConfirmDialog supports awaitable user confirmation via TaskCompletionSource
This commit is contained in:
@@ -1,56 +1,168 @@
|
||||
@* DUMB COMPONENT: Kennt keine Services, nur Parameter und Events *@
|
||||
@* AccessCodeForm: Zeigt das Zugangscode-Eingabeformular.
|
||||
Entspricht dem AccessCode-Teil von EnvelopeLocked.cshtml im Web-Projekt.
|
||||
|
||||
"Dumme" Komponente: Keine Services, keine API-Calls.
|
||||
- Bekommt Daten über [Parameter]
|
||||
- Gibt Eingaben über EventCallback an die Eltern-Page zurück
|
||||
- Die Page entscheidet, was mit dem Code passiert *@
|
||||
|
||||
<div class="access-code-container">
|
||||
<h2>Zugangscode eingeben</h2>
|
||||
<p>Ein Zugangscode wurde an Ihre E-Mail-Adresse gesendet.</p>
|
||||
|
||||
<EditForm Model="_model" OnValidSubmit="Submit">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="form-group">
|
||||
<InputText @bind-Value="_model.Code"
|
||||
class="form-control code-input"
|
||||
placeholder="000000"
|
||||
maxlength="6" />
|
||||
<ValidationMessage For="() => _model.Code" />
|
||||
<div class="page">
|
||||
@* ── Header: Icon + Titel ── *@
|
||||
<header class="text-center">
|
||||
<div class="status-icon locked mt-4 mb-1">
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
</div>
|
||||
<h1>Zugangscode eingeben</h1>
|
||||
</header>
|
||||
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-2">@ErrorMessage</div>
|
||||
}
|
||||
@* ── Erklärungstext ── *@
|
||||
<section class="text-center mb-4">
|
||||
<p class="text-muted">
|
||||
Ein Zugangscode wurde an Ihre E-Mail-Adresse gesendet.
|
||||
Bitte geben Sie diesen unten ein.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<button type="submit" class="btn btn-primary mt-3" disabled="@_isSubmitting">
|
||||
@if (_isSubmitting)
|
||||
@* ── Formular ── *@
|
||||
<div class="access-code-container">
|
||||
<EditForm Model="_model" OnValidSubmit="Submit">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
@* Code-Eingabefeld: type="password" damit niemand mitlesen kann *@
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="_model.Code"
|
||||
type="password"
|
||||
class="form-control code-input"
|
||||
id="accessCodeInput"
|
||||
placeholder="Zugangscode" />
|
||||
<label for="accessCodeInput">Zugangscode</label>
|
||||
<ValidationMessage For="() => _model.Code" />
|
||||
</div>
|
||||
|
||||
@* TFA-Switch: Nur sichtbar wenn der Umschlag TFA aktiviert hat.
|
||||
Im Web-Projekt ist das der "2FA per SMS"-Toggle.
|
||||
Disabled wenn der Empfänger keine Telefonnummer hat. *@
|
||||
@if (TfaEnabled)
|
||||
{
|
||||
<LoadingIndicator Small="true" />
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input"
|
||||
type="checkbox"
|
||||
id="preferSmsSwitch"
|
||||
checked="@_preferSms"
|
||||
disabled="@(!HasPhoneNumber)"
|
||||
@onchange="ToggleSms" />
|
||||
<label class="form-check-label" for="preferSmsSwitch">
|
||||
2FA per SMS
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
|
||||
@* Fehlermeldung: Wird von der Eltern-Page gesetzt
|
||||
wenn der AccessCode falsch war *@
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<span>Bestätigen</span>
|
||||
<div class="alert alert-danger">@ErrorMessage</div>
|
||||
}
|
||||
</button>
|
||||
</EditForm>
|
||||
|
||||
@* Submit-Button mit Loading-State *@
|
||||
<button type="submit" class="btn btn-primary w-100" disabled="@_isSubmitting">
|
||||
@if (_isSubmitting)
|
||||
{
|
||||
<LoadingIndicator Small="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>
|
||||
<span>Bestätigen</span>
|
||||
}
|
||||
</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@* ── Hilfe-Bereich: Wer hat dieses Dokument gesendet? ──
|
||||
Im Web-Projekt ist das der <details>-Bereich ganz unten.
|
||||
Zeigt Sender-Mail und Umschlag-Titel, damit der Empfänger
|
||||
bei Problemen weiß, an wen er sich wenden kann. *@
|
||||
@if (!string.IsNullOrEmpty(SenderEmail))
|
||||
{
|
||||
<section class="text-center mt-4">
|
||||
<details>
|
||||
<summary class="text-muted">Woher kommt dieser Code?</summary>
|
||||
<p class="text-muted mt-2">
|
||||
Dieses Dokument
|
||||
@if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
<span>«@Title» </span>
|
||||
}
|
||||
wurde Ihnen von <a href="mailto:@SenderEmail">@SenderEmail</a> zugesendet.
|
||||
Der Zugangscode wurde ebenfalls an Ihre E-Mail-Adresse geschickt.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// Parameter von der Eltern-Page
|
||||
[Parameter] public required string EnvelopeKey { get; set; }
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
// ── Parameter von der Eltern-Page ──
|
||||
|
||||
// EventCallback: Informiert die Page, dass ein Code eingegeben wurde
|
||||
[Parameter] public EventCallback<string> OnSubmit { get; set; }
|
||||
/// <summary>Der Envelope-Key aus der URL</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string EnvelopeKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Fehlermeldung (z.B. "Falscher Zugangscode")</summary>
|
||||
[Parameter]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>E-Mail des Absenders — für den Hilfe-Bereich unten</summary>
|
||||
[Parameter]
|
||||
public string? SenderEmail { get; set; }
|
||||
|
||||
/// <summary>Titel des Umschlags — für den Hilfe-Bereich unten</summary>
|
||||
[Parameter]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>Ob TFA für diesen Umschlag aktiviert ist — zeigt den SMS-Switch</summary>
|
||||
[Parameter]
|
||||
public bool TfaEnabled { get; set; }
|
||||
|
||||
/// <summary>Ob der Empfänger eine Telefonnummer hat — sonst ist SMS-Switch disabled</summary>
|
||||
[Parameter]
|
||||
public bool HasPhoneNumber { get; set; }
|
||||
|
||||
// ── Event: Gibt Code + SMS-Präferenz an die Page zurück ──
|
||||
|
||||
/// <summary>
|
||||
/// Callback wenn der Benutzer das Formular abschickt.
|
||||
/// Gibt ein Tuple zurück: (Code, PreferSms)
|
||||
/// Die Page entscheidet dann, was damit passiert (API-Call etc.)
|
||||
///
|
||||
/// WARUM ein Tuple statt nur string?
|
||||
/// Weil die Page auch wissen muss, ob der Benutzer SMS bevorzugt.
|
||||
/// Im Web-Projekt wird das als separates Form-Feld "userSelectSMS" gesendet.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<(string Code, bool PreferSms)> OnSubmit { get; set; }
|
||||
|
||||
// ── Interner State ──
|
||||
|
||||
private AccessCodeModel _model = new();
|
||||
private bool _isSubmitting;
|
||||
private bool _preferSms;
|
||||
|
||||
private void ToggleSms(ChangeEventArgs e)
|
||||
{
|
||||
_preferSms = (bool)(e.Value ?? false);
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
_isSubmitting = true;
|
||||
await OnSubmit.InvokeAsync(_model.Code);
|
||||
await OnSubmit.InvokeAsync((_model.Code, _preferSms));
|
||||
_isSubmitting = false;
|
||||
}
|
||||
|
||||
// ── Validierungs-Model ──
|
||||
|
||||
private class AccessCodeModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Bitte Zugangscode eingeben")]
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
@* ActionPanel: Fixierte Button-Leiste am unteren Bildschirmrand.
|
||||
Entspricht dem <div id="flex-action-panel"> in ShowEnvelope.cshtml.
|
||||
|
||||
Wird angezeigt wenn der Empfänger das Dokument sieht (Status = ShowDocument).
|
||||
Bei ReadOnly-Dokumenten wird nichts angezeigt.
|
||||
|
||||
Die Buttons lösen EventCallbacks aus. Die Eltern-Komponente (EnvelopePage)
|
||||
entscheidet, was passiert (API-Call etc.).
|
||||
|
||||
Der ConfirmDialog wird über eine Referenz aufgerufen:
|
||||
var confirmed = await _confirmDialog.ShowAsync("Titel", "Text");
|
||||
|
||||
WARUM EventCallbacks statt direkte API-Calls?
|
||||
- Die Komponente bleibt "dumm" (keine Services, kein API-Wissen)
|
||||
- Die Eltern-Page kann den gleichen Button-Click anders behandeln
|
||||
- Einfacher zu testen *@
|
||||
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
@* position-fixed: Bleibt am unteren Rand auch beim Scrollen.
|
||||
Gleiche Positionierung wie im Web-Projekt. *@
|
||||
<div class="position-fixed bottom-0 end-0 p-3 d-flex gap-2" style="z-index: 1050;">
|
||||
|
||||
@* Zurücksetzen-Button: Setzt alle Signaturen zurück.
|
||||
Im Web-Projekt ist das der graue Button mit dem
|
||||
Pfeil-gegen-den-Uhrzeigersinn-Icon. *@
|
||||
<button class="btn btn-secondary" @onclick="HandleRefresh" title="Zurücksetzen">
|
||||
<i class="bi bi-arrow-counterclockwise"></i>
|
||||
</button>
|
||||
|
||||
@* Ablehnen-Button: Öffnet ConfirmDialog, dann EventCallback.
|
||||
Im Web-Projekt ist das der rote Button mit dem X-Icon. *@
|
||||
<button class="btn btn-danger" @onclick="HandleReject" title="Ablehnen">
|
||||
<i class="bi bi-x-lg me-1"></i>
|
||||
<span class="d-none d-md-inline">Ablehnen</span>
|
||||
</button>
|
||||
|
||||
@* Unterschreiben-Button: Öffnet ConfirmDialog, dann EventCallback.
|
||||
Im Web-Projekt ist das der grüne Button mit dem Briefumschlag-Icon. *@
|
||||
<button class="btn btn-success" @onclick="HandleSign" title="Unterschreiben">
|
||||
<i class="bi bi-pen me-1"></i>
|
||||
<span class="d-none d-md-inline">Unterschreiben</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ConfirmDialog: Wird nur gerendert wenn nötig (wenn ShowAsync aufgerufen wird).
|
||||
Die Referenz (_confirmDialog) erlaubt uns, ShowAsync von den Button-Handlern aufzurufen. *@
|
||||
<ConfirmDialog @ref="_confirmDialog" />
|
||||
|
||||
@code {
|
||||
// ── Parameter ──
|
||||
|
||||
/// <summary>Bei ReadOnly wird das gesamte Panel ausgeblendet</summary>
|
||||
[Parameter]
|
||||
public bool ReadOnly { get; set; }
|
||||
|
||||
/// <summary>Wird ausgelöst wenn der Benutzer "Unterschreiben" bestätigt</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnSign { get; set; }
|
||||
|
||||
/// <summary>Wird ausgelöst wenn der Benutzer "Ablehnen" bestätigt</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnReject { get; set; }
|
||||
|
||||
/// <summary>Wird ausgelöst wenn der Benutzer "Zurücksetzen" klickt</summary>
|
||||
[Parameter]
|
||||
public EventCallback OnRefresh { get; set; }
|
||||
|
||||
// ── Referenz auf den ConfirmDialog ──
|
||||
|
||||
/// <summary>
|
||||
/// Referenz auf die ConfirmDialog-Komponente.
|
||||
///
|
||||
/// WAS IST @ref?
|
||||
/// Mit @ref speichert Blazor eine Referenz auf eine Kind-Komponente.
|
||||
/// Dann kann man Methoden darauf aufrufen: _confirmDialog.ShowAsync(...)
|
||||
/// Das ist wie document.getElementById() in JavaScript, nur typsicher.
|
||||
/// </summary>
|
||||
private ConfirmDialog _confirmDialog = default!;
|
||||
|
||||
// ── Button-Handler ──
|
||||
|
||||
/// <summary>
|
||||
/// Unterschreiben: Erst bestätigen, dann Event auslösen.
|
||||
/// Der ConfirmDialog zeigt "Möchten Sie das Dokument unterschreiben?"
|
||||
/// Nur wenn der Benutzer "Ja" klickt, wird OnSign ausgelöst.
|
||||
/// </summary>
|
||||
private async Task HandleSign()
|
||||
{
|
||||
var confirmed = await _confirmDialog.ShowAsync(
|
||||
"Unterschreiben",
|
||||
"Möchten Sie das Dokument wirklich unterschreiben? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
confirmText: "Unterschreiben",
|
||||
confirmColor: "success");
|
||||
|
||||
if (confirmed)
|
||||
await OnSign.InvokeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ablehnen: Erst bestätigen, dann Event auslösen.
|
||||
/// Roter Button im ConfirmDialog, weil Ablehnen destruktiv ist.
|
||||
/// </summary>
|
||||
private async Task HandleReject()
|
||||
{
|
||||
var confirmed = await _confirmDialog.ShowAsync(
|
||||
"Ablehnen",
|
||||
"Möchten Sie das Dokument wirklich ablehnen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
confirmText: "Ablehnen",
|
||||
confirmColor: "danger");
|
||||
|
||||
if (confirmed)
|
||||
await OnReject.InvokeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zurücksetzen: Kein ConfirmDialog nötig, weil es nicht destruktiv ist.
|
||||
/// Setzt nur die Signatur-Positionen zurück, der Empfänger kann danach
|
||||
/// erneut signieren.
|
||||
/// </summary>
|
||||
private async Task HandleRefresh()
|
||||
{
|
||||
await OnRefresh.InvokeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
@* EnvelopeInfoCard: Zeigt die Umschlag-Infos oberhalb des PDF-Viewers.
|
||||
Entspricht dem Card-Bereich in ShowEnvelope.cshtml im Web-Projekt.
|
||||
|
||||
Wird angezeigt wenn der Empfänger erfolgreich authentifiziert ist
|
||||
und das Dokument sehen darf.
|
||||
|
||||
"Dumme" Komponente: Keine Services, nur Parameter → Anzeige. *@
|
||||
|
||||
<div class="card mb-3">
|
||||
@* ── Card Header: Logo + Fortschrittsbalken ──
|
||||
Im Web-Projekt gibt es hier das signFLOW-Logo und darunter
|
||||
"2/3 Signatures". Wir zeigen den Fortschritt nur wenn es
|
||||
KEIN ReadOnly-Dokument ist (ReadOnly = nur lesen, nicht signieren). *@
|
||||
<div class="card-header bg-white">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong class="text-primary">signFLOW</strong>
|
||||
|
||||
@if (!ReadOnly && SignatureTotal > 0)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="progress" style="width: 120px; height: 8px;">
|
||||
<div class="progress-bar bg-success"
|
||||
role="progressbar"
|
||||
style="width: @ProgressPercent%"
|
||||
aria-valuenow="@SignaturesDone"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="@SignatureTotal">
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted">@SignaturesDone/@SignatureTotal</small>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (ReadOnly)
|
||||
{
|
||||
<span class="badge bg-secondary">Nur Ansicht</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Card Body: Titel, Nachricht, Sender-Info ── *@
|
||||
<div class="card-body">
|
||||
@* Titel des Umschlags *@
|
||||
<h5 class="card-title">@Title</h5>
|
||||
|
||||
@* Nachricht des Absenders — im Web-Projekt wird das mit Marked.js
|
||||
als Markdown gerendert. Hier erstmal als Plain Text.
|
||||
Markdown-Rendering kommt in Phase 6 (Feinschliff). *@
|
||||
@if (!string.IsNullOrEmpty(Message))
|
||||
{
|
||||
<p class="card-text">@Message</p>
|
||||
}
|
||||
|
||||
@* Sender-Info: Wer hat es gesendet, wann?
|
||||
Im Web-Projekt steht hier:
|
||||
"Gesendet am 15.03.2026 von Max Mustermann (max@firma.de)" *@
|
||||
<p class="card-text">
|
||||
<small class="text-muted">
|
||||
@if (!string.IsNullOrEmpty(SenderName) && !string.IsNullOrEmpty(SenderEmail))
|
||||
{
|
||||
<span>
|
||||
Gesendet
|
||||
@if (SentDate is not null)
|
||||
{
|
||||
<span>am @SentDate.Value.ToString("dd.MM.yyyy")</span>
|
||||
}
|
||||
von @SenderName
|
||||
(<a href="mailto:@SenderEmail">@SenderEmail</a>)
|
||||
</span>
|
||||
}
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// ── Parameter ──
|
||||
|
||||
/// <summary>Titel des Umschlags (z.B. "Vertragsdokument")</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Nachricht des Absenders</summary>
|
||||
[Parameter]
|
||||
public string? Message { get; set; }
|
||||
|
||||
/// <summary>Name des Absenders (z.B. "Max Mustermann")</summary>
|
||||
[Parameter]
|
||||
public string? SenderName { get; set; }
|
||||
|
||||
/// <summary>E-Mail des Absenders</summary>
|
||||
[Parameter]
|
||||
public string? SenderEmail { get; set; }
|
||||
|
||||
/// <summary>Datum an dem der Umschlag gesendet wurde</summary>
|
||||
[Parameter]
|
||||
public DateTime? SentDate { get; set; }
|
||||
|
||||
/// <summary>Ob das Dokument nur zum Lesen ist (kein Signieren)</summary>
|
||||
[Parameter]
|
||||
public bool ReadOnly { get; set; }
|
||||
|
||||
/// <summary>Anzahl bereits geleisteter Unterschriften</summary>
|
||||
[Parameter]
|
||||
public int SignaturesDone { get; set; }
|
||||
|
||||
/// <summary>Gesamtanzahl benötigter Unterschriften</summary>
|
||||
[Parameter]
|
||||
public int SignatureTotal { get; set; }
|
||||
|
||||
// ── Berechnete Werte ──
|
||||
|
||||
/// <summary>
|
||||
/// Fortschritt in Prozent für den Balken.
|
||||
/// Vermeidet Division durch Null.
|
||||
/// </summary>
|
||||
private int ProgressPercent =>
|
||||
SignatureTotal > 0
|
||||
? (int)((double)SignaturesDone / SignatureTotal * 100)
|
||||
: 0;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
@* TfaForm: Zwei-Faktor-Authentifizierung — SMS oder Authenticator.
|
||||
Entspricht dem TFA-Teil von EnvelopeLocked.cshtml im Web-Projekt.
|
||||
|
||||
Wird angezeigt NACH dem AccessCode-Schritt, wenn der Umschlag TFA erfordert.
|
||||
|
||||
Zwei Varianten:
|
||||
- TfaType="sms" → SMS wurde gesendet, Countdown-Timer zeigt verbleibende Zeit
|
||||
- TfaType="authenticator" → Benutzer gibt Code aus seiner Authenticator-App ein
|
||||
|
||||
"Dumme" Komponente: Keine Services, keine API-Calls. *@
|
||||
|
||||
@implements IDisposable
|
||||
|
||||
<div class="page">
|
||||
@* ── Header: Icon + Titel ── *@
|
||||
<header class="text-center">
|
||||
<div class="status-icon locked mt-4 mb-1">
|
||||
<i class="bi bi-shield-check"></i>
|
||||
</div>
|
||||
<h1>Zwei-Faktor-Authentifizierung</h1>
|
||||
</header>
|
||||
|
||||
@* ── Erklärungstext: Unterschiedlich je nach TFA-Typ ── *@
|
||||
<section class="text-center mb-4">
|
||||
@if (TfaType == "sms")
|
||||
{
|
||||
<p class="text-muted">
|
||||
Ein Bestätigungscode wurde per SMS an Ihre Telefonnummer gesendet.
|
||||
</p>
|
||||
@* SMS-Countdown-Timer: Zeigt wie lange der Code noch gültig ist.
|
||||
Im Web-Projekt ist das JavaScript (setInterval).
|
||||
Hier machen wir es mit einem C#-Timer — kein JS nötig. *@
|
||||
@if (_remainingTime is not null)
|
||||
{
|
||||
<div class="alert @(_remainingTime > TimeSpan.Zero ? "alert-primary" : "alert-warning")">
|
||||
@if (_remainingTime > TimeSpan.Zero)
|
||||
{
|
||||
<span>Code gültig für: @_remainingTime.Value.ToString("mm\\:ss")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Code abgelaufen. Bitte fordern Sie einen neuen Code an.</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">
|
||||
Öffnen Sie Ihre Authenticator-App und geben Sie den angezeigten Code ein.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@* ── Formular ── *@
|
||||
<div class="access-code-container">
|
||||
<EditForm Model="_model" OnValidSubmit="Submit">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="form-floating mb-3">
|
||||
<InputText @bind-Value="_model.Code"
|
||||
type="password"
|
||||
class="form-control code-input"
|
||||
id="tfaCodeInput"
|
||||
placeholder="Code" />
|
||||
<label for="tfaCodeInput">
|
||||
@(TfaType == "sms" ? "SMS-Code" : "Authenticator-Code")
|
||||
</label>
|
||||
<ValidationMessage For="() => _model.Code" />
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger">@ErrorMessage</div>
|
||||
}
|
||||
|
||||
<button type="submit"
|
||||
class="btn btn-primary w-100"
|
||||
disabled="@(_isSubmitting || _remainingTime <= TimeSpan.Zero)">
|
||||
@if (_isSubmitting)
|
||||
{
|
||||
<LoadingIndicator Small="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-unlock me-2"></i>
|
||||
<span>Bestätigen</span>
|
||||
}
|
||||
</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// ── Parameter von der Eltern-Page ──
|
||||
|
||||
/// <summary>Der Envelope-Key aus der URL</summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string EnvelopeKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// "sms" oder "authenticator" — bestimmt welche Variante angezeigt wird.
|
||||
/// Kommt aus der API-Antwort nach dem AccessCode-Schritt.
|
||||
/// </summary>
|
||||
[Parameter, EditorRequired]
|
||||
public string TfaType { get; set; } = "authenticator";
|
||||
|
||||
/// <summary>
|
||||
/// Ablaufzeit des SMS-Codes. Nur bei TfaType="sms" relevant.
|
||||
/// Der Timer zählt von jetzt bis zu diesem Zeitpunkt runter.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public DateTime? TfaExpiration { get; set; }
|
||||
|
||||
/// <summary>Ob der Empfänger eine Telefonnummer hat</summary>
|
||||
[Parameter]
|
||||
public bool HasPhoneNumber { get; set; }
|
||||
|
||||
/// <summary>Fehlermeldung (z.B. "Falscher Code")</summary>
|
||||
[Parameter]
|
||||
public string? ErrorMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback: Gibt (Code, Type) an die Page zurück.
|
||||
/// Die Page sendet das dann an die API.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public EventCallback<(string Code, string Type)> OnSubmit { get; set; }
|
||||
|
||||
// ── Interner State ──
|
||||
|
||||
private TfaCodeModel _model = new();
|
||||
private bool _isSubmitting;
|
||||
private TimeSpan? _remainingTime;
|
||||
private System.Threading.Timer? _timer;
|
||||
|
||||
// ── Lifecycle ──
|
||||
|
||||
/// <summary>
|
||||
/// Wird aufgerufen wenn die Komponente initialisiert wird.
|
||||
/// Startet den SMS-Countdown-Timer falls nötig.
|
||||
///
|
||||
/// OnInitialized (nicht Async) reicht hier, weil wir keinen
|
||||
/// await brauchen — der Timer läuft über einen Callback.
|
||||
/// </summary>
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (TfaType == "sms" && TfaExpiration is not null)
|
||||
{
|
||||
// Restzeit berechnen
|
||||
_remainingTime = TfaExpiration.Value - DateTime.Now;
|
||||
|
||||
// Timer: Jede Sekunde _remainingTime aktualisieren.
|
||||
// InvokeAsync + StateHasChanged sagt Blazor: "Zeichne die UI neu".
|
||||
// Das ist das Blazor-Äquivalent zu setInterval + DOM-Update in JavaScript.
|
||||
_timer = new System.Threading.Timer(_ =>
|
||||
{
|
||||
_remainingTime = TfaExpiration.Value - DateTime.Now;
|
||||
|
||||
if (_remainingTime <= TimeSpan.Zero)
|
||||
{
|
||||
_remainingTime = TimeSpan.Zero;
|
||||
_timer?.Dispose();
|
||||
}
|
||||
|
||||
// InvokeAsync ist nötig, weil der Timer auf einem anderen Thread läuft.
|
||||
// Blazor erlaubt UI-Updates nur auf dem UI-Thread.
|
||||
InvokeAsync(StateHasChanged);
|
||||
},
|
||||
state: null,
|
||||
dueTime: TimeSpan.Zero, // Sofort starten
|
||||
period: TimeSpan.FromSeconds(1) // Jede Sekunde
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
_isSubmitting = true;
|
||||
await OnSubmit.InvokeAsync((_model.Code, TfaType));
|
||||
_isSubmitting = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aufräumen: Timer stoppen wenn die Komponente entfernt wird.
|
||||
/// Ohne Dispose würde der Timer weiterlaufen und Fehler verursachen,
|
||||
/// weil er versucht eine nicht mehr existierende UI zu aktualisieren.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_timer?.Dispose();
|
||||
}
|
||||
|
||||
// ── Validierungs-Model ──
|
||||
|
||||
private class TfaCodeModel
|
||||
{
|
||||
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Bitte Code eingeben")]
|
||||
[System.ComponentModel.DataAnnotations.StringLength(6, MinimumLength = 6, ErrorMessage = "Der Code muss 6 Zeichen lang sein")]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user