From 93488ba83ee0b00cfc27d3d3655540320f2d6abe Mon Sep 17 00:00:00 2001 From: OlgunR Date: Mon, 23 Mar 2026 15:57:20 +0100 Subject: [PATCH] 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 --- .../Models/ReceiverAuthModel.cs | 31 +++++ .../Pages/Envelope/EnvelopePage.razor | 130 ++++++++++++++---- .../Program.cs | 1 + .../Services/Base/ApiResponse.cs | 8 ++ .../Services/Base/ApiServiceBase.cs | 35 ++++- .../Services/IReceiverAuthSerive.cs | 26 ++++ .../Services/ReceiverAuthService.cs | 41 ++++++ .../State/EnvelopeState.cs | 91 +++++++++--- 8 files changed, 312 insertions(+), 51 deletions(-) create mode 100644 EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Models/ReceiverAuthModel.cs create mode 100644 EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/IReceiverAuthSerive.cs create mode 100644 EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/ReceiverAuthService.cs diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Models/ReceiverAuthModel.cs b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Models/ReceiverAuthModel.cs new file mode 100644 index 00000000..cc397e19 --- /dev/null +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Models/ReceiverAuthModel.cs @@ -0,0 +1,31 @@ +namespace EnvelopeGenerator.ReceiverUI.Client.Models; + +/// +/// 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) +/// +public record ReceiverAuthModel +{ + /// + /// Aktueller Status des Empfänger-Flows. + /// Werte: "requires_access_code", "requires_tfa", "show_document", + /// "already_signed", "rejected", "not_found", "expired", "error" + /// + 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; } +} \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Pages/Envelope/EnvelopePage.razor b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Pages/Envelope/EnvelopePage.razor index 3923a713..5b09f32b 100644 --- a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Pages/Envelope/EnvelopePage.razor +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Pages/Envelope/EnvelopePage.razor @@ -1,6 +1,6 @@ @page "/envelope/{EnvelopeKey}" @rendermode InteractiveAuto -@inject IEnvelopeService EnvelopeService +@inject IReceiverAuthService ReceiverAuthService @inject EnvelopeState State @implements IDisposable @@ -13,24 +13,59 @@ break; case EnvelopePageStatus.NotFound: - + break; case EnvelopePageStatus.AlreadySigned: - + + break; + + case EnvelopePageStatus.Rejected: + + break; + + case EnvelopePageStatus.Expired: + break; case EnvelopePageStatus.RequiresAccessCode: break; + case EnvelopePageStatus.RequiresTwoFactor: + + break; + case EnvelopePageStatus.ShowDocument: - + @* Phase 4 (PSPDFKit) kommt später — vorerst Platzhalter *@ +
+
+ +
+

Dokument bereit

+

+ «@State.Title» — PDF-Viewer wird in Phase 4 integriert. +

