From 940d129c1166ec7bad0fb3ee713547fb4780b007 Mon Sep 17 00:00:00 2001 From: TekH Date: Mon, 28 Sep 2026 14:42:52 +0200 Subject: [PATCH] Add read-only sharing and viewing functionality Introduced the ability to share envelopes in read-only mode and view them via a dedicated page. Added `ShareEnvelopeReadOnlyAsync` in `AuthService.cs` to handle sharing logic. Updated `ReceiverPage.razor` with a "Share" button, popup UI, and validation for sharing inputs. Created `ReceiverReadOnlyPage.razor` for read-only document viewing with error handling and lifecycle management. Added `EnvelopeKeyRedirectController.cs` to support legacy link redirection. Enhanced `ReadOnlyController.cs` to support envelope sharing with improved logging and `IMediator` integration. Improved state management, validation, and error handling across components. Performed general code cleanup and UI enhancements for better user experience. --- .../Services/AuthService.cs | 12 ++ .../Components/Pages/ReceiverPage.razor | 161 +++++++++++++++ .../Pages/ReceiverReadOnlyPage.razor | 188 ++++++++++++++++++ .../EnvelopeKeyRedirectController.cs | 18 ++ .../Controllers/ReadOnlyController.cs | 36 +++- .../EnvelopeGenerator.Server.csproj | 6 +- 6 files changed, 407 insertions(+), 14 deletions(-) create mode 100644 EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverReadOnlyPage.razor create mode 100644 EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeKeyRedirectController.cs diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/AuthService.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/AuthService.cs index 30a2e35d..637e0180 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/AuthService.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/AuthService.cs @@ -86,6 +86,18 @@ public class AuthService(IHttpClientFactory httpClientFactory) return response.IsSuccessStatusCode; } + /// + /// Shares the current envelope in read-only mode for the specified receiver and expiration date. + /// Calls POST /api/readonly/{envelopeKey}. + /// + public async Task ShareEnvelopeReadOnlyAsync(string envelopeKey, string receiverMail, DateTime dateValid, CancellationToken cancel = default) + { + using var http = CreateDefaultClient(); + var payload = new { receiverMail, dateValid }; + var response = await http.PostAsJsonAsync($"/api/readonly/{Uri.EscapeDataString(envelopeKey)}", payload, cancel); + return response.IsSuccessStatusCode; + } + /// /// Removes the per-envelope receiver cookie for the given envelope key. /// Calls POST /api/auth/logout/envelope/{envelopeKey}. diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverPage.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverPage.razor index 082213b0..376e9803 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverPage.razor +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverPage.razor @@ -5,12 +5,15 @@ @using EnvelopeGenerator.Server.Client.Models @using EnvelopeGenerator.Server.Client.Models.Constants @using EnvelopeGenerator.Server.Client.Services +@using EnvelopeGenerator.Application.Common.Extensions @using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver @using EnvelopeGenerator.Application.EnvelopeReceivers.Queries +@using EnvelopeGenerator.Domain.Constants @using Microsoft.JSInterop @using DevExpress.Blazor @using System.Drawing @using System.Security.Claims +@using System.ComponentModel.DataAnnotations @using Microsoft.Extensions.Caching.Memory @using MediatR @using EnvelopeGenerator.Server.Extensions @@ -130,6 +133,20 @@ } + @if (!(_envelopeReceiver?.Envelope?.ReadOnly ?? false)) + { + + } + + + + + + @code { // ----- Constants ----- const string SignatureTabDraw = "draw"; @@ -404,6 +472,14 @@ string _signaturePlace = string.Empty; bool _isLoggingOut = false; + // ----- Share popup state ----- + bool _sharePopupVisible = false; + bool _isSharing = false; + string _shareReceiverMail = string.Empty; + DateTime _shareDateValid = DateTime.Today.AddDays(7); + string? _shareMessage; + bool _shareMessageIsError = false; + async Task LogoutAsync() { if (_isLoggingOut || string.IsNullOrWhiteSpace(EnvelopeKey)) @@ -424,6 +500,85 @@ Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true); } + void OpenSharePopup() + { + _shareReceiverMail = string.Empty; + _shareDateValid = DateTime.Today.AddDays(7); + _shareMessage = null; + _shareMessageIsError = false; + _sharePopupVisible = true; + } + + void CloseSharePopup() + { + if (_isSharing) + return; + + _sharePopupVisible = false; + } + + void OnShareDateChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args) + { + if (DateTime.TryParse(args.Value?.ToString(), out var parsedDate)) + _shareDateValid = parsedDate.Date; + } + + async Task ShareEnvelopeAsync() + { + if (_isSharing) + return; + + var email = _shareReceiverMail.Trim(); + if (string.IsNullOrWhiteSpace(email) || !new EmailAddressAttribute().IsValid(email)) + { + _shareMessage = "Bitte geben Sie eine gultige E-Mail-Adresse ein."; + _shareMessageIsError = true; + return; + } + + var minDate = DateTime.Today.AddDays(1); + var maxDate = DateTime.Today.AddDays(90); + if (_shareDateValid.Date < minDate || _shareDateValid.Date > maxDate) + { + _shareMessage = "Bitte wahlen Sie ein Datum zwischen morgen und 90 Tagen."; + _shareMessageIsError = true; + return; + } + + _isSharing = true; + _shareMessage = null; + _shareMessageIsError = false; + await InvokeAsync(StateHasChanged); + + try + { + var shared = await AuthService.ShareEnvelopeReadOnlyAsync(EnvelopeKey!, email, _shareDateValid.Date); + if (shared) + { + _shareMessage = "Dokument wurde erfolgreich als Nur-Lesen freigegeben."; + _shareMessageIsError = false; + _shareReceiverMail = string.Empty; + _shareDateValid = DateTime.Today.AddDays(7); + } + else + { + _shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut."; + _shareMessageIsError = true; + } + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to share envelope in read-only mode for {EnvelopeKey}", EnvelopeKey); + _shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut."; + _shareMessageIsError = true; + } + finally + { + _isSharing = false; + await InvokeAsync(StateHasChanged); + } + } + // ----- Lifecycle ----- protected override async Task OnInitializedAsync() { @@ -434,6 +589,12 @@ return; } + if (EnvelopeKey.TryDecode(out var decodedKeys) && decodedKeys.GetEncodeType() == EncodeType.EnvelopeReceiverReadOnly) + { + Navigation.NavigateTo($"/envelope/readonly/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true); + return; + } + // Authorization — same pattern as EnvelopeReceiverPage _receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey); if (_receiverUser is null) diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverReadOnlyPage.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverReadOnlyPage.razor new file mode 100644 index 00000000..dc45fa01 --- /dev/null +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverReadOnlyPage.razor @@ -0,0 +1,188 @@ +@page "/envelope/readonly/{ReadOnlyKey}" +@rendermode InteractiveServer +@using DevExpress.Blazor.Reporting +@using DevExpress.XtraReports.UI +@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly +@using EnvelopeGenerator.Application.Common.Extensions +@using EnvelopeGenerator.Application.Common.Interfaces.Services +@using EnvelopeGenerator.Domain.Constants +@inject EnvelopeGenerator.Server.Client.Services.AppVersionService AppVersion +@inject ILogger Logger +@inject IEnvelopeReceiverReadOnlyService ReadOnlyService +@inject IEnvelopeReceiverService EnvelopeReceiverService +@inject IEnvelopeHistoryService HistoryService +@implements IDisposable + + + + + +
+
+
+
+
Dokumentansicht (Nur Lesen)
+ @if (_readOnly is not null) + { +
Gultig bis: @_readOnly.DateValid.ToString("dd.MM.yyyy")
+ } +
+
+
+ +
+ @if (_isLoading) + { +
+
+
+

Dokument wird geladen...

+
+
+ } + else if (!string.IsNullOrWhiteSpace(_errorMessage)) + { +
+
+
Dokument nicht verfugbar
+

@_errorMessage

+
+
+ } + else if (_isExpired) + { +
+
+
Freigabe abgelaufen
+

Der Zeitraum fur diese Nur-Lesen-Freigabe ist abgelaufen.

+
+
+ } + else if (_report is not null) + { + + } +
+
+ +@code { + [Parameter] public string? ReadOnlyKey { get; set; } + + bool _isLoading = true; + bool _isExpired = false; + string? _errorMessage; + EnvelopeReceiverReadOnlyDto? _readOnly; + + DxReportViewer? _reportViewer; + XtraReport? _report; + + protected override async Task OnInitializedAsync() + { + if (string.IsNullOrWhiteSpace(ReadOnlyKey)) + { + _errorMessage = "Freigabe-Schlussel fehlt."; + _isLoading = false; + return; + } + + if (!ReadOnlyKey.TryDecode(out var decodedKeys) || decodedKeys.GetEncodeType() != EncodeType.EnvelopeReceiverReadOnly) + { + _errorMessage = "Ungultiger Freigabe-Link."; + _isLoading = false; + return; + } + + try + { + var readOnlyId = decodedKeys.ParseReadOnlyId(); + var readOnlyRes = await ReadOnlyService.ReadByIdAsync(readOnlyId); + if (readOnlyRes.IsFailed) + { + _errorMessage = "Freigabe konnte nicht geladen werden."; + _isLoading = false; + return; + } + + _readOnly = readOnlyRes.Data; + + if (DateTime.Now > _readOnly.DateValid) + { + _isExpired = true; + _isLoading = false; + return; + } + + if (_readOnly.Envelope is null || _readOnly.Receiver is null) + { + _errorMessage = "Freigabe ist unvollstandig."; + _isLoading = false; + return; + } + + var erRes = await EnvelopeReceiverService.ReadByUuidSignatureAsync(_readOnly.Envelope.Uuid, _readOnly.Receiver.Signature); + if (erRes.IsFailed) + { + _errorMessage = "Dokument wurde nicht gefunden."; + _isLoading = false; + return; + } + + var envelopeReceiver = erRes.Data; + var documentBytes = envelopeReceiver.Envelope?.Documents?.FirstOrDefault()?.ByteData; + if (documentBytes is not { Length: > 0 }) + { + _errorMessage = "Dokumentdaten konnten nicht geladen werden."; + _isLoading = false; + return; + } + + _report = BuildReport(documentBytes); + + try + { + var historyResult = await HistoryService.RecordAsync((int)_readOnly.EnvelopeId, _readOnly.AddedWho, EnvelopeStatus.EnvelopeViewed); + if (historyResult.IsFailed) + Logger.LogWarning("Failed to record EnvelopeViewed history for read-only key {ReadOnlyKey}", ReadOnlyKey); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to record read-only history for {ReadOnlyKey}", ReadOnlyKey); + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Unexpected error in read-only page for {ReadOnlyKey}", ReadOnlyKey); + _errorMessage = "Beim Laden ist ein unerwarteter Fehler aufgetreten."; + } + + _isLoading = false; + } + + static XtraReport BuildReport(byte[] pdfBytes) + { + var report = new XtraReport + { + PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4, + Landscape = false, + Margins = new System.Drawing.Printing.Margins(0, 0, 0, 0) + }; + + var detail = new DetailBand { HeightF = 0f }; + report.Bands.Add(detail); + + detail.Controls.Add(new XRPdfContent + { + Source = pdfBytes, + GenerateOwnPages = true + }); + + return report; + } + + public void Dispose() + { + _report?.Dispose(); + } +} diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeKeyRedirectController.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeKeyRedirectController.cs new file mode 100644 index 00000000..bc7f3b9b --- /dev/null +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeKeyRedirectController.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc; + +namespace EnvelopeGenerator.Server.Controllers; + +/// +/// Redirects legacy envelope key links to Blazor routes. +/// +[ApiExplorerSettings(IgnoreApi = true)] +[Route("EnvelopeKey")] +public class EnvelopeKeyRedirectController : Controller +{ + /// + /// Redirects /EnvelopeKey/{*path} links to /envelope/{path}. + /// + [HttpGet("{*path}")] + public IActionResult RedirectToEnvelopePath([FromRoute] string path) + => Redirect($"/envelope/{path}"); +} diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/ReadOnlyController.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/ReadOnlyController.cs index b00f17de..f55d417e 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/ReadOnlyController.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/ReadOnlyController.cs @@ -1,8 +1,10 @@ using DigitalData.Core.Abstraction.Application.DTO; using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly; using EnvelopeGenerator.Application.Common.Interfaces.Services; +using EnvelopeGenerator.Application.EnvelopeReceivers.Queries; using EnvelopeGenerator.Domain.Constants; -using EnvelopeGenerator.Server.Extensions; +using MediatR; +using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; @@ -33,24 +35,36 @@ public class ReadOnlyController : ControllerBase } /// - /// Creates a new read-only receiver for the current envelope. + /// Creates a new read-only receiver for an envelope. /// + /// Envelope key (uuid+signature encoded). /// Creation payload. - [HttpPost] - [Authorize(Policy = AuthPolicy.Receiver)] + [HttpPost("{envelopeKey}")] + [Authorize(Policy = AuthPolicy.SenderOrReceiver)] [Obsolete("Use MediatR")] - public async Task CreateAsync([FromBody] EnvelopeReceiverReadOnlyCreateDto createDto) + public async Task CreateAsync( + [FromRoute] string envelopeKey, + [FromBody] EnvelopeReceiverReadOnlyCreateDto createDto, + [FromServices] IMediator mediator, + CancellationToken cancel) { - var authReceiverMail = User.ReceiverMail(); - if (authReceiverMail is null) + var envelopeReceiver = await mediator.Send(new ReadEnvelopeReceiverQuery { Key = envelopeKey }, cancel); + var authEnvelopeReceiver = envelopeReceiver.FirstOrDefault(); + + if (authEnvelopeReceiver?.Envelope is null) + return NotFound(); + + var addedWho = User.FindFirstValue(ClaimTypes.Email) + ?? authEnvelopeReceiver.Receiver?.EmailAddress; + if (string.IsNullOrWhiteSpace(addedWho)) { - _logger.LogError("EmailAddress claim is not found in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto)); - return Unauthorized(); + _logger.LogError("Receiver e-mail is missing in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto)); + return StatusCode(StatusCodes.Status500InternalServerError); } - var envelopeId = User.EnvelopeId(); + var envelopeId = authEnvelopeReceiver.EnvelopeId; - createDto.AddedWho = authReceiverMail; + createDto.AddedWho = addedWho; createDto.EnvelopeId = envelopeId; var creationRes = await _readOnlyService.CreateAsync(createDto: createDto); diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/EnvelopeGenerator.Server.csproj b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/EnvelopeGenerator.Server.csproj index cd5f8957..fabf8493 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/EnvelopeGenerator.Server.csproj +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/EnvelopeGenerator.Server.csproj @@ -10,9 +10,9 @@ Digital Data GmbH Digital Data GmbH EnvelopeGenerator.Server - 1.2.0-beta - 1.2.0.0 - 1.2.0.0 + 1.4.0-beta + 1.4.0.0 + 1.4.0.0 Copyright © 2026 Digital Data GmbH. All rights reserved. bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml