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.
This commit is contained in:
2026-09-28 14:42:52 +02:00
parent 44bbdbb835
commit 940d129c11
6 changed files with 407 additions and 14 deletions

View File

@@ -86,6 +86,18 @@ public class AuthService(IHttpClientFactory httpClientFactory)
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
} }
/// <summary>
/// Shares the current envelope in read-only mode for the specified receiver and expiration date.
/// Calls POST /api/readonly/{envelopeKey}.
/// </summary>
public async Task<bool> 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;
}
/// <summary> /// <summary>
/// Removes the per-envelope receiver cookie for the given envelope key. /// Removes the per-envelope receiver cookie for the given envelope key.
/// Calls POST /api/auth/logout/envelope/{envelopeKey}. /// Calls POST /api/auth/logout/envelope/{envelopeKey}.

View File

@@ -5,12 +5,15 @@
@using EnvelopeGenerator.Server.Client.Models @using EnvelopeGenerator.Server.Client.Models
@using EnvelopeGenerator.Server.Client.Models.Constants @using EnvelopeGenerator.Server.Client.Models.Constants
@using EnvelopeGenerator.Server.Client.Services @using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Common.Extensions
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver @using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
@using EnvelopeGenerator.Application.EnvelopeReceivers.Queries @using EnvelopeGenerator.Application.EnvelopeReceivers.Queries
@using EnvelopeGenerator.Domain.Constants
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using DevExpress.Blazor @using DevExpress.Blazor
@using System.Drawing @using System.Drawing
@using System.Security.Claims @using System.Security.Claims
@using System.ComponentModel.DataAnnotations
@using Microsoft.Extensions.Caching.Memory @using Microsoft.Extensions.Caching.Memory
@using MediatR @using MediatR
@using EnvelopeGenerator.Server.Extensions @using EnvelopeGenerator.Server.Extensions
@@ -130,6 +133,20 @@
</button> </button>
} }
@if (!(_envelopeReceiver?.Envelope?.ReadOnly ?? false))
{
<button class="pdf-toolbar__btn"
@onclick="OpenSharePopup"
title="Dokument teilen"
style="flex-shrink: 0;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.5 1a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2zm-11 5a1.5 1.5 0 1 0 1.415 2H4a1.5 1.5 0 0 0-1.5-2zm11 5a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2z"/>
<path d="M2.854 7.646a.5.5 0 0 1 .0-.707l9-5a.5.5 0 1 1 .486.874l-9 5a.5.5 0 0 1-.486-.167zm0 .708a.5.5 0 0 1 .486-.167l9 5a.5.5 0 0 1-.486.874l-9-5a.5.5 0 0 1 0-.707z"/>
</svg>
<span class="pdf-toolbar__btn-text">Teilen</span>
</button>
}
<button class="pdf-toolbar__btn" <button class="pdf-toolbar__btn"
@onclick="LogoutAsync" @onclick="LogoutAsync"
disabled="@_isLoggingOut" disabled="@_isLoggingOut"
@@ -358,6 +375,57 @@
</FooterContentTemplate> </FooterContentTemplate>
</DxPopup> </DxPopup>
<DxPopup @bind-Visible="_sharePopupVisible"
HeaderText="Dokument teilen"
Width="520px"
MaxWidth="95vw"
ShowFooter="true"
CloseOnOutsideClick="true">
<BodyContentTemplate>
<div class="mb-3">
<label class="form-label" for="rp-share-mail" style="font-size: 0.9rem; font-weight: 500;">E-Mail</label>
<input id="rp-share-mail"
class="form-control"
placeholder="user@mail.com"
value="@_shareReceiverMail"
@oninput="args => _shareReceiverMail = args.Value?.ToString() ?? string.Empty" />
</div>
<div class="mb-1">
<label class="form-label" for="rp-share-date" style="font-size: 0.9rem; font-weight: 500;">Gultig bis</label>
<input id="rp-share-date"
class="form-control"
type="date"
min="@DateTime.Today.AddDays(1).ToString("yyyy-MM-dd")"
max="@DateTime.Today.AddDays(90).ToString("yyyy-MM-dd")"
value="@_shareDateValid.ToString("yyyy-MM-dd")"
@oninput="OnShareDateChanged" />
</div>
@if (!string.IsNullOrWhiteSpace(_shareMessage))
{
<div class="mt-3 alert @(_shareMessageIsError ? "alert-danger" : "alert-success") mb-0" role="alert">
@_shareMessage
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<div class="d-flex justify-content-end gap-2 w-100">
<button class="btn btn-outline-secondary"
@onclick="CloseSharePopup"
disabled="@_isSharing">
Schliessen
</button>
<button class="btn btn-primary"
@onclick="ShareEnvelopeAsync"
disabled="@_isSharing"
style="background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%); border: none;">
@(_isSharing ? "Senden..." : "Senden")
</button>
</div>
</FooterContentTemplate>
</DxPopup>
@code { @code {
// ----- Constants ----- // ----- Constants -----
const string SignatureTabDraw = "draw"; const string SignatureTabDraw = "draw";
@@ -404,6 +472,14 @@
string _signaturePlace = string.Empty; string _signaturePlace = string.Empty;
bool _isLoggingOut = false; 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() async Task LogoutAsync()
{ {
if (_isLoggingOut || string.IsNullOrWhiteSpace(EnvelopeKey)) if (_isLoggingOut || string.IsNullOrWhiteSpace(EnvelopeKey))
@@ -424,6 +500,85 @@
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true); 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 ----- // ----- Lifecycle -----
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -434,6 +589,12 @@
return; 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 // Authorization — same pattern as EnvelopeReceiverPage
_receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey); _receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
if (_receiverUser is null) if (_receiverUser is null)

View File

@@ -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<ReceiverReadOnlyPage> Logger
@inject IEnvelopeReceiverReadOnlyService ReadOnlyService
@inject IEnvelopeReceiverService EnvelopeReceiverService
@inject IEnvelopeHistoryService HistoryService
@implements IDisposable
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Reporting.Viewer/css/dx-blazor-reporting-components.bs5.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<div class="envelope-viewer-layout">
<div class="envelope-action-bar">
<div class="envelope-action-bar__inner" style="padding: 0.5rem 1.25rem;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:1rem;">
<div style="font-weight:600; color:#1f2937;">Dokumentansicht (Nur Lesen)</div>
@if (_readOnly is not null)
{
<div style="font-size:0.8rem; color:#6b7280;">Gultig bis: @_readOnly.DateValid.ToString("dd.MM.yyyy")</div>
}
</div>
</div>
</div>
<div class="envelope-content" style="padding:0; overflow:hidden;">
@if (_isLoading)
{
<div class="d-flex justify-content-center align-items-center h-100">
<div class="text-center">
<div class="spinner-border text-white mb-3" style="width:3.5rem; height:3.5rem;" role="status"></div>
<p class="text-white fw-semibold">Dokument wird geladen...</p>
</div>
</div>
}
else if (!string.IsNullOrWhiteSpace(_errorMessage))
{
<div class="error-container">
<div class="alert alert-danger shadow-lg mb-0">
<h5 class="mb-2">Dokument nicht verfugbar</h5>
<p class="mb-0">@_errorMessage</p>
</div>
</div>
}
else if (_isExpired)
{
<div class="error-container">
<div class="alert alert-warning shadow-lg mb-0">
<h5 class="mb-2">Freigabe abgelaufen</h5>
<p class="mb-0">Der Zeitraum fur diese Nur-Lesen-Freigabe ist abgelaufen.</p>
</div>
</div>
}
else if (_report is not null)
{
<DxReportViewer @ref="_reportViewer"
Report="_report"
RootCssClasses="w-100 h-100" />
}
</div>
</div>
@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();
}
}

View File

@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
/// Redirects legacy envelope key links to Blazor routes.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
[Route("EnvelopeKey")]
public class EnvelopeKeyRedirectController : Controller
{
/// <summary>
/// Redirects /EnvelopeKey/{*path} links to /envelope/{path}.
/// </summary>
[HttpGet("{*path}")]
public IActionResult RedirectToEnvelopePath([FromRoute] string path)
=> Redirect($"/envelope/{path}");
}

View File

@@ -1,8 +1,10 @@
using DigitalData.Core.Abstraction.Application.DTO; using DigitalData.Core.Abstraction.Application.DTO;
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly; using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
using EnvelopeGenerator.Application.Common.Interfaces.Services; using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Domain.Constants; using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Server.Extensions; using MediatR;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json; using Newtonsoft.Json;
@@ -33,24 +35,36 @@ public class ReadOnlyController : ControllerBase
} }
/// <summary> /// <summary>
/// Creates a new read-only receiver for the current envelope. /// Creates a new read-only receiver for an envelope.
/// </summary> /// </summary>
/// <param name="envelopeKey">Envelope key (uuid+signature encoded).</param>
/// <param name="createDto">Creation payload.</param> /// <param name="createDto">Creation payload.</param>
[HttpPost] [HttpPost("{envelopeKey}")]
[Authorize(Policy = AuthPolicy.Receiver)] [Authorize(Policy = AuthPolicy.SenderOrReceiver)]
[Obsolete("Use MediatR")] [Obsolete("Use MediatR")]
public async Task<IActionResult> CreateAsync([FromBody] EnvelopeReceiverReadOnlyCreateDto createDto) public async Task<IActionResult> CreateAsync(
[FromRoute] string envelopeKey,
[FromBody] EnvelopeReceiverReadOnlyCreateDto createDto,
[FromServices] IMediator mediator,
CancellationToken cancel)
{ {
var authReceiverMail = User.ReceiverMail(); var envelopeReceiver = await mediator.Send(new ReadEnvelopeReceiverQuery { Key = envelopeKey }, cancel);
if (authReceiverMail is null) 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)); _logger.LogError("Receiver e-mail is missing in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
return Unauthorized(); return StatusCode(StatusCodes.Status500InternalServerError);
} }
var envelopeId = User.EnvelopeId(); var envelopeId = authEnvelopeReceiver.EnvelopeId;
createDto.AddedWho = authReceiverMail; createDto.AddedWho = addedWho;
createDto.EnvelopeId = envelopeId; createDto.EnvelopeId = envelopeId;
var creationRes = await _readOnlyService.CreateAsync(createDto: createDto); var creationRes = await _readOnlyService.CreateAsync(createDto: createDto);

View File

@@ -10,9 +10,9 @@
<Authors>Digital Data GmbH</Authors> <Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company> <Company>Digital Data GmbH</Company>
<Product>EnvelopeGenerator.Server</Product> <Product>EnvelopeGenerator.Server</Product>
<Version>1.2.0-beta</Version> <Version>1.4.0-beta</Version>
<FileVersion>1.2.0.0</FileVersion> <FileVersion>1.4.0.0</FileVersion>
<AssemblyVersion>1.2.0.0</AssemblyVersion> <AssemblyVersion>1.4.0.0</AssemblyVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright> <Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile> <DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup> </PropertyGroup>