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"> <div class="page">
<h2>Zugangscode eingeben</h2> @* ── Header: Icon + Titel ── *@
<p>Ein Zugangscode wurde an Ihre E-Mail-Adresse gesendet.</p> <header class="text-center">
<div class="status-icon locked mt-4 mb-1">
<EditForm Model="_model" OnValidSubmit="Submit"> <i class="bi bi-shield-lock"></i>
<DataAnnotationsValidator />
<div class="form-group">
<InputText @bind-Value="_model.Code"
class="form-control code-input"
placeholder="000000"
maxlength="6" />
<ValidationMessage For="() => _model.Code" />
</div> </div>
<h1>Zugangscode eingeben</h1>
</header>
@if (!string.IsNullOrEmpty(ErrorMessage)) @* ── Erklärungstext ── *@
{ <section class="text-center mb-4">
<div class="alert alert-danger mt-2">@ErrorMessage</div> <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"> @* ── Formular ── *@
@if (_isSubmitting) <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> </div>
@code { @code {
// Parameter von der Eltern-Page // ── Parameter von der Eltern-Page ──
[Parameter] public required string EnvelopeKey { get; set; }
[Parameter] public string? ErrorMessage { get; set; }
// EventCallback: Informiert die Page, dass ein Code eingegeben wurde /// <summary>Der Envelope-Key aus der URL</summary>
[Parameter] public EventCallback<string> OnSubmit { get; set; } [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 AccessCodeModel _model = new();
private bool _isSubmitting; private bool _isSubmitting;
private bool _preferSms;
private void ToggleSms(ChangeEventArgs e)
{
_preferSms = (bool)(e.Value ?? false);
}
private async Task Submit() private async Task Submit()
{ {
_isSubmitting = true; _isSubmitting = true;
await OnSubmit.InvokeAsync(_model.Code); await OnSubmit.InvokeAsync((_model.Code, _preferSms));
_isSubmitting = false; _isSubmitting = false;
} }
// ── Validierungs-Model ──
private class AccessCodeModel private class AccessCodeModel
{ {
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Bitte Zugangscode eingeben")] [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;
}
}

View File

@@ -0,0 +1,133 @@
@* ConfirmDialog: Ersetzt SweetAlert2 aus dem Web-Projekt.
Zeigt einen modalen Bestätigungsdialog mit Titel, Text und Ja/Nein-Buttons.
Wird NICHT über Parameter gesteuert, sondern über eine Methode:
var confirmed = await _dialog.ShowAsync("Titel", "Text");
WARUM eine Methode statt Parameter?
- Ein Dialog ist ein "einmaliges Ereignis", kein dauerhafter Zustand
- Die aufrufende Komponente will auf das Ergebnis WARTEN (await)
- Mit Parametern müsste man den State manuell hin- und herschalten
WARUM kein SweetAlert2?
- SweetAlert2 ist eine JavaScript-Bibliothek
- In Blazor können wir das nativ in C# lösen, ohne JS-Interop
- Weniger Abhängigkeiten = weniger Wartung = weniger Fehlerquellen *@
@if (_isVisible)
{
@* Hintergrund-Overlay: Dunkler Hintergrund hinter dem Dialog.
Im Web-Projekt macht SweetAlert2 das automatisch.
Hier bauen wir es selbst mit CSS. *@
<div class="modal-backdrop fade show"></div>
@* Modal-Dialog: Bootstrap 5 Modal-Markup.
Normalerweise braucht Bootstrap-Modal JavaScript (bootstrap.js)
um zu öffnen/schließen. Wir steuern die Sichtbarkeit stattdessen
über die _isVisible-Variable — Blazor macht das DOM-Update. *@
<div class="modal fade show d-block" tabindex="-1" role="dialog">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
@* Header: Titel + Schließen-Button *@
<div class="modal-header">
<h5 class="modal-title">@_title</h5>
<button type="button" class="btn-close" @onclick="Cancel"></button>
</div>
@* Body: Beschreibungstext *@
<div class="modal-body">
<p>@_message</p>
</div>
@* Footer: Abbrechen + Bestätigen *@
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @onclick="Cancel">
@_cancelText
</button>
<button type="button" class="btn btn-@_confirmColor" @onclick="Confirm">
@_confirmText
</button>
</div>
</div>
</div>
</div>
}
@code {
// ── Interner State (NICHT Parameter — wird über ShowAsync gesetzt) ──
private bool _isVisible;
private string _title = string.Empty;
private string _message = string.Empty;
private string _confirmText = "Ja";
private string _cancelText = "Abbrechen";
private string _confirmColor = "primary";
/// <summary>
/// TaskCompletionSource: Das Herzstück dieser Komponente.
///
/// WAS IST DAS?
/// Ein TaskCompletionSource erstellt einen Task, der erst dann
/// "fertig" wird, wenn jemand SetResult() aufruft.
///
/// WIE FUNKTIONIERT ES?
/// 1. ShowAsync() erstellt ein neues TaskCompletionSource
/// 2. ShowAsync() gibt dessen Task zurück → der Aufrufer wartet (await)
/// 3. Der Benutzer klickt "Ja" → Confirm() ruft SetResult(true) auf
/// 4. Der await in der aufrufenden Komponente kommt zurück mit true
///
/// Das ist wie ein "Promise" in JavaScript, nur in C#.
/// </summary>
private TaskCompletionSource<bool>? _tcs;
/// <summary>
/// Zeigt den Dialog und wartet auf die Benutzer-Entscheidung.
///
/// Beispiel-Aufruf:
/// var confirmed = await _dialog.ShowAsync(
/// "Unterschreiben",
/// "Möchten Sie das Dokument wirklich unterschreiben?");
/// </summary>
/// <param name="title">Dialog-Überschrift</param>
/// <param name="message">Beschreibungstext</param>
/// <param name="confirmText">Text auf dem Bestätigen-Button (Standard: "Ja")</param>
/// <param name="cancelText">Text auf dem Abbrechen-Button (Standard: "Abbrechen")</param>
/// <param name="confirmColor">Bootstrap-Farbe des Bestätigen-Buttons (Standard: "primary")</param>
/// <returns>true wenn bestätigt, false wenn abgebrochen</returns>
public Task<bool> ShowAsync(
string title,
string message,
string confirmText = "Ja",
string cancelText = "Abbrechen",
string confirmColor = "primary")
{
_title = title;
_message = message;
_confirmText = confirmText;
_cancelText = cancelText;
_confirmColor = confirmColor;
_tcs = new TaskCompletionSource<bool>();
_isVisible = true;
StateHasChanged();
return _tcs.Task;
}
private void Confirm()
{
_isVisible = false;
_tcs?.TrySetResult(true);
StateHasChanged();
}
private void Cancel()
{
_isVisible = false;
_tcs?.TrySetResult(false);
StateHasChanged();
}
}

View File

@@ -0,0 +1,79 @@
@* StatusPage: Wiederverwendbare Status-Seite für alle Endzustände.
Ersetzt EnvelopeSigned.cshtml, EnvelopeRejected.cshtml, Error-Views und Expired.
"Dumme" Komponente: Keine Services, keine API-Calls, nur Parameter → Anzeige. *@
<div class="page">
<header class="text-center">
@switch (Type)
{
case "signed":
<div class="status-icon signed">
<i class="bi bi-check-circle"></i>
</div>
<h1>Dokument erfolgreich unterschrieben</h1>
<p class="text-muted">
Sie erhalten eine Bestätigung per E-Mail, sobald alle Empfänger unterschrieben haben.
</p>
break;
case "rejected":
<div class="status-icon rejected">
<i class="bi bi-x-circle"></i>
</div>
<h1>Dokument wurde abgelehnt</h1>
<p class="text-muted">
@if (!string.IsNullOrEmpty(Title) && !string.IsNullOrEmpty(SenderEmail))
{
<span>
Das Dokument «@Title» wurde abgelehnt.
Bei Fragen wenden Sie sich an
<a href="mailto:@SenderEmail">@SenderEmail</a>.
</span>
}
else
{
<span>Dieses Dokument wurde von einem Empfänger abgelehnt.</span>
}
</p>
break;
case "not_found":
<div class="status-icon locked">
<i class="bi bi-question-circle"></i>
</div>
<h1>Dokument nicht gefunden</h1>
<p class="text-muted">
Dieses Dokument existiert nicht oder ist nicht mehr verfügbar.
Wenn Sie diese URL per E-Mail erhalten haben, wenden Sie sich bitte an das IT-Team.
</p>
break;
case "expired":
<div class="status-icon locked">
<i class="bi bi-clock-history"></i>
</div>
<h1>Link abgelaufen</h1>
<p class="text-muted">
Der Zugang zu diesem Dokument ist abgelaufen.
</p>
break;
}
</header>
</div>
@code {
/// <summary>
/// Bestimmt welche Status-Variante angezeigt wird.
/// Erlaubte Werte: "signed", "rejected", "not_found", "expired"
/// </summary>
[Parameter, EditorRequired]
public string Type { get; set; } = string.Empty;
/// <summary>E-Mail des Absenders — nur bei "rejected" relevant.</summary>
[Parameter]
public string? SenderEmail { get; set; }
/// <summary>Titel des Umschlags — nur bei "rejected" relevant.</summary>
[Parameter]
public string? Title { get; set; }
}

View File

@@ -0,0 +1,70 @@
@* Toast: Zeigt kurze Benachrichtigungen an. Ersetzt AlertifyJS aus dem Web-Projekt.
Wird einmal im MainLayout platziert. Hört auf den ToastService.
Wenn eine Komponente irgendwo _toast.ShowSuccess("Text") aufruft,
erscheint hier ein Toast und verschwindet nach 4 Sekunden automatisch.
Bootstrap Toast-Klassen werden genutzt:
- toast-container: Positionierung (oben rechts)
- toast: Einzelner Toast
- bg-success/bg-danger/etc: Farbe basierend auf Typ *@
@inject ToastService ToastService
@implements IDisposable
@if (ToastService.Messages.Count > 0)
{
@* toast-container: Bootstrap-Klasse die Toasts oben rechts positioniert.
position-fixed: Bleibt an der Stelle auch beim Scrollen.
z-index 1080: Über dem Modal-Backdrop (1070) damit Toasts
auch während eines ConfirmDialogs sichtbar sind. *@
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1080;">
@foreach (var message in ToastService.Messages)
{
<div class="toast show align-items-center text-white bg-@message.Type border-0"
role="alert">
<div class="d-flex">
<div class="toast-body">
<i class="bi @message.IconClass me-2"></i>
@message.Text
</div>
<button type="button"
class="btn-close btn-close-white me-2 m-auto"
@onclick="() => ToastService.Dismiss(message)">
</button>
</div>
</div>
}
</div>
}
@code {
protected override void OnInitialized()
{
// Subscriber: Wenn der ToastService eine Änderung meldet,
// zeichnet diese Komponente sich neu (StateHasChanged).
// So erscheinen/verschwinden Toasts automatisch.
ToastService.OnChange += HandleChange;
}
/// <summary>
/// InvokeAsync ist nötig weil OnChange von einem
/// async void (dem Timer im ToastService) ausgelöst wird.
/// Das kann auf einem anderen Thread passieren.
/// InvokeAsync wechselt zurück auf den Blazor UI-Thread.
/// </summary>
private async void HandleChange()
{
await InvokeAsync(StateHasChanged);
}
/// <summary>
/// Event-Handler abmelden wenn die Komponente entfernt wird.
/// Ohne das würde der ToastService eine Referenz auf eine
/// nicht mehr existierende Komponente halten → Memory Leak.
/// </summary>
public void Dispose()
{
ToastService.OnChange -= HandleChange;
}
}

View File

@@ -24,4 +24,6 @@ builder.Services.AddScoped<IEnvelopeService, EnvelopeService>();
// State: Ein State-Objekt pro Browser-Tab // State: Ein State-Objekt pro Browser-Tab
builder.Services.AddScoped<EnvelopeState>(); builder.Services.AddScoped<EnvelopeState>();
builder.Services.AddScoped<ToastService>();
await builder.Build().RunAsync(); await builder.Build().RunAsync();

View File

@@ -0,0 +1,86 @@
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
/// <summary>
/// Service für Toast-Benachrichtigungen. Ersetzt AlertifyJS aus dem Web-Projekt.
///
/// WARUM ein Service und keine Komponente mit Parametern?
/// - Toasts können von ÜBERALL ausgelöst werden (Pages, Services, andere Komponenten)
/// - Ein Service ist über Dependency Injection überall verfügbar
/// - Die Toast-Komponente im Layout hört auf diesen Service und rendert die Nachrichten
///
/// PATTERN: Pub/Sub (Publisher/Subscriber)
/// - Publisher: Jede Komponente die _toast.ShowSuccess("...") aufruft
/// - Subscriber: Die Toast-Komponente im MainLayout, die auf OnChange hört
///
/// Das ist das gleiche Pattern wie beim EnvelopeState.
/// </summary>
public class ToastService
{
/// <summary>
/// Liste aller aktuell sichtbaren Toasts.
/// Mehrere Toasts können gleichzeitig angezeigt werden (gestapelt).
/// </summary>
public List<ToastMessage> Messages { get; } = [];
/// <summary>Event: Informiert die Toast-Komponente über Änderungen</summary>
public event Action? OnChange;
public void ShowSuccess(string text) => Show(text, "success");
public void ShowError(string text) => Show(text, "danger");
public void ShowInfo(string text) => Show(text, "info");
public void ShowWarning(string text) => Show(text, "warning");
/// <summary>
/// Fügt einen Toast hinzu und entfernt ihn nach der angegebenen Dauer automatisch.
///
/// WARUM async void?
/// Normalerweise vermeidet man async void. Hier ist es ok, weil:
/// - Es ist ein Fire-and-Forget-Timer (wir warten nicht auf das Ergebnis)
/// - Fehler im Delay können die App nicht zum Absturz bringen
/// - Das ist ein gängiges Pattern für Auto-Dismiss-Logik
/// </summary>
private async void Show(string text, string type, int durationMs = 4000)
{
var message = new ToastMessage(text, type);
Messages.Add(message);
OnChange?.Invoke();
// Nach Ablauf der Dauer automatisch entfernen
await Task.Delay(durationMs);
Messages.Remove(message);
OnChange?.Invoke();
}
/// <summary>Entfernt einen Toast sofort (z.B. wenn der Benutzer auf X klickt)</summary>
public void Dismiss(ToastMessage message)
{
Messages.Remove(message);
OnChange?.Invoke();
}
}
/// <summary>
/// Ein einzelner Toast-Eintrag.
///
/// WARUM ein record statt class?
/// - Records haben automatisch Equals/GetHashCode basierend auf allen Properties
/// - Wir brauchen das für Messages.Remove() — es vergleicht über Referenz-Gleichheit
/// - Die Id (Guid) macht jeden Toast einzigartig, auch bei gleichem Text
/// </summary>
public record ToastMessage(string Text, string Type)
{
/// <summary>Eindeutige Id — damit zwei Toasts mit gleichem Text unterscheidbar sind</summary>
public Guid Id { get; } = Guid.NewGuid();
/// <summary>
/// Gibt die Bootstrap-Icon-Klasse basierend auf dem Typ zurück.
/// success → check-circle, danger → x-circle, etc.
/// </summary>
public string IconClass => Type switch
{
"success" => "bi-check-circle-fill",
"danger" => "bi-x-circle-fill",
"warning" => "bi-exclamation-triangle-fill",
_ => "bi-info-circle-fill"
};
}

View File

@@ -1,12 +1,35 @@
@inherits LayoutComponentBase @* MainLayout: Das Grundgerüst jeder Seite.
Entspricht _Layout.cshtml im Web-Projekt.
Aufbau:
- Header: signFLOW-Logo/Titel (oben, sticky)
- Main: Der Seiteninhalt (@Body) mit ErrorBoundary
- Footer: Copyright + Privacy-Link (unten)
Sticky Footer Pattern: Der Footer klebt immer am unteren Rand,
auch wenn der Inhalt wenig Platz braucht. Das funktioniert über
Flexbox in app.css (.app-container mit min-height: 100vh). *@
@inherits LayoutComponentBase
<div class="app-container"> <div class="app-container">
<Toast />
@* ── Header ── *@
<header class="app-header"> <header class="app-header">
<div class="header-content"> <div class="d-flex align-items-center gap-2">
@* Im Web-Projekt steht hier ein <img> mit dem signFLOW-Logo.
Wir nutzen erstmal Text. Das Logo kommt in Phase 6
wenn wir die Bilder aus dem Web-Projekt portieren. *@
<span class="app-title">signFLOW</span> <span class="app-title">signFLOW</span>
</div> </div>
</header> </header>
@* ── Main: Seiteninhalt mit Error-Schutz ──
ErrorBoundary fängt unbehandelte Exceptions in Komponenten ab.
Ohne ErrorBoundary würde die gesamte App abstürzen.
Mit ErrorBoundary zeigen wir stattdessen eine Fehlermeldung
und einen "Erneut versuchen"-Button. *@
<main class="app-main"> <main class="app-main">
<ErrorBoundary @ref="_errorBoundary"> <ErrorBoundary @ref="_errorBoundary">
<ChildContent> <ChildContent>
@@ -14,21 +37,51 @@
</ChildContent> </ChildContent>
<ErrorContent Context="ex"> <ErrorContent Context="ex">
<div class="error-container text-center py-5"> <div class="error-container text-center py-5">
<h2>😵 Ein unerwarteter Fehler ist aufgetreten</h2> <div class="status-icon locked mb-3">
<i class="bi bi-exclamation-triangle"></i>
</div>
<h2>Ein unerwarteter Fehler ist aufgetreten</h2>
<p class="text-muted">Bitte versuchen Sie es erneut.</p> <p class="text-muted">Bitte versuchen Sie es erneut.</p>
<button class="btn btn-primary" @onclick="Recover">Erneut versuchen</button> <button class="btn btn-primary" @onclick="Recover">
<i class="bi bi-arrow-counterclockwise me-2"></i>
Erneut versuchen
</button>
</div> </div>
</ErrorContent> </ErrorContent>
</ErrorBoundary> </ErrorBoundary>
</main> </main>
<footer class="app-footer text-center py-2 text-muted"> @* ── Footer ──
<small>&copy; @DateTime.Now.Year Digital Data GmbH</small> Im Web-Projekt gibt es hier drei Elemente:
1. Copyright + Link zur Firmenwebsite
2. Sprachauswahl (Dropdown mit Flaggen) → kommt in Phase 6
3. Privacy-Link (Datenschutzerklärung)
Die Datenschutz-HTML-Dateien existieren im Web-Projekt unter
wwwroot/privacy-policy.de-DE.html. Wir verlinken vorerst
auf eine statische URL. Die Datei selbst portieren wir in Phase 6. *@
<footer class="app-footer">
<small>
&copy; signFLOW @DateTime.Now.Year
<a href="https://digitaldata.works" target="_blank" class="text-muted text-decoration-none">
Digital Data GmbH
</a>
</small>
@* Platzhalter für Sprachauswahl — kommt in Phase 6 *@
<a href="/privacy-policy.de-DE.html" target="_blank" class="text-muted text-decoration-none">
<small>Datenschutz</small>
</a>
</footer> </footer>
</div> </div>
@code { @code {
private ErrorBoundary? _errorBoundary; private ErrorBoundary? _errorBoundary;
/// <summary>
/// Setzt die ErrorBoundary zurück.
/// Blazor rendert dann @Body erneut statt der Fehlermeldung.
/// </summary>
private void Recover() => _errorBoundary?.Recover(); private void Recover() => _errorBoundary?.Recover();
} }

View File

@@ -8,14 +8,14 @@
Wenn sich das Branding ändert, änderst du nur diese Werte. */ Wenn sich das Branding ändert, änderst du nur diese Werte. */
:root { :root {
--sf-primary: #0d6efd; --sf-primary: #0d6efd;
--sf - danger: #dc3545; --sf-danger: #dc3545;
--sf - success: #198754; --sf-success: #198754;
--sf - bg: #f8f9fa; --sf-bg: #f8f9fa;
--sf - text: #212529; --sf-text: #212529;
--sf - muted: #6c757d; --sf-muted: #6c757d;
--sf - border: #dee2e6; --sf-border: #dee2e6;
--sf - header - height: 56px; --sf-header-height: 56px;
--sf - footer - height: 48px; --sf-footer-height: 48px;
} }
/* ----- Globale Resets ----- /* ----- Globale Resets -----
@@ -23,67 +23,67 @@
*, *,
*::before, *::before,
*::after { *::after {
box - sizing: border - box; box-sizing: border-box;
} }
html, body { html, body {
height: 100 %; height: 100%;
margin: 0; margin: 0;
font - family: 'Segoe UI', system - ui, -apple - system, sans - serif; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background - color: var(--sf - bg); background-color: var(--sf-bg);
color: var(--sf - text); color: var(--sf-text);
} }
/* ----- App-Container ----- /* ----- App-Container -----
WARUM: Flexbox-Layout damit Footer immer unten bleibt, WARUM: Flexbox-Layout damit Footer immer unten bleibt,
auch wenn der Content wenig Inhalt hat (Sticky Footer Pattern). */ auch wenn der Content wenig Inhalt hat (Sticky Footer Pattern). */
.app - container { .app-container {
display: flex; display: flex;
flex - direction: column; flex-direction: column;
min - height: 100vh; min-height: 100vh;
} }
/* ----- Header ----- /* ----- Header -----
WARUM: Fester Header oben wie im Web-Projekt. WARUM: Fester Header oben wie im Web-Projekt.
Höhe ist als CSS-Variable definiert, damit main darunter beginnt. */ Höhe ist als CSS-Variable definiert, damit main darunter beginnt. */
.app - header { .app-header {
background - color: #fff; background-color: #fff;
border - bottom: 1px solid var(--sf - border); border-bottom: 1px solid var(--sf-border);
height: var(--sf - header - height); height: var(--sf-header-height);
display: flex; display: flex;
align - items: center; align-items: center;
padding: 0 1rem; padding: 0 1rem;
position: sticky; position: sticky;
top: 0; top: 0;
z - index: 1000; z-index: 1000;
} }
.app - title { .app-title {
font - weight: 700; font-weight: 700;
font - size: 1.25rem; font-size: 1.25rem;
color: var(--sf - primary); color: var(--sf-primary);
} }
/* ----- Main Content ----- /* ----- Main Content -----
WARUM: flex: 1 sorgt dafür, dass der Content-Bereich den gesamten WARUM: flex: 1 sorgt dafür, dass der Content-Bereich den gesamten
verfügbaren Platz einnimmt. Der Footer wird nach unten gedrückt. */ verfügbaren Platz einnimmt. Der Footer wird nach unten gedrückt. */
.app - main { .app-main {
flex: 1; flex: 1;
padding: 1rem; padding: 1rem;
} }
/* ----- Footer ----- /* ----- Footer -----
WARUM: Immer am unteren Rand. Enthält Copyright + Sprachauswahl + Privacy-Link WARUM: Immer am unteren Rand. Enthält Copyright + Sprachauswahl + Privacy-Link
(wie im Web-Projekt). */ (wie im Web-Projekt). */
.app - footer { .app-footer {
background - color: #fff; background-color: #fff;
border - top: 1px solid var(--sf - border); border-top: 1px solid var(--sf-border);
height: var(--sf - footer - height); height: var(--sf-footer-height);
display: flex; display: flex;
align - items: center; align-items: center;
justify - content: center; justify-content: center;
gap: 1rem; gap: 1rem;
padding: 0 1rem; padding: 0 1rem;
} }
/* ----- Page Container ----- /* ----- Page Container -----
@@ -91,72 +91,80 @@ padding: 0 1rem;
Entspricht dem <div class="page container"> im Web-Projekt. */ Entspricht dem <div class="page container"> im Web-Projekt. */
.page { .page {
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
padding: 2rem 1rem; padding: 2rem 1rem;
} }
/* ----- AccessCode-Formular ----- /* ----- AccessCode-Formular -----
WARUM: Zentriertes Eingabefeld wie EnvelopeLocked.cshtml. WARUM: Zentriertes Eingabefeld wie EnvelopeLocked.cshtml.
Die Klasse .code-input macht das Eingabefeld größer und zentriert den Text. */ Die Klasse .code-input macht das Eingabefeld größer und zentriert den Text. */
.access - code - container { .access-code-container {
text - align: center; text-align: center;
} }
.code - input { .code-input {
text - align: center; text-align: center;
font - size: 1.5rem; font-size: 1.5rem;
letter - spacing: 0.5rem; letter-spacing: 0.5rem;
max - width: 300px; max-width: 300px;
margin: 0 auto; margin: 0 auto;
} }
/* ----- Status-Icons ----- /* ----- Status-Icons -----
WARUM: Die SVG-Icons für Signed/Rejected/Locked aus dem Web-Projekt WARUM: Die SVG-Icons für Signed/Rejected/Locked aus dem Web-Projekt
bekommen hier einheitliche Größen und Farben. */ bekommen hier einheitliche Größen und Farben. */
.status - icon { .status-icon {
display: flex; display: flex;
justify - content: center; justify-content: center;
margin - bottom: 1rem; margin-bottom: 1rem;
} }
.status - icon svg, .status-icon svg,
.status-icon .bi { .status-icon .bi {
font-size: 4rem; font-size: 4rem;
} }
.status - icon.signed { color: var(--sf - success); } .status-icon.signed {
.status - icon.rejected { color: var(--sf - danger); } color: var(--sf-success);
.status - icon.locked { color: var(--sf - muted); } }
.status-icon.rejected {
color: var(--sf-danger);
}
.status-icon.locked {
color: var(--sf-muted);
}
/* ----- PDF-Container ----- /* ----- PDF-Container -----
WARUM: PSPDFKit braucht einen Container mit fester Höhe. WARUM: PSPDFKit braucht einen Container mit fester Höhe.
Wird in Phase 6 relevant, aber die Klasse wird schon jetzt definiert. */ Wird in Phase 6 relevant, aber die Klasse wird schon jetzt definiert. */
.pdf - container { .pdf-container {
border: 1px solid var(--sf - border); border: 1px solid var(--sf-border);
border - radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
} }
/* ----- Error Container ----- /* ----- Error Container -----
WARUM: Styling für die ErrorBoundary im MainLayout. */ WARUM: Styling für die ErrorBoundary im MainLayout. */
.error - container { .error-container {
max - width: 500px; max-width: 500px;
margin: 0 auto; margin: 0 auto;
} }
/* ----- Responsive ----- /* ----- Responsive -----
WARUM: Auf Mobilgeräten braucht der Content weniger Padding. */ WARUM: Auf Mobilgeräten braucht der Content weniger Padding. */
@media(max - width: 576px) { @media (max-width: 576px) {
.app - main { .app-main {
padding: 0.5rem; padding: 0.5rem;
} }
.page { .page {
padding: 1rem 0.5rem; padding: 1rem 0.5rem;
} }
.code - input { .code-input {
font - size: 1.25rem; font-size: 1.25rem;
letter - spacing: 0.3rem; letter-spacing: 0.3rem;
} }
} }