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:
OlgunR
2026-03-23 12:37:14 +01:00
parent 0a544cfe85
commit 7aa9853756
11 changed files with 1109 additions and 117 deletions

View File

@@ -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")]

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}