Refactor: cleanup deprecated Blazor UI, remove placeholders & WASM

Major project cleanup and refactor:
- Removed placeholder and unused .razor components and models.
- Deleted obsolete client-side (WASM) project files and custom auth logic.
- Improved layout and styling for a more professional, responsive UI.
- Consolidated state management and API service patterns.
- Removed favicon and Bootstrap Icons font files; cleaned up static assets.
- Updated solution file to remove references to deleted projects.
- Prepared codebase for a server-side or hybrid Blazor architecture with a maintainable, focused structure.
This commit is contained in:
2026-05-12 15:17:21 +02:00
parent 8655d84ed5
commit d24049cc02
69 changed files with 0 additions and 2633 deletions

View File

@@ -1,46 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using EnvelopeGenerator.ReceiverUI.Client.Services;
namespace EnvelopeGenerator.ReceiverUI.Client.Auth;
/// <summary>
/// Fragt die API, ob der Nutzer eingeloggt ist.
///
/// WARUM nicht selbst Token lesen?
/// - Das Auth-Cookie ist HttpOnly → JavaScript/WASM kann es nicht lesen
/// - Stattdessen: Frage die API "bin ich eingeloggt?" → GET /api/auth/check
/// - Die API prüft das Cookie serverseitig und antwortet mit 200 oder 401
/// </summary>
public class ApiAuthStateProvider : AuthenticationStateProvider
{
private readonly IAuthService _authService;
public ApiAuthStateProvider(IAuthService authService)
{
_authService = authService;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var result = await _authService.CheckAuthAsync();
if (result.IsSuccess)
{
// Eingeloggt → Erstelle einen authentifizierten ClaimsPrincipal
var identity = new ClaimsIdentity("cookie");
return new AuthenticationState(new ClaimsPrincipal(identity));
}
// Nicht eingeloggt
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
/// <summary>
/// Wird nach Login/Logout aufgerufen, damit Blazor den Auth-State aktualisiert.
/// </summary>
public void NotifyAuthChanged()
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}

View File

@@ -1,172 +0,0 @@
@* 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="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>
@* ── 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>
@* ── 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)
{
<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>
}
@* Fehlermeldung: Wird von der Eltern-Page gesetzt
wenn der AccessCode falsch war *@
@if (!string.IsNullOrEmpty(ErrorMessage))
{
<div class="alert alert-danger">@ErrorMessage</div>
}
@* 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 ──
/// <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, _preferSms));
_isSubmitting = false;
}
// ── Validierungs-Model ──
private class AccessCodeModel
{
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Bitte Zugangscode eingeben")]
[System.ComponentModel.DataAnnotations.StringLength(6, MinimumLength = 4)]
public string Code { get; set; } = string.Empty;
}
}

View File

@@ -1,126 +0,0 @@
@* 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

@@ -1,121 +0,0 @@
@* 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

@@ -1,23 +0,0 @@
@inject IJSRuntime JS
@implements IAsyncDisposable
<div id="pspdfkit-container" class="pdf-container" style="width: 100%; height: 80vh;"></div>
@code {
[Parameter] public byte[]? DocumentBytes { get; set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && DocumentBytes is not null)
{
// TODO: PSPDFKit JS-Interop implementieren (Phase 6)
// await JS.InvokeVoidAsync("initPdfViewer", DocumentBytes);
}
}
public async ValueTask DisposeAsync()
{
// TODO: PSPDFKit aufräumen
// await JS.InvokeVoidAsync("destroyPdfViewer");
}
}

View File

@@ -1,202 +0,0 @@
@* 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

@@ -1,133 +0,0 @@
@* 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

@@ -1,19 +0,0 @@
<div class="text-center py-5">
@if (!string.IsNullOrEmpty(Icon))
{
<div class="mb-3">
<i class="bi bi-@Icon" style="font-size: 3rem;"></i>
</div>
}
<h2>@Title</h2>
@if (!string.IsNullOrEmpty(Message))
{
<p class="text-muted">@Message</p>
}
</div>
@code {
[Parameter] public string Title { get; set; } = "Fehler";
[Parameter] public string? Message { get; set; }
[Parameter] public string? Icon { get; set; }
}

View File

@@ -1,18 +0,0 @@
<div class="d-flex justify-content-center align-items-center @(Small ? "" : "py-5")" style="@(Small ? "" : "min-height: 40vh;")">
<div class="text-center">
<div class="spinner-border @(Small ? "spinner-border-sm" : "text-primary")"
style="@(Small ? "" : "width: 3rem; height: 3rem;")"
role="status">
<span class="visually-hidden">Laden...</span>
</div>
@if (!Small && Message is not null)
{
<p class="mt-3 text-muted">@Message</p>
}
</div>
</div>
@code {
[Parameter] public bool Small { get; set; }
[Parameter] public string? Message { get; set; }
}

View File

@@ -1,79 +0,0 @@
@* 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

@@ -1,70 +0,0 @@
@* 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

@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="9.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.3" />
</ItemGroup>
</Project>

View File

@@ -1,13 +0,0 @@
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
/// <summary>
/// Hält den aktuellen Authentifizierungs-Zustand im Client.
/// Wird vom ApiAuthStateProvider gesetzt und von Komponenten gelesen.
/// </summary>
public class AuthState
{
public bool IsAuthenticated { get; set; }
public string? Role { get; set; }
public string? EnvelopeUuid { get; set; }
public string? ReceiverEmail { get; set; }
}

View File

@@ -1,12 +0,0 @@
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
/// <summary>
/// Client-seitiges DTO für Dokument-Daten.
/// </summary>
public record DocumentModel
{
public int Id { get; init; }
public int EnvelopeId { get; init; }
public DateTime AddedWhen { get; init; }
public byte[]? ByteData { get; init; }
}

View File

@@ -1,27 +0,0 @@
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
/// <summary>
/// Client-seitiges DTO für Umschlag-Daten.
/// Muss nur die JSON-Properties matchen, die die API zurückgibt
/// und die der Client tatsächlich braucht.
///
/// WARUM eigene DTOs statt die aus EnvelopeGenerator.Application?
/// - Application hat Server-Abhängigkeiten (SqlClient, JwtBearer, EF Core)
/// - Diese Pakete existieren nicht für browser-wasm → Build-Fehler
/// - Der Client braucht nur eine Teilmenge der Felder
/// - Eigene DTOs machen den Client unabhängig vom Server
/// </summary>
public record EnvelopeModel
{
public int Id { get; init; }
public string Uuid { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
public bool UseAccessCode { get; init; }
public bool TFAEnabled { get; init; }
public bool ReadOnly { get; init; }
public string Language { get; init; } = "de-DE";
public DateTime AddedWhen { get; init; }
public UserModel? User { get; init; }
public IEnumerable<DocumentModel>? Documents { get; init; }
}

View File

@@ -1,15 +0,0 @@
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
/// <summary>
/// Client-seitiges DTO für die Envelope-Receiver-Zuordnung.
/// </summary>
public record EnvelopeReceiverModel
{
public EnvelopeModel? Envelope { get; init; }
public ReceiverModel? Receiver { get; init; }
public int EnvelopeId { get; init; }
public int ReceiverId { get; init; }
public int Sequence { get; init; }
public string? Name { get; init; }
public bool HasPhoneNumber { get; init; }
}

View File

@@ -1,6 +0,0 @@
namespace EnvelopeGenerator.ReceiverUI.Client.Models
{
public class EnvelopeViewModel
{
}
}

View File

@@ -1,31 +0,0 @@
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
/// <summary>
/// Client-seitiges DTO für die Antwort des ReceiverAuthControllers.
/// Wird 1:1 aus dem JSON deserialisiert.
///
/// WARUM ein eigenes Client-Model statt das API-Model zu referenzieren?
/// - Das API-Projekt hat Server-Abhängigkeiten (EF Core, SqlClient, etc.)
/// - Diese Pakete existieren nicht für browser-wasm → Build-Fehler
/// - Die Property-Namen müssen nur zum JSON passen (case-insensitive)
/// </summary>
public record ReceiverAuthModel
{
/// <summary>
/// Aktueller Status des Empfänger-Flows.
/// Werte: "requires_access_code", "requires_tfa", "show_document",
/// "already_signed", "rejected", "not_found", "expired", "error"
/// </summary>
public string Status { get; init; } = string.Empty;
public string? Title { get; init; }
public string? Message { get; init; }
public string? SenderEmail { get; init; }
public string? ReceiverName { get; init; }
public bool TfaEnabled { get; init; }
public bool HasPhoneNumber { get; init; }
public bool ReadOnly { get; init; }
public string? TfaType { get; init; }
public DateTime? TfaExpiration { get; init; }
public string? ErrorMessage { get; init; }
}

View File

@@ -1,13 +0,0 @@
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
/// <summary>
/// Client-seitiges DTO für Empfänger-Daten.
/// </summary>
public record ReceiverModel
{
public int Id { get; init; }
public string EmailAddress { get; init; } = string.Empty;
public string Signature { get; init; } = string.Empty;
public DateTime AddedWhen { get; init; }
public DateTime? TfaRegDeadline { get; init; }
}

View File

@@ -1,10 +0,0 @@
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
/// <summary>
/// Client-seitiges DTO für Benutzer-Daten (Absender).
/// </summary>
public record UserModel
{
public string? Email { get; init; }
public string? DisplayName { get; init; }
}

View File

@@ -1,159 +0,0 @@
@page "/envelope/{EnvelopeKey}"
@rendermode InteractiveAuto
@inject IReceiverAuthService ReceiverAuthService
@inject EnvelopeState State
@implements IDisposable
<PageTitle>Dokument</PageTitle>
@switch (State.Status)
{
case EnvelopePageStatus.Loading:
<LoadingIndicator Message="Dokument wird geladen..." />
break;
case EnvelopePageStatus.NotFound:
<StatusPage Type="not_found" />
break;
case EnvelopePageStatus.AlreadySigned:
<StatusPage Type="signed"
Title="@State.Title"
SenderEmail="@State.SenderEmail" />
break;
case EnvelopePageStatus.Rejected:
<StatusPage Type="rejected"
Title="@State.Title"
SenderEmail="@State.SenderEmail" />
break;
case EnvelopePageStatus.Expired:
<StatusPage Type="expired" />
break;
case EnvelopePageStatus.RequiresAccessCode:
<AccessCodeForm EnvelopeKey="@EnvelopeKey"
ErrorMessage="@State.ErrorMessage"
SenderEmail="@State.SenderEmail"
Title="@State.Title"
TfaEnabled="@State.TfaEnabled"
HasPhoneNumber="@State.HasPhoneNumber"
OnSubmit="HandleAccessCodeSubmit" />
break;
case EnvelopePageStatus.RequiresTwoFactor:
<TfaForm EnvelopeKey="@EnvelopeKey"
TfaType="@(State.TfaType ?? "authenticator")"
TfaExpiration="@State.TfaExpiration"
HasPhoneNumber="@State.HasPhoneNumber"
ErrorMessage="@State.ErrorMessage"
OnSubmit="HandleTfaSubmit" />
break;
case EnvelopePageStatus.ShowDocument:
@* Phase 4 (PSPDFKit) kommt später — vorerst Platzhalter *@
<div class="text-center mt-5">
<div class="status-icon signed">
<i class="bi bi-file-earmark-check"></i>
</div>
<h2>Dokument bereit</h2>
<p class="text-muted">
«@State.Title» — PDF-Viewer wird in Phase 4 integriert.
</p>
@if (State.ReadOnly)
{
<span class="badge bg-secondary">Nur Lesen</span>
}
</div>
break;
case EnvelopePageStatus.Error:
<ErrorDisplay Title="Fehler" Message="@State.ErrorMessage" />
break;
}
@code {
[Parameter] public string EnvelopeKey { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
State.OnChange += StateHasChanged;
await LoadStatusAsync();
}
/// <summary>
/// Erster API-Call: Status prüfen.
/// Entspricht dem GET /Envelope/{key} im Web-Projekt.
/// Die API entscheidet, was passiert (AccessCode nötig? Bereits signiert? etc.)
/// </summary>
private async Task LoadStatusAsync()
{
State.SetLoading();
var result = await ReceiverAuthService.GetStatusAsync(EnvelopeKey);
if (result.IsSuccess && result.Data is not null)
{
State.ApplyApiResponse(result.Data);
}
else if (result.StatusCode == 404)
{
State.SetNotFound();
}
else
{
State.SetError(result.ErrorMessage ?? "Verbindung zum Server fehlgeschlagen.");
}
}
/// <summary>
/// Zweiter API-Call: AccessCode senden.
/// Wird von AccessCodeForm aufgerufen (OnSubmit-Callback).
/// Die API prüft den Code und antwortet mit dem nächsten Status.
/// </summary>
private async Task HandleAccessCodeSubmit((string Code, bool PreferSms) submission)
{
var result = await ReceiverAuthService.SubmitAccessCodeAsync(
EnvelopeKey, submission.Code, submission.PreferSms);
if (result.IsSuccess && result.Data is not null)
{
State.ApplyApiResponse(result.Data);
}
else if (result.Data is not null)
{
// 401 mit Body → falscher Code, API gibt trotzdem ReceiverAuthModel zurück
State.ApplyApiResponse(result.Data);
}
else
{
State.SetError(result.ErrorMessage ?? "Fehler bei der Code-Prüfung.");
}
}
/// <summary>
/// Dritter API-Call: TFA-Code senden.
/// Wird von TfaForm aufgerufen (OnSubmit-Callback).
/// </summary>
private async Task HandleTfaSubmit((string Code, string Type) submission)
{
var result = await ReceiverAuthService.SubmitTfaCodeAsync(
EnvelopeKey, submission.Code, submission.Type);
if (result.IsSuccess && result.Data is not null)
{
State.ApplyApiResponse(result.Data);
}
else if (result.Data is not null)
{
State.ApplyApiResponse(result.Data);
}
else
{
State.SetError(result.ErrorMessage ?? "Fehler bei der TFA-Prüfung.");
}
}
public void Dispose() => State.OnChange -= StateHasChanged;
}

View File

@@ -1,5 +0,0 @@
<h3>Home</h3>
@code {
}

View File

@@ -1,5 +0,0 @@
<h3>NotFound</h3>
@code {
}

View File

@@ -1,30 +0,0 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using EnvelopeGenerator.ReceiverUI.Client.Auth;
using EnvelopeGenerator.ReceiverUI.Client.Services;
using EnvelopeGenerator.ReceiverUI.Client.State;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
// HttpClient: BaseAddress zeigt auf den ReceiverUI-Server (gleiche Domain)
// Von dort werden alle /api/* Calls via YARP an die echte API weitergeleitet
builder.Services.AddScoped(sp =>
new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
// Auth: Blazor fragt über diesen Provider "Ist der Nutzer eingeloggt?"
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<ApiAuthStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp =>
sp.GetRequiredService<ApiAuthStateProvider>());
// API-Services: Je ein Service pro API-Controller
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IEnvelopeService, EnvelopeService>();
builder.Services.AddScoped<IReceiverAuthService, ReceiverAuthService>();
// State: Ein State-Objekt pro Browser-Tab
builder.Services.AddScoped<EnvelopeState>();
builder.Services.AddScoped<ToastService>();
await builder.Build().RunAsync();

View File

@@ -1,54 +0,0 @@
using EnvelopeGenerator.ReceiverUI.Client.Services.Base;
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
/// <summary>
/// Spricht mit dem bestehenden AuthController der API.
/// Die API erkennt den Nutzer über das Cookie "AuthToken" automatisch.
/// </summary>
public class AuthService : ApiServiceBase, IAuthService
{
public AuthService(HttpClient http, ILogger<AuthService> logger) : base(http, logger) { }
public async Task<ApiResponse> CheckAuthAsync(string? role = null, CancellationToken ct = default)
{
var endpoint = role is not null ? $"api/auth/check?role={role}" : "api/auth/check";
try
{
var response = await Http.GetAsync(endpoint, ct);
return response.IsSuccessStatusCode
? ApiResponse.Success((int)response.StatusCode)
: ApiResponse.Failure((int)response.StatusCode);
}
catch (HttpRequestException ex)
{
Logger.LogError(ex, "HTTP error calling GET {Endpoint}", endpoint);
return ApiResponse.Failure(0, "Verbindung zum Server fehlgeschlagen.");
}
catch (TaskCanceledException)
{
return ApiResponse.Failure(0, "Anfrage abgebrochen.");
}
}
public async Task<ApiResponse> LogoutAsync(CancellationToken ct = default)
{
const string endpoint = "api/auth/logout";
try
{
var response = await Http.PostAsync(endpoint, null, ct);
return response.IsSuccessStatusCode
? ApiResponse.Success((int)response.StatusCode)
: ApiResponse.Failure((int)response.StatusCode);
}
catch (HttpRequestException ex)
{
Logger.LogError(ex, "HTTP error calling POST {Endpoint}", endpoint);
return ApiResponse.Failure(0, "Verbindung zum Server fehlgeschlagen.");
}
catch (TaskCanceledException)
{
return ApiResponse.Failure(0, "Anfrage abgebrochen.");
}
}
}

View File

@@ -1,46 +0,0 @@
namespace EnvelopeGenerator.ReceiverUI.Client.Services.Base;
/// <summary>
/// Einheitliches Response-Objekt für ALLE API-Aufrufe.
///
/// WARUM: Jeder API-Aufruf kann fehlschlagen (Netzwerk, 401, 500...).
/// Statt überall try-catch zu haben, kapselt dieses Objekt Erfolg/Fehler einheitlich.
/// So kann jede Blazor-Komponente einheitlich darauf reagieren.
/// </summary>
public record ApiResponse<T>
{
public bool IsSuccess { get; init; }
public T? Data { get; init; }
public int StatusCode { get; init; }
public string? ErrorMessage { get; init; }
public static ApiResponse<T> Success(T data, int statusCode = 200)
=> new() { IsSuccess = true, Data = data, StatusCode = statusCode };
public static ApiResponse<T> Failure(int statusCode, string? error = null)
=> new() { IsSuccess = false, StatusCode = statusCode, ErrorMessage = error };
/// <summary>
/// Failure mit deserialisiertem Body — für Fälle wo die API
/// bei 401/404 trotzdem ein strukturiertes JSON zurückgibt
/// (z.B. ReceiverAuthResponse mit ErrorMessage + Status).
/// </summary>
public static ApiResponse<T> Failure(int statusCode, string? error, T? data)
=> new() { IsSuccess = false, StatusCode = statusCode, ErrorMessage = error, Data = data };
}
/// <summary>
/// Response ohne Daten (für POST/PUT/DELETE die nur Status zurückgeben).
/// </summary>
public record ApiResponse
{
public bool IsSuccess { get; init; }
public int StatusCode { get; init; }
public string? ErrorMessage { get; init; }
public static ApiResponse Success(int statusCode = 200)
=> new() { IsSuccess = true, StatusCode = statusCode };
public static ApiResponse Failure(int statusCode, string? error = null)
=> new() { IsSuccess = false, StatusCode = statusCode, ErrorMessage = error };
}

View File

@@ -1,141 +0,0 @@
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
namespace EnvelopeGenerator.ReceiverUI.Client.Services.Base;
/// <summary>
/// Basisklasse für ALLE API-Services.
///
/// WARUM eine Basisklasse?
/// - Einheitliches Error-Handling: Jeder API-Aufruf wird gleich behandelt
/// - DRY (Don't Repeat Yourself): Logging, Fehlerbehandlung, Serialisierung nur einmal
/// - Einfache Erweiterung: Retry-Logik, Token-Refresh etc. nur hier ändern
/// </summary>
public abstract class ApiServiceBase
{
protected readonly HttpClient Http;
protected readonly ILogger Logger;
protected ApiServiceBase(HttpClient http, ILogger logger)
{
Http = http;
Logger = logger;
}
/// <summary>
/// GET-Request mit Deserialisierung.
/// Alle API GET-Aufrufe gehen durch diese Methode.
/// </summary>
protected async Task<ApiResponse<T>> GetAsync<T>(string endpoint, CancellationToken ct = default)
{
try
{
var response = await Http.GetAsync(endpoint, ct);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(ct);
Logger.LogWarning("GET {Endpoint} failed: {Status} - {Body}",
endpoint, (int)response.StatusCode, errorBody);
// Versuche den Body trotzdem zu deserialisieren —
// die API gibt bei 401/404 oft strukturierte JSON-Antworten zurück
// (z.B. ReceiverAuthResponse mit ErrorMessage + Status)
var errorData = await TryDeserializeAsync<T>(response, ct);
return ApiResponse<T>.Failure((int)response.StatusCode, errorBody, errorData);
}
var data = await response.Content.ReadFromJsonAsync<T>(cancellationToken: ct);
return ApiResponse<T>.Success(data!, (int)response.StatusCode);
}
catch (HttpRequestException ex)
{
Logger.LogError(ex, "HTTP error calling GET {Endpoint}", endpoint);
return ApiResponse<T>.Failure(0, "Verbindung zum Server fehlgeschlagen.");
}
catch (TaskCanceledException)
{
Logger.LogWarning("GET {Endpoint} was cancelled", endpoint);
return ApiResponse<T>.Failure(0, "Anfrage abgebrochen.");
}
}
/// <summary>
/// POST-Request mit Body und Response-Deserialisierung.
/// </summary>
protected async Task<ApiResponse<TResponse>> PostAsync<TRequest, TResponse>(
string endpoint, TRequest body, CancellationToken ct = default)
{
try
{
var response = await Http.PostAsJsonAsync(endpoint, body, ct);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(ct);
Logger.LogWarning("POST {Endpoint} failed: {Status} - {Body}",
endpoint, (int)response.StatusCode, errorBody);
var errorData = await TryDeserializeAsync<TResponse>(response, ct);
return ApiResponse<TResponse>.Failure((int)response.StatusCode, errorBody, errorData);
}
var data = await response.Content.ReadFromJsonAsync<TResponse>(cancellationToken: ct);
return ApiResponse<TResponse>.Success(data!, (int)response.StatusCode);
}
catch (HttpRequestException ex)
{
Logger.LogError(ex, "HTTP error calling POST {Endpoint}", endpoint);
return ApiResponse<TResponse>.Failure(0, "Verbindung zum Server fehlgeschlagen.");
}
}
/// <summary>
/// POST-Request ohne Response-Body (z.B. Logout).
/// </summary>
protected async Task<ApiResponse> PostAsync<TRequest>(
string endpoint, TRequest body, CancellationToken ct = default)
{
try
{
var response = await Http.PostAsJsonAsync(endpoint, body, ct);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(ct);
return ApiResponse.Failure((int)response.StatusCode, errorBody);
}
return ApiResponse.Success((int)response.StatusCode);
}
catch (HttpRequestException ex)
{
Logger.LogError(ex, "HTTP error calling POST {Endpoint}", endpoint);
return ApiResponse.Failure(0, "Verbindung zum Server fehlgeschlagen.");
}
}
/// <summary>
/// Versucht den Response-Body als JSON zu deserialisieren.
/// Gibt null zurück wenn es nicht klappt (z.B. bei HTML-Fehlerseiten).
/// </summary>
private static async Task<T?> TryDeserializeAsync<T>(HttpResponseMessage response, CancellationToken ct)
{
try
{
// Nur versuchen wenn der Content-Type JSON ist
if (response.Content.Headers.ContentType?.MediaType == "application/json")
{
return await response.Content.ReadFromJsonAsync<T>(cancellationToken: ct);
}
}
catch
{
// Ignorieren — der Body war kein valides JSON
}
return default;
}
}