+ @if (State.ReadOnly) + { + Nur Lesen + } +
break; case EnvelopePageStatus.Error: @@ -41,44 +76,83 @@ @code { [Parameter] public string EnvelopeKey { get; set; } = default!; - private byte[]? _documentBytes; - protected override async Task OnInitializedAsync() { State.OnChange += StateHasChanged; - await LoadEnvelopeAsync(); + await LoadStatusAsync(); } - private async Task LoadEnvelopeAsync() + /// + /// 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.) + /// + private async Task LoadStatusAsync() { State.SetLoading(); - var result = await EnvelopeService.GetEnvelopeReceiversAsync(); + var result = await ReceiverAuthService.GetStatusAsync(EnvelopeKey); - if (!result.IsSuccess) + if (result.IsSuccess && result.Data is not null) { - if (result.StatusCode == 401) - State.SetAccessCodeRequired(); - else if (result.StatusCode == 404) - State.SetNotFound(); - else - State.SetError(result.ErrorMessage ?? "Unbekannter Fehler"); - return; + State.ApplyApiResponse(result.Data); + } + else if (result.StatusCode == 404) + { + State.SetNotFound(); + } + else + { + State.SetError(result.ErrorMessage ?? "Verbindung zum Server fehlgeschlagen."); } - - State.SetDocument(); } /// - /// Empfängt das Tuple (Code, PreferSms) von AccessCodeForm.OnSubmit. - /// AccessCodeForm gibt immer ein Tuple zurück, weil es auch den - /// SMS-Toggle-Zustand enthält (für TFA). + /// Zweiter API-Call: AccessCode senden. + /// Wird von AccessCodeForm aufgerufen (OnSubmit-Callback). + /// Die API prüft den Code und antwortet mit dem nächsten Status. /// private async Task HandleAccessCodeSubmit((string Code, bool PreferSms) submission) { - // AccessCode an API senden - // Bei Erfolg: State.SetDocument() oder State.SetTwoFactorRequired() - // Bei Fehler: State.SetError(...) + 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."); + } + } + + /// + /// Dritter API-Call: TFA-Code senden. + /// Wird von TfaForm aufgerufen (OnSubmit-Callback). + /// + 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; diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Program.cs b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Program.cs index ee4d8011..d308bb1f 100644 --- a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Program.cs +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Program.cs @@ -20,6 +20,7 @@ builder.Services.AddScoped(sp => // API-Services: Je ein Service pro API-Controller builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // State: Ein State-Objekt pro Browser-Tab builder.Services.AddScoped(); diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/Base/ApiResponse.cs b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/Base/ApiResponse.cs index 747422fa..d904161f 100644 --- a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/Base/ApiResponse.cs +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/Base/ApiResponse.cs @@ -19,6 +19,14 @@ public record ApiResponse public static ApiResponse Failure(int statusCode, string? error = null) => new() { IsSuccess = false, StatusCode = statusCode, ErrorMessage = error }; + + /// + /// 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). + /// + public static ApiResponse Failure(int statusCode, string? error, T? data) + => new() { IsSuccess = false, StatusCode = statusCode, ErrorMessage = error, Data = data }; } /// diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/Base/ApiServiceBase.cs b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/Base/ApiServiceBase.cs index 2ec26168..bee4b9ab 100644 --- a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/Base/ApiServiceBase.cs +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/Base/ApiServiceBase.cs @@ -37,7 +37,13 @@ public abstract class ApiServiceBase var errorBody = await response.Content.ReadAsStringAsync(ct); Logger.LogWarning("GET {Endpoint} failed: {Status} - {Body}", endpoint, (int)response.StatusCode, errorBody); - return ApiResponse.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(response, ct); + + return ApiResponse.Failure((int)response.StatusCode, errorBody, errorData); } var data = await response.Content.ReadFromJsonAsync(cancellationToken: ct); @@ -70,7 +76,10 @@ public abstract class ApiServiceBase var errorBody = await response.Content.ReadAsStringAsync(ct); Logger.LogWarning("POST {Endpoint} failed: {Status} - {Body}", endpoint, (int)response.StatusCode, errorBody); - return ApiResponse.Failure((int)response.StatusCode, errorBody); + + var errorData = await TryDeserializeAsync(response, ct); + + return ApiResponse.Failure((int)response.StatusCode, errorBody, errorData); } var data = await response.Content.ReadFromJsonAsync(cancellationToken: ct); @@ -107,4 +116,26 @@ public abstract class ApiServiceBase return ApiResponse.Failure(0, "Verbindung zum Server fehlgeschlagen."); } } + + /// + /// Versucht den Response-Body als JSON zu deserialisieren. + /// Gibt null zurück wenn es nicht klappt (z.B. bei HTML-Fehlerseiten). + /// + private static async Task TryDeserializeAsync(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(cancellationToken: ct); + } + } + catch + { + // Ignorieren — der Body war kein valides JSON + } + + return default; + } } \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/IReceiverAuthSerive.cs b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/IReceiverAuthSerive.cs new file mode 100644 index 00000000..4577ed19 --- /dev/null +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/IReceiverAuthSerive.cs @@ -0,0 +1,26 @@ +using EnvelopeGenerator.ReceiverUI.Client.Models; +using EnvelopeGenerator.ReceiverUI.Client.Services.Base; + +namespace EnvelopeGenerator.ReceiverUI.Client.Services; + +/// +/// 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 +/// +public interface IReceiverAuthService +{ + /// Prüft den aktuellen Status des Empfänger-Flows + Task> GetStatusAsync(string key, CancellationToken ct = default); + + /// Sendet den Zugangscode zur Prüfung + Task> SubmitAccessCodeAsync( + string key, string accessCode, bool preferSms, CancellationToken ct = default); + + /// Sendet den TFA-Code (SMS oder Authenticator) zur Prüfung + Task> SubmitTfaCodeAsync( + string key, string code, string type, CancellationToken ct = default); +} \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/ReceiverAuthService.cs b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/ReceiverAuthService.cs new file mode 100644 index 00000000..ca2b929a --- /dev/null +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/Services/ReceiverAuthService.cs @@ -0,0 +1,41 @@ +using EnvelopeGenerator.ReceiverUI.Client.Models; +using EnvelopeGenerator.ReceiverUI.Client.Services.Base; + +namespace EnvelopeGenerator.ReceiverUI.Client.Services; + +/// +/// Spricht mit dem ReceiverAuthController der API. +/// +/// Nutzt die Basisklasse ApiServiceBase für einheitliches Error-Handling. +/// Jede Methode gibt ApiResponse<ReceiverAuthModel> 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. +/// +public class ReceiverAuthService : ApiServiceBase, IReceiverAuthService +{ + public ReceiverAuthService(HttpClient http, ILogger logger) + : base(http, logger) { } + + public Task> GetStatusAsync( + string key, CancellationToken ct = default) + => GetAsync($"api/receiverauth/{key}/status", ct); + + public Task> SubmitAccessCodeAsync( + string key, string accessCode, bool preferSms, CancellationToken ct = default) + => PostAsync( + $"api/receiverauth/{key}/access-code", + new { AccessCode = accessCode, PreferSms = preferSms }, + ct); + + public Task> SubmitTfaCodeAsync( + string key, string code, string type, CancellationToken ct = default) + => PostAsync( + $"api/receiverauth/{key}/tfa", + new { Code = code, Type = type }, + ct); +} \ No newline at end of file diff --git a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/State/EnvelopeState.cs b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/State/EnvelopeState.cs index e11a9387..2cddd303 100644 --- a/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/State/EnvelopeState.cs +++ b/EnvelopeGenerator.ReceiverUI/EnvelopeGenerator.ReceiverUI.Client/State/EnvelopeState.cs @@ -1,4 +1,6 @@ -namespace EnvelopeGenerator.ReceiverUI.Client.State; +using EnvelopeGenerator.ReceiverUI.Client.Models; + +namespace EnvelopeGenerator.ReceiverUI.Client.State; /// /// Hält den aktuellen Zustand des geladenen Umschlags. @@ -9,11 +11,13 @@ /// - 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. /// public class EnvelopeState { private EnvelopePageStatus _status = EnvelopePageStatus.Loading; - private string? _errorMessage; /// Aktueller Seitenstatus public EnvelopePageStatus Status @@ -26,42 +30,87 @@ public class EnvelopeState } } - /// Fehlermeldung (falls vorhanden) - public string? ErrorMessage - { - get => _errorMessage; - private set - { - _errorMessage = value; - NotifyStateChanged(); - } - } + // ── Felder aus ReceiverAuthModel ── + + /// Titel des Umschlags (z.B. "Vertragsdokument") + public string? Title { get; private set; } + + /// Nachricht des Absenders + public string? Message { get; private set; } - // --- Zustandsübergänge (öffentliche Methoden) --- + /// E-Mail des Absenders (für Rückfragen-Hinweis) + public string? SenderEmail { get; private set; } - public void SetLoading() => Status = EnvelopePageStatus.Loading; + /// Ob TFA für diesen Umschlag aktiviert ist + public bool TfaEnabled { get; private set; } - public void SetAccessCodeRequired() + /// Ob der Empfänger eine Telefonnummer hat (für SMS-TFA) + public bool HasPhoneNumber { get; private set; } + + /// Ob das Dokument nur gelesen werden soll (ReadAndConfirm) + public bool ReadOnly { get; private set; } + + /// TFA-Typ: "sms" oder "authenticator" + public string? TfaType { get; private set; } + + /// Ablaufzeit des SMS-Codes (für Countdown-Timer) + public DateTime? TfaExpiration { get; private set; } + + /// Fehlermeldung (z.B. "Falscher Zugangscode") + public string? ErrorMessage { get; private set; } + + // ── Zustandsübergänge ── + + public void SetLoading() { ErrorMessage = null; - Status = EnvelopePageStatus.RequiresAccessCode; + Status = EnvelopePageStatus.Loading; } - public void SetTwoFactorRequired() => Status = EnvelopePageStatus.RequiresTwoFactor; + /// + /// Setzt den State aus einer API-Antwort. + /// Zentrale Methode — alle Endpunkte liefern ReceiverAuthModel, + /// und diese Methode mappt den Status-String auf das richtige Enum. + /// + 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 + }; + } + /// Setzt Fehler wenn der API-Call selbst fehlschlägt (Netzwerk etc.) public void SetError(string message) { ErrorMessage = message; Status = EnvelopePageStatus.Error; } - public void SetAlreadySigned() => Status = EnvelopePageStatus.AlreadySigned; - public void SetRejected() => Status = EnvelopePageStatus.Rejected; + /// Setzt NotFound (z.B. bei 404 ohne Body) public void SetNotFound() => Status = EnvelopePageStatus.NotFound; - // --- Event: Benachrichtigt Komponenten über Änderungen --- + // ── Event ── public event Action? OnChange; private void NotifyStateChanged() => OnChange?.Invoke(); }