First successfull build
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,60 @@
|
||||
<h3>AccessCodeForm</h3>
|
||||
@* DUMB COMPONENT: Kennt keine Services, nur Parameter und Events *@
|
||||
|
||||
<div class="access-code-container">
|
||||
<h2>Zugangscode eingeben</h2>
|
||||
<p>Ein Zugangscode wurde an Ihre E-Mail-Adresse gesendet.</p>
|
||||
|
||||
<EditForm Model="_model" OnValidSubmit="Submit">
|
||||
<DataAnnotationsValidator />
|
||||
|
||||
<div class="form-group">
|
||||
<InputText @bind-Value="_model.Code"
|
||||
class="form-control code-input"
|
||||
placeholder="000000"
|
||||
maxlength="6" />
|
||||
<ValidationMessage For="() => _model.Code" />
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-2">@ErrorMessage</div>
|
||||
}
|
||||
|
||||
<button type="submit" class="btn btn-primary mt-3" disabled="@_isSubmitting">
|
||||
@if (_isSubmitting)
|
||||
{
|
||||
<LoadingIndicator Small="true" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Bestätigen</span>
|
||||
}
|
||||
</button>
|
||||
</EditForm>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
// Parameter von der Eltern-Page
|
||||
[Parameter] public required string EnvelopeKey { get; set; }
|
||||
[Parameter] public string? ErrorMessage { get; set; }
|
||||
|
||||
}
|
||||
// EventCallback: Informiert die Page, dass ein Code eingegeben wurde
|
||||
[Parameter] public EventCallback<string> OnSubmit { get; set; }
|
||||
|
||||
private AccessCodeModel _model = new();
|
||||
private bool _isSubmitting;
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
_isSubmitting = true;
|
||||
await OnSubmit.InvokeAsync(_model.Code);
|
||||
_isSubmitting = false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,23 @@
|
||||
<h3>PdfViewer</h3>
|
||||
@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");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,19 @@
|
||||
<h3>ErrorDisplay</h3>
|
||||
<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; }
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
<h3>LoadingIndicator</h3>
|
||||
<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; }
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.22" />
|
||||
</ItemGroup>
|
||||
<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>
|
||||
</Project>
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace EnvelopeGenerator.ReceiverUI.Client.Models
|
||||
{
|
||||
public class AccessCodeModel
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
namespace EnvelopeGenerator.ReceiverUI.Client.Models
|
||||
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 class AuthState
|
||||
{
|
||||
}
|
||||
}
|
||||
public bool IsAuthenticated { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public string? EnvelopeUuid { get; set; }
|
||||
public string? ReceiverEmail { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,5 +1,84 @@
|
||||
<h3>EnvelopePage</h3>
|
||||
@page "/envelope/{EnvelopeKey}"
|
||||
@rendermode InteractiveAuto
|
||||
@inject IEnvelopeService EnvelopeService
|
||||
@inject EnvelopeState State
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Dokument</PageTitle>
|
||||
|
||||
@switch (State.Status)
|
||||
{
|
||||
case EnvelopePageStatus.Loading:
|
||||
<LoadingIndicator Message="Dokument wird geladen..." />
|
||||
break;
|
||||
|
||||
case EnvelopePageStatus.NotFound:
|
||||
<ErrorDisplay Title="Nicht gefunden"
|
||||
Message="Dieses Dokument existiert nicht oder ist nicht mehr verfügbar." />
|
||||
break;
|
||||
|
||||
case EnvelopePageStatus.AlreadySigned:
|
||||
<ErrorDisplay Title="Bereits unterschrieben"
|
||||
Message="Dieses Dokument wurde bereits unterschrieben."
|
||||
Icon="check-circle" />
|
||||
break;
|
||||
|
||||
case EnvelopePageStatus.RequiresAccessCode:
|
||||
<AccessCodeForm EnvelopeKey="@EnvelopeKey"
|
||||
ErrorMessage="@State.ErrorMessage"
|
||||
OnSubmit="HandleAccessCodeSubmit" />
|
||||
break;
|
||||
|
||||
case EnvelopePageStatus.ShowDocument:
|
||||
<PdfViewer DocumentBytes="@_documentBytes" />
|
||||
break;
|
||||
|
||||
case EnvelopePageStatus.Error:
|
||||
<ErrorDisplay Title="Fehler" Message="@State.ErrorMessage" />
|
||||
break;
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string EnvelopeKey { get; set; } = default!;
|
||||
|
||||
}
|
||||
private byte[]? _documentBytes;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
State.OnChange += StateHasChanged;
|
||||
await LoadEnvelopeAsync();
|
||||
}
|
||||
|
||||
private async Task LoadEnvelopeAsync()
|
||||
{
|
||||
State.SetLoading();
|
||||
|
||||
// Die genaue API-Logik hängt von den verfügbaren Endpunkten ab.
|
||||
// Dies ist die Struktur — die konkreten Endpoints implementierst du
|
||||
// basierend auf den vorhandenen API-Controllern.
|
||||
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;
|
||||
}
|
||||
|
||||
// Daten verarbeiten und Status setzen
|
||||
State.SetDocument();
|
||||
}
|
||||
|
||||
private async Task HandleAccessCodeSubmit(string code)
|
||||
{
|
||||
// AccessCode an API senden
|
||||
// Bei Erfolg: State.SetDocument() oder State.SetTwoFactorRequired()
|
||||
// Bei Fehler: State.SetError(...)
|
||||
}
|
||||
|
||||
public void Dispose() => State.OnChange -= StateHasChanged;
|
||||
}
|
||||
@@ -1,5 +1,27 @@
|
||||
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);
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
// 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>();
|
||||
|
||||
// State: Ein State-Objekt pro Browser-Tab
|
||||
builder.Services.AddScoped<EnvelopeState>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -1,6 +1,54 @@
|
||||
namespace EnvelopeGenerator.ReceiverUI.Client.Services
|
||||
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 class AuthService
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,38 @@
|
||||
namespace EnvelopeGenerator.ReceiverUI.Client.Services.Base
|
||||
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 class ApiResponse
|
||||
{
|
||||
}
|
||||
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>
|
||||
/// 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 };
|
||||
}
|
||||
@@ -1,6 +1,110 @@
|
||||
namespace EnvelopeGenerator.ReceiverUI.Client.Services.Base
|
||||
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
|
||||
{
|
||||
public 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);
|
||||
return ApiResponse<T>.Failure((int)response.StatusCode, errorBody);
|
||||
}
|
||||
|
||||
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);
|
||||
return ApiResponse<TResponse>.Failure((int)response.StatusCode, errorBody);
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
namespace EnvelopeGenerator.ReceiverUI.Client.Services
|
||||
using EnvelopeGenerator.ReceiverUI.Client.Models;
|
||||
using EnvelopeGenerator.ReceiverUI.Client.Services.Base;
|
||||
|
||||
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
|
||||
|
||||
public class EnvelopeService : ApiServiceBase, IEnvelopeService
|
||||
{
|
||||
public class EnvelopeService
|
||||
{
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
namespace EnvelopeGenerator.ReceiverUI.Client.Services
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
namespace EnvelopeGenerator.ReceiverUI.Client.Services
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -1,6 +1,81 @@
|
||||
namespace EnvelopeGenerator.ReceiverUI.Client.State
|
||||
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.
|
||||
/// </summary>
|
||||
public class EnvelopeState
|
||||
{
|
||||
public class EnvelopeState
|
||||
private EnvelopePageStatus _status = EnvelopePageStatus.Loading;
|
||||
private string? _errorMessage;
|
||||
|
||||
/// <summary>Aktueller Seitenstatus</summary>
|
||||
public EnvelopePageStatus Status
|
||||
{
|
||||
get => _status;
|
||||
private set
|
||||
{
|
||||
_status = value;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fehlermeldung (falls vorhanden)</summary>
|
||||
public string? ErrorMessage
|
||||
{
|
||||
get => _errorMessage;
|
||||
private set
|
||||
{
|
||||
_errorMessage = value;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Zustandsübergänge (öffentliche Methoden) ---
|
||||
|
||||
public void SetLoading() => Status = EnvelopePageStatus.Loading;
|
||||
|
||||
public void SetAccessCodeRequired()
|
||||
{
|
||||
ErrorMessage = null;
|
||||
Status = EnvelopePageStatus.RequiresAccessCode;
|
||||
}
|
||||
|
||||
public void SetTwoFactorRequired() => Status = EnvelopePageStatus.RequiresTwoFactor;
|
||||
|
||||
public void SetDocument() => Status = EnvelopePageStatus.ShowDocument;
|
||||
|
||||
public void SetError(string message)
|
||||
{
|
||||
ErrorMessage = message;
|
||||
Status = EnvelopePageStatus.Error;
|
||||
}
|
||||
|
||||
public void SetAlreadySigned() => Status = EnvelopePageStatus.AlreadySigned;
|
||||
public void SetRejected() => Status = EnvelopePageStatus.Rejected;
|
||||
public void SetNotFound() => Status = EnvelopePageStatus.NotFound;
|
||||
|
||||
// --- Event: Benachrichtigt Komponenten über Änderungen ---
|
||||
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
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@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
|
||||
@@ -7,3 +8,10 @@
|
||||
@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
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
@@ -17,4 +17,4 @@
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
@@ -1,23 +1,34 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<div class="header-content">
|
||||
<span class="app-title">signFLOW</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<article class="content px-4">
|
||||
@Body
|
||||
</article>
|
||||
<main class="app-main">
|
||||
<ErrorBoundary @ref="_errorBoundary">
|
||||
<ChildContent>
|
||||
@Body
|
||||
</ChildContent>
|
||||
<ErrorContent Context="ex">
|
||||
<div class="error-container text-center py-5">
|
||||
<h2>😵 Ein unerwarteter Fehler ist aufgetreten</h2>
|
||||
<p class="text-muted">Bitte versuchen Sie es erneut.</p>
|
||||
<button class="btn btn-primary" @onclick="Recover">Erneut versuchen</button>
|
||||
</div>
|
||||
</ErrorContent>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer text-center py-2 text-muted">
|
||||
<small>© @DateTime.Now.Year Digital Data GmbH</small>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
@code {
|
||||
private ErrorBoundary? _errorBoundary;
|
||||
|
||||
private void Recover() => _errorBoundary?.Recover();
|
||||
}
|
||||
@@ -3,4 +3,12 @@
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
<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>
|
||||
@@ -0,0 +1,9 @@
|
||||
@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
|
||||
@@ -1,18 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<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="8.0.22" />
|
||||
</ItemGroup>
|
||||
<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\css\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,16 +1,21 @@
|
||||
using EnvelopeGenerator.ReceiverUI.Client.Pages;
|
||||
using EnvelopeGenerator.ReceiverUI.Components;
|
||||
using EnvelopeGenerator.ReceiverUI.Components;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
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();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseWebAssemblyDebugging();
|
||||
@@ -18,18 +23,20 @@ if (app.Environment.IsDevelopment())
|
||||
else
|
||||
{
|
||||
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
||||
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();
|
||||
app.Run();
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"ApiBaseUrl": "https://localhost:5001",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user