View File

@@ -1,16 +0,0 @@
using EnvelopeGenerator.ReceiverUI.Client.Models;
using EnvelopeGenerator.ReceiverUI.Client.Services.Base;
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
public class EnvelopeService : ApiServiceBase, IEnvelopeService
{
public EnvelopeService(HttpClient http, ILogger<EnvelopeService> logger) : base(http, logger) { }
public Task<ApiResponse<IEnumerable<EnvelopeModel>>> GetEnvelopesAsync(CancellationToken ct = default)
=> GetAsync<IEnumerable<EnvelopeModel>>("api/envelope", ct);
public Task<ApiResponse<IEnumerable<EnvelopeReceiverModel>>> GetEnvelopeReceiversAsync(
CancellationToken ct = default)
=> GetAsync<IEnumerable<EnvelopeReceiverModel>>("api/envelopereceiver", ct);
}

View File

@@ -1,6 +0,0 @@
namespace EnvelopeGenerator.ReceiverUI.Client.Services
{
public class HistoryService
{
}
}

View File

@@ -1,20 +0,0 @@
using EnvelopeGenerator.ReceiverUI.Client.Services.Base;
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
/// <summary>
/// Kommuniziert mit dem AuthController der API.
///
/// WARUM Interface + Implementierung?
/// - Testbarkeit: In Unit-Tests kann man einen Mock verwenden
/// - Austauschbarkeit: Wenn sich die API ändert, ändert sich nur die Implementierung
/// - Blazor-Konvention: Services werden über Interfaces per DI registriert
/// </summary>
public interface IAuthService
{
/// <summary>Prüft ob der Nutzer eingeloggt ist → GET /api/auth/check</summary>
Task<ApiResponse> CheckAuthAsync(string? role = null, CancellationToken ct = default);
/// <summary>Logout → POST /api/auth/logout</summary>
Task<ApiResponse> LogoutAsync(CancellationToken ct = default);
}

View File

@@ -1,18 +0,0 @@
using EnvelopeGenerator.ReceiverUI.Client.Models;
using EnvelopeGenerator.ReceiverUI.Client.Services.Base;
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
/// <summary>
/// Kommuniziert mit EnvelopeController und EnvelopeReceiverController.
/// Verwendet Client-eigene Models statt der Server-DTOs.
/// </summary>
public interface IEnvelopeService
{
/// <summary>Lädt Umschläge → GET /api/envelope</summary>
Task<ApiResponse<IEnumerable<EnvelopeModel>>> GetEnvelopesAsync(CancellationToken ct = default);
/// <summary>Lädt EnvelopeReceiver → GET /api/envelopereceiver</summary>
Task<ApiResponse<IEnumerable<EnvelopeReceiverModel>>> GetEnvelopeReceiversAsync(
CancellationToken ct = default);
}

View File

@@ -1,6 +0,0 @@
namespace EnvelopeGenerator.ReceiverUI.Client.Services
{
public interface IHistoryService
{
}
}

View File

@@ -1,26 +0,0 @@
using EnvelopeGenerator.ReceiverUI.Client.Models;
using EnvelopeGenerator.ReceiverUI.Client.Services.Base;
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
/// <summary>
/// Kommuniziert mit dem ReceiverAuthController der API.
///
/// Drei Methoden — eine pro Endpunkt:
/// 1. GetStatusAsync → GET /api/receiverauth/{key}/status
/// 2. SubmitAccessCodeAsync → POST /api/receiverauth/{key}/access-code
/// 3. SubmitTfaCodeAsync → POST /api/receiverauth/{key}/tfa
/// </summary>
public interface IReceiverAuthService
{
/// <summary>Prüft den aktuellen Status des Empfänger-Flows</summary>
Task<ApiResponse<ReceiverAuthModel>> GetStatusAsync(string key, CancellationToken ct = default);
/// <summary>Sendet den Zugangscode zur Prüfung</summary>
Task<ApiResponse<ReceiverAuthModel>> SubmitAccessCodeAsync(
string key, string accessCode, bool preferSms, CancellationToken ct = default);
/// <summary>Sendet den TFA-Code (SMS oder Authenticator) zur Prüfung</summary>
Task<ApiResponse<ReceiverAuthModel>> SubmitTfaCodeAsync(
string key, string code, string type, CancellationToken ct = default);
}

View File

@@ -1,41 +0,0 @@
using EnvelopeGenerator.ReceiverUI.Client.Models;
using EnvelopeGenerator.ReceiverUI.Client.Services.Base;
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
/// <summary>
/// Spricht mit dem ReceiverAuthController der API.
///
/// Nutzt die Basisklasse ApiServiceBase für einheitliches Error-Handling.
/// Jede Methode gibt ApiResponse&lt;ReceiverAuthModel&gt; zurück —
/// egal ob Erfolg oder Fehler. Die aufrufende Komponente prüft dann
/// result.IsSuccess und result.Data.Status.
///
/// WARUM gibt die API bei 401 trotzdem ein ReceiverAuthModel zurück?
/// Weil auch bei "falscher Code" der Client wissen muss, welchen
/// Status er anzeigen soll (z.B. "requires_access_code" + ErrorMessage).
/// Deshalb deserialisieren wir auch bei Fehler-Statuscodes den Body.
/// </summary>
public class ReceiverAuthService : ApiServiceBase, IReceiverAuthService
{
public ReceiverAuthService(HttpClient http, ILogger<ReceiverAuthService> logger)
: base(http, logger) { }
public Task<ApiResponse<ReceiverAuthModel>> GetStatusAsync(
string key, CancellationToken ct = default)
=> GetAsync<ReceiverAuthModel>($"api/receiverauth/{key}/status", ct);
public Task<ApiResponse<ReceiverAuthModel>> SubmitAccessCodeAsync(
string key, string accessCode, bool preferSms, CancellationToken ct = default)
=> PostAsync<object, ReceiverAuthModel>(
$"api/receiverauth/{key}/access-code",
new { AccessCode = accessCode, PreferSms = preferSms },
ct);
public Task<ApiResponse<ReceiverAuthModel>> SubmitTfaCodeAsync(
string key, string code, string type, CancellationToken ct = default)
=> PostAsync<object, ReceiverAuthModel>(
$"api/receiverauth/{key}/tfa",
new { Code = code, Type = type },
ct);
}

View File

@@ -1,86 +0,0 @@
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,6 +0,0 @@
namespace EnvelopeGenerator.ReceiverUI.Client.State
{
public class AuthState
{
}
}

View File

@@ -1,130 +0,0 @@
using EnvelopeGenerator.ReceiverUI.Client.Models;
namespace EnvelopeGenerator.ReceiverUI.Client.State;
/// <summary>
/// Hält den aktuellen Zustand des geladenen Umschlags.
///
/// WARUM ein eigenes State-Objekt?
/// - Mehrere Komponenten auf einer Seite brauchen die gleichen Daten
/// - Ohne State müsste jede Komponente die Daten selbst laden → doppelte API-Calls
/// - StateHasChanged() informiert automatisch alle Subscriber
///
/// PATTERN: "Observable State" — Services setzen den State, Komponenten reagieren darauf.
///
/// Die Set-Methoden nehmen jetzt ein ReceiverAuthModel entgegen,
/// damit alle Felder (Title, SenderEmail, TfaType etc.) zentral gespeichert werden.
/// </summary>
public class EnvelopeState
{
private EnvelopePageStatus _status = EnvelopePageStatus.Loading;
/// <summary>Aktueller Seitenstatus</summary>
public EnvelopePageStatus Status
{
get => _status;
private set
{
_status = value;
NotifyStateChanged();
}
}
// ── Felder aus ReceiverAuthModel ──
/// <summary>Titel des Umschlags (z.B. "Vertragsdokument")</summary>
public string? Title { get; private set; }
/// <summary>Nachricht des Absenders</summary>
public string? Message { get; private set; }
/// <summary>E-Mail des Absenders (für Rückfragen-Hinweis)</summary>
public string? SenderEmail { get; private set; }
/// <summary>Ob TFA für diesen Umschlag aktiviert ist</summary>
public bool TfaEnabled { get; private set; }
/// <summary>Ob der Empfänger eine Telefonnummer hat (für SMS-TFA)</summary>
public bool HasPhoneNumber { get; private set; }
/// <summary>Ob das Dokument nur gelesen werden soll (ReadAndConfirm)</summary>
public bool ReadOnly { get; private set; }
/// <summary>TFA-Typ: "sms" oder "authenticator"</summary>
public string? TfaType { get; private set; }
/// <summary>Ablaufzeit des SMS-Codes (für Countdown-Timer)</summary>
public DateTime? TfaExpiration { get; private set; }
/// <summary>Fehlermeldung (z.B. "Falscher Zugangscode")</summary>
public string? ErrorMessage { get; private set; }
// ── Zustandsübergänge ──
public void SetLoading()
{
ErrorMessage = null;
Status = EnvelopePageStatus.Loading;
}
/// <summary>
/// Setzt den State aus einer API-Antwort.
/// Zentrale Methode — alle Endpunkte liefern ReceiverAuthModel,
/// und diese Methode mappt den Status-String auf das richtige Enum.
/// </summary>
public void ApplyApiResponse(ReceiverAuthModel model)
{
// Gemeinsame Felder immer übernehmen
Title = model.Title ?? Title;
Message = model.Message ?? Message;
SenderEmail = model.SenderEmail ?? SenderEmail;
TfaEnabled = model.TfaEnabled;
HasPhoneNumber = model.HasPhoneNumber;
ReadOnly = model.ReadOnly;
TfaType = model.TfaType ?? TfaType;
TfaExpiration = model.TfaExpiration ?? TfaExpiration;
ErrorMessage = model.ErrorMessage;
// Status-String → Enum
Status = model.Status switch
{
"requires_access_code" => EnvelopePageStatus.RequiresAccessCode,
"requires_tfa" => EnvelopePageStatus.RequiresTwoFactor,
"show_document" => EnvelopePageStatus.ShowDocument,
"already_signed" => EnvelopePageStatus.AlreadySigned,
"rejected" => EnvelopePageStatus.Rejected,
"not_found" => EnvelopePageStatus.NotFound,
"expired" => EnvelopePageStatus.Expired,
"error" => EnvelopePageStatus.Error,
_ => EnvelopePageStatus.Error
};
}
/// <summary>Setzt Fehler wenn der API-Call selbst fehlschlägt (Netzwerk etc.)</summary>
public void SetError(string message)
{
ErrorMessage = message;
Status = EnvelopePageStatus.Error;
}
/// <summary>Setzt NotFound (z.B. bei 404 ohne Body)</summary>
public void SetNotFound() => Status = EnvelopePageStatus.NotFound;
// ── Event ──
public event Action? OnChange;
private void NotifyStateChanged() => OnChange?.Invoke();
}
/// <summary>Alle möglichen Zustände der Umschlag-Seite</summary>
public enum EnvelopePageStatus
{
Loading,
RequiresAccessCode,
RequiresTwoFactor,
ShowDocument,
AlreadySigned,
Rejected,
NotFound,
Expired,
Error
}

View File

@@ -1,17 +0,0 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using EnvelopeGenerator.ReceiverUI.Client
@using EnvelopeGenerator.ReceiverUI.Client.Models
@using EnvelopeGenerator.ReceiverUI.Client.Services
@using EnvelopeGenerator.ReceiverUI.Client.Services.Base
@using EnvelopeGenerator.ReceiverUI.Client.State
@using EnvelopeGenerator.ReceiverUI.Client.Auth
@using EnvelopeGenerator.ReceiverUI.Client.Components.Shared
@using EnvelopeGenerator.ReceiverUI.Client.Components.Envelope

View File

@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="bootstrap-icons/bootstrap-icons.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="EnvelopeGenerator.ReceiverUI.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
</head>
<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
</body>
</html>

View File

@@ -1,87 +0,0 @@
@* 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">
<Toast />
@* ── Header ── *@
<header class="app-header">
<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>
</div>
</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">
<ErrorBoundary @ref="_errorBoundary">
<ChildContent>
@Body
</ChildContent>
<ErrorContent Context="ex">
<div class="error-container text-center py-5">
<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>
<button class="btn btn-primary" @onclick="Recover">
<i class="bi bi-arrow-counterclockwise me-2"></i>
Erneut versuchen
</button>
</div>
</ErrorContent>
</ErrorBoundary>
</main>
@* ── Footer ──
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>
</div>
@code {
private ErrorBoundary? _errorBoundary;
/// <summary>
/// Setzt die ErrorBoundary zurück.
/// Blazor rendert dann @Body erneut statt der Fehlermeldung.
/// </summary>
private void Recover() => _errorBoundary?.Recover();
}

View File

@@ -1,96 +0,0 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View File

@@ -1,36 +0,0 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

@@ -1,7 +0,0 @@
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.

View File

@@ -1,14 +0,0 @@
<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
<NotFound>
<LayoutView Layout="typeof(Layout.MainLayout)">
<div class="text-center py-5">
<h1>404</h1>
<p>Diese Seite wurde nicht gefunden.</p>
</div>
</LayoutView>
</NotFound>
</Router>

View File

@@ -1,9 +0,0 @@
@using System.Net.Http
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using EnvelopeGenerator.ReceiverUI.Components
@using EnvelopeGenerator.ReceiverUI.Components.Layout

View File

@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.ReceiverUI.Client\EnvelopeGenerator.ReceiverUI.Client.csproj" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.3" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.1.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\bootstrap\" />
<Folder Include="wwwroot\bootstrap-icons\" />
</ItemGroup>
</Project>

View File

@@ -1,72 +0,0 @@
using EnvelopeGenerator.ReceiverUI.Client.Auth;
using EnvelopeGenerator.ReceiverUI.Client.Services;
using EnvelopeGenerator.ReceiverUI.Client.State;
using EnvelopeGenerator.ReceiverUI.Components;
using Microsoft.AspNetCore.Components.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
// API-Proxy: Alle /api/* Aufrufe an die echte API weiterleiten
// WARUM: Der Blazor-Client ruft /api/envelope auf. Diese Anfrage geht an den
// ReceiverUI-Server (gleiche Domain, kein CORS), der sie an die echte API weiterleitet.
var apiBaseUrl = builder.Configuration["ApiBaseUrl"]
?? throw new InvalidOperationException("ApiBaseUrl is not configured in appsettings.json.");
builder.Services.AddHttpForwarder();
// ── Services: Müssen AUCH auf dem Server registriert sein ──
// WARUM? Bei @rendermode InteractiveAuto rendert Blazor die Seite zuerst
// auf dem Server (SSR/Prerendering). Dabei resolved es @inject-Properties.
// Wenn ein Service nur im Client-Projekt (WASM) registriert ist, aber nicht
// hier, gibt es eine InvalidOperationException beim Prerendering.
//
// Der HttpClient auf dem Server zeigt auf sich selbst (localhost),
// weil die /api/* Requests über MapForwarder an die echte API gehen.
builder.Services.AddScoped(sp =>
{
var navigationManager = sp.GetService<Microsoft.AspNetCore.Components.NavigationManager>();
var baseUri = navigationManager?.BaseUri ?? $"https://localhost:{builder.Configuration["ASPNETCORE_HTTPS_PORT"] ?? "7206"}/";
return new HttpClient { BaseAddress = new Uri(baseUri) };
});
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<ApiAuthStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp =>
sp.GetRequiredService<ApiAuthStateProvider>());
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IEnvelopeService, EnvelopeService>();
builder.Services.AddScoped<IReceiverAuthService, ReceiverAuthService>();
builder.Services.AddScoped<EnvelopeState>();
builder.Services.AddScoped<ToastService>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
// Alle /api/* Requests an die echte EnvelopeGenerator.API weiterleiten
// So muss der Browser nie direkt mit der API sprechen → kein CORS, Cookies funktionieren
app.MapForwarder("/api/{**catch-all}", apiBaseUrl);
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(EnvelopeGenerator.ReceiverUI.Client._Imports).Assembly);
app.Run();

View File

@@ -1,41 +0,0 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:3101",
"sslPort": 44303
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5109",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7206;http://localhost:5109",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -1,9 +0,0 @@
{
"ApiBaseUrl": "https://localhost:8088",
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

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

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -39,10 +39,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.WorkerSer
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.API", "EnvelopeGenerator.API\EnvelopeGenerator.API.csproj", "{EC768913-6270-14F4-1DD3-69C87A659462}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.API", "EnvelopeGenerator.API\EnvelopeGenerator.API.csproj", "{EC768913-6270-14F4-1DD3-69C87A659462}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.ReceiverUI", "EnvelopeGenerator.ReceiverUI\EnvelopeGenerator.ReceiverUI\EnvelopeGenerator.ReceiverUI.csproj", "{620A0476-2F37-490D-ABE9-50DEEB217DA7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.ReceiverUI.Client", "EnvelopeGenerator.ReceiverUI\EnvelopeGenerator.ReceiverUI.Client\EnvelopeGenerator.ReceiverUI.Client.csproj", "{62DD9063-C026-4805-BD6D-4DB899ABC504}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -101,14 +97,6 @@ Global
{EC768913-6270-14F4-1DD3-69C87A659462}.Debug|Any CPU.Build.0 = Debug|Any CPU {EC768913-6270-14F4-1DD3-69C87A659462}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.ActiveCfg = Release|Any CPU {EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.Build.0 = Release|Any CPU {EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.Build.0 = Release|Any CPU
{620A0476-2F37-490D-ABE9-50DEEB217DA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{620A0476-2F37-490D-ABE9-50DEEB217DA7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{620A0476-2F37-490D-ABE9-50DEEB217DA7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{620A0476-2F37-490D-ABE9-50DEEB217DA7}.Release|Any CPU.Build.0 = Release|Any CPU
{62DD9063-C026-4805-BD6D-4DB899ABC504}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{62DD9063-C026-4805-BD6D-4DB899ABC504}.Debug|Any CPU.Build.0 = Debug|Any CPU
{62DD9063-C026-4805-BD6D-4DB899ABC504}.Release|Any CPU.ActiveCfg = Release|Any CPU
{62DD9063-C026-4805-BD6D-4DB899ABC504}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -130,8 +118,6 @@ Global
{3D0514EA-2681-4B13-AD71-35CC6363DBD7} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB} {3D0514EA-2681-4B13-AD71-35CC6363DBD7} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{E3676510-7030-4E85-86E1-51E483E2A3B6} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB} {E3676510-7030-4E85-86E1-51E483E2A3B6} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{EC768913-6270-14F4-1DD3-69C87A659462} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB} {EC768913-6270-14F4-1DD3-69C87A659462} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{620A0476-2F37-490D-ABE9-50DEEB217DA7} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{62DD9063-C026-4805-BD6D-4DB899ABC504} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7} SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}