Refactor envelope receiver auth flow and state handling

- Introduce IReceiverAuthService and ReceiverAuthService for all envelope receiver authentication and status API calls
- Add ReceiverAuthModel as a client-side DTO for API responses
- Refactor EnvelopeState to store all relevant fields and update via ApplyApiResponse
- Overhaul EnvelopePage.razor to use new service and state, with improved status handling and UI
- Enhance ApiResponse and ApiServiceBase to support structured error deserialization
- Register IReceiverAuthService in DI container
This commit is contained in:
OlgunR
2026-03-23 15:57:20 +01:00
parent 4aa889f178
commit 93488ba83e
8 changed files with 320 additions and 59 deletions

View File

@@ -0,0 +1,31 @@
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,6 +1,6 @@
@page "/envelope/{EnvelopeKey}" @page "/envelope/{EnvelopeKey}"
@rendermode InteractiveAuto @rendermode InteractiveAuto
@inject IEnvelopeService EnvelopeService @inject IReceiverAuthService ReceiverAuthService
@inject EnvelopeState State @inject EnvelopeState State
@implements IDisposable @implements IDisposable
@@ -13,24 +13,59 @@
break; break;
case EnvelopePageStatus.NotFound: case EnvelopePageStatus.NotFound:
<ErrorDisplay Title="Nicht gefunden" <StatusPage Type="not_found" />
Message="Dieses Dokument existiert nicht oder ist nicht mehr verfügbar." />
break; break;
case EnvelopePageStatus.AlreadySigned: case EnvelopePageStatus.AlreadySigned:
<ErrorDisplay Title="Bereits unterschrieben" <StatusPage Type="signed"
Message="Dieses Dokument wurde bereits unterschrieben." Title="@State.Title"
Icon="check-circle" /> SenderEmail="@State.SenderEmail" />
break;
case EnvelopePageStatus.Rejected:
<StatusPage Type="rejected"
Title="@State.Title"
SenderEmail="@State.SenderEmail" />
break;
case EnvelopePageStatus.Expired:
<StatusPage Type="expired" />
break; break;
case EnvelopePageStatus.RequiresAccessCode: case EnvelopePageStatus.RequiresAccessCode:
<AccessCodeForm EnvelopeKey="@EnvelopeKey" <AccessCodeForm EnvelopeKey="@EnvelopeKey"
ErrorMessage="@State.ErrorMessage" ErrorMessage="@State.ErrorMessage"
SenderEmail="@State.SenderEmail"
Title="@State.Title"
TfaEnabled="@State.TfaEnabled"
HasPhoneNumber="@State.HasPhoneNumber"
OnSubmit="HandleAccessCodeSubmit" /> OnSubmit="HandleAccessCodeSubmit" />
break; 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: case EnvelopePageStatus.ShowDocument:
<PdfViewer DocumentBytes="@_documentBytes" /> @* 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; break;
case EnvelopePageStatus.Error: case EnvelopePageStatus.Error:
@@ -41,44 +76,83 @@
@code { @code {
[Parameter] public string EnvelopeKey { get; set; } = default!; [Parameter] public string EnvelopeKey { get; set; } = default!;
private byte[]? _documentBytes;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
State.OnChange += StateHasChanged; State.OnChange += StateHasChanged;
await LoadEnvelopeAsync(); await LoadStatusAsync();
}
private async Task LoadEnvelopeAsync()
{
State.SetLoading();
var result = await EnvelopeService.GetEnvelopeReceiversAsync();
if (!result.IsSuccess)
{
if (result.StatusCode == 401)
State.SetAccessCodeRequired();
else if (result.StatusCode == 404)
State.SetNotFound();
else
State.SetError(result.ErrorMessage ?? "Unbekannter Fehler");
return;
}
State.SetDocument();
} }
/// <summary> /// <summary>
/// Empfängt das Tuple (Code, PreferSms) von AccessCodeForm.OnSubmit. /// Erster API-Call: Status prüfen.
/// AccessCodeForm gibt immer ein Tuple zurück, weil es auch den /// Entspricht dem GET /Envelope/{key} im Web-Projekt.
/// SMS-Toggle-Zustand enthält (für TFA). /// 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> /// </summary>
private async Task HandleAccessCodeSubmit((string Code, bool PreferSms) submission) private async Task HandleAccessCodeSubmit((string Code, bool PreferSms) submission)
{ {
// AccessCode an API senden var result = await ReceiverAuthService.SubmitAccessCodeAsync(
// Bei Erfolg: State.SetDocument() oder State.SetTwoFactorRequired() EnvelopeKey, submission.Code, submission.PreferSms);
// Bei Fehler: State.SetError(...)
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; public void Dispose() => State.OnChange -= StateHasChanged;

View File

@@ -20,6 +20,7 @@ builder.Services.AddScoped<AuthenticationStateProvider>(sp =>
// API-Services: Je ein Service pro API-Controller // API-Services: Je ein Service pro API-Controller
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IEnvelopeService, EnvelopeService>(); builder.Services.AddScoped<IEnvelopeService, EnvelopeService>();
builder.Services.AddScoped<IReceiverAuthService, ReceiverAuthService>();
// State: Ein State-Objekt pro Browser-Tab // State: Ein State-Objekt pro Browser-Tab
builder.Services.AddScoped<EnvelopeState>(); builder.Services.AddScoped<EnvelopeState>();

View File

@@ -19,6 +19,14 @@ public record ApiResponse<T>
public static ApiResponse<T> Failure(int statusCode, string? error = null) public static ApiResponse<T> Failure(int statusCode, string? error = null)
=> new() { IsSuccess = false, StatusCode = statusCode, ErrorMessage = error }; => 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> /// <summary>

View File

@@ -37,7 +37,13 @@ public abstract class ApiServiceBase
var errorBody = await response.Content.ReadAsStringAsync(ct); var errorBody = await response.Content.ReadAsStringAsync(ct);
Logger.LogWarning("GET {Endpoint} failed: {Status} - {Body}", Logger.LogWarning("GET {Endpoint} failed: {Status} - {Body}",
endpoint, (int)response.StatusCode, errorBody); endpoint, (int)response.StatusCode, errorBody);
return ApiResponse<T>.Failure((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); var data = await response.Content.ReadFromJsonAsync<T>(cancellationToken: ct);
@@ -70,7 +76,10 @@ public abstract class ApiServiceBase
var errorBody = await response.Content.ReadAsStringAsync(ct); var errorBody = await response.Content.ReadAsStringAsync(ct);
Logger.LogWarning("POST {Endpoint} failed: {Status} - {Body}", Logger.LogWarning("POST {Endpoint} failed: {Status} - {Body}",
endpoint, (int)response.StatusCode, errorBody); endpoint, (int)response.StatusCode, errorBody);
return ApiResponse<TResponse>.Failure((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); var data = await response.Content.ReadFromJsonAsync<TResponse>(cancellationToken: ct);
@@ -107,4 +116,26 @@ public abstract class ApiServiceBase
return ApiResponse.Failure(0, "Verbindung zum Server fehlgeschlagen."); 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

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

@@ -0,0 +1,41 @@
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,4 +1,6 @@
namespace EnvelopeGenerator.ReceiverUI.Client.State; using EnvelopeGenerator.ReceiverUI.Client.Models;
namespace EnvelopeGenerator.ReceiverUI.Client.State;
/// <summary> /// <summary>
/// Hält den aktuellen Zustand des geladenen Umschlags. /// Hält den aktuellen Zustand des geladenen Umschlags.
@@ -9,11 +11,13 @@
/// - StateHasChanged() informiert automatisch alle Subscriber /// - StateHasChanged() informiert automatisch alle Subscriber
/// ///
/// PATTERN: "Observable State" — Services setzen den State, Komponenten reagieren darauf. /// 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> /// </summary>
public class EnvelopeState public class EnvelopeState
{ {
private EnvelopePageStatus _status = EnvelopePageStatus.Loading; private EnvelopePageStatus _status = EnvelopePageStatus.Loading;
private string? _errorMessage;
/// <summary>Aktueller Seitenstatus</summary> /// <summary>Aktueller Seitenstatus</summary>
public EnvelopePageStatus Status public EnvelopePageStatus Status
@@ -26,42 +30,87 @@ public class EnvelopeState
} }
} }
/// <summary>Fehlermeldung (falls vorhanden)</summary> // ── Felder aus ReceiverAuthModel ──
public string? ErrorMessage
{
get => _errorMessage;
private set
{
_errorMessage = value;
NotifyStateChanged();
}
}
// --- Zustandsübergänge (öffentliche Methoden) --- /// <summary>Titel des Umschlags (z.B. "Vertragsdokument")</summary>
public string? Title { get; private set; }
public void SetLoading() => Status = EnvelopePageStatus.Loading; /// <summary>Nachricht des Absenders</summary>
public string? Message { get; private set; }
public void SetAccessCodeRequired() /// <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; ErrorMessage = null;
Status = EnvelopePageStatus.RequiresAccessCode; Status = EnvelopePageStatus.Loading;
} }
public void SetTwoFactorRequired() => Status = EnvelopePageStatus.RequiresTwoFactor; /// <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;
public void SetDocument() => Status = EnvelopePageStatus.ShowDocument; // 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) public void SetError(string message)
{ {
ErrorMessage = message; ErrorMessage = message;
Status = EnvelopePageStatus.Error; Status = EnvelopePageStatus.Error;
} }
public void SetAlreadySigned() => Status = EnvelopePageStatus.AlreadySigned; /// <summary>Setzt NotFound (z.B. bei 404 ohne Body)</summary>
public void SetRejected() => Status = EnvelopePageStatus.Rejected;
public void SetNotFound() => Status = EnvelopePageStatus.NotFound; public void SetNotFound() => Status = EnvelopePageStatus.NotFound;
// --- Event: Benachrichtigt Komponenten über Änderungen --- // ── Event ──
public event Action? OnChange; public event Action? OnChange;
private void NotifyStateChanged() => OnChange?.Invoke(); private void NotifyStateChanged() => OnChange?.Invoke();
} }