Compare commits

..

2 Commits

Author SHA1 Message Date
940d129c11 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.
2026-09-28 14:42:52 +02:00
44bbdbb835 Add logout functionality and signed status check
Introduced a logout feature to the `ReceiverPage` component:
- Added a logout button to the toolbar with `_isLoggingOut` state.
- Implemented `LogoutAsync` method to handle logout logic, including navigation and error handling.
- Injected `IMediator` and `ILogger<ReceiverPage>` for logging and MediatR functionality.
- Added a check in `OnInitializedAsync` to redirect users to `/envelope/signed-result` if the envelope is already signed.
- Updated namespaces to include MediatR and custom extensions.

These changes improve user experience and enhance the component's functionality.
2026-09-28 14:11:06 +02:00
13 changed files with 439 additions and 424 deletions

View File

@@ -236,12 +236,6 @@ Manual testing workflow:
3. Test sender login at `/sender/login`
4. Test receiver flow at `/envelope/login/{envelopeKey}`
## Technical Debt TODOs
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor` to reduce complexity.
- Move sender dashboard business/status logic into dedicated services and extension methods (for example effective status resolution, tab classification, receiver signed/rejected detection, and grid-layout filter sanitation).
- Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component.
## Database
**SQL Server** (DD_ECM)

View File

@@ -26,24 +26,4 @@ public class AnnotationService(IHttpClientFactory httpClientFactory)
var result = await response.Content.ReadFromJsonAsync<List<AnnotationDto>>(_jsonOptions, cancel);
return result ?? [];
}
public async Task RejectAsync(string envelopeKey, string? reason, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.PostAsJsonAsync(
$"/api/Annotation/reject/{Uri.EscapeDataString(envelopeKey)}",
reason,
_jsonOptions,
cancel);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancel);
throw new HttpRequestException(
$"Failed to reject envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}",
null,
response.StatusCode);
}
}
}

View File

@@ -86,6 +86,18 @@ public class AuthService(IHttpClientFactory httpClientFactory)
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>
/// Removes the per-envelope receiver cookie for the given envelope key.
/// Calls POST /api/auth/logout/envelope/{envelopeKey}.

View File

@@ -262,7 +262,7 @@
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null)
{
var statusInfo = GetStatusInfo(GetEffectiveEnvelopeStatus(envelope));
var statusInfo = GetStatusInfo(envelope.Status);
<div class="status-badge status-badge--@statusInfo.CssClass">
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
@statusInfo.Label
@@ -314,20 +314,12 @@
{
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
@{
var isRejected = IsReceiverRejected(envelope, receiver);
var isSigned = IsReceiverSigned(envelope, receiver);
var accessCode = receiver.AccessCode;
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
}
<span class="receiver-badge receiver-badge--@(isRejected ? "unsigned" : (isSigned ? "signed" : "unsigned"))" style="min-width: 100px;">
@if (isRejected)
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
<span>@GetStatusLabel(EnvelopeStatus.DocumentRejected)</span>
}
else if (isSigned)
<span class="receiver-badge receiver-badge--@(isSigned ? "signed" : "unsigned")" style="min-width: 100px;">
@if (isSigned)
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
@@ -598,11 +590,10 @@
{
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
// Split into active and completed based on effective status
// (history-aware: rejected envelopes should not stay in active tab).
// Split into active and completed based on status
var envelopes = _allEnvelopes.ToList();
_activeEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsCompleted()).ToList();
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
}
catch (Exception ex)
{
@@ -754,13 +745,13 @@
bool IsEnvelopeSent(EnvelopeDto envelope)
{
var status = GetEffectiveEnvelopeStatus(envelope);
var status = (EnvelopeStatus)envelope.Status;
return status >= EnvelopeStatus.EnvelopeQueued;
}
bool IsEnvelopeEditable(EnvelopeDto envelope)
{
var status = GetEffectiveEnvelopeStatus(envelope);
var status = (EnvelopeStatus)envelope.Status;
return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved;
}
@@ -809,7 +800,7 @@
IReadOnlyList<StatusFilterItem> GetStatusFilterItems(IEnumerable<EnvelopeDto>? source)
{
return (source ?? Enumerable.Empty<EnvelopeDto>())
.Select(GetEffectiveEnvelopeStatus)
.Select(x => x.Status)
.Distinct()
.OrderBy(x => x)
.Select(x => new StatusFilterItem(x, GetStatusLabel(x)))
@@ -827,7 +818,7 @@
bool IsReceiverSigned(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
{
var status = GetEffectiveEnvelopeStatus(envelope);
var status = (EnvelopeStatus)envelope.Status;
if (status is EnvelopeStatus.EnvelopeCompletelySigned or EnvelopeStatus.MessageCompletionSent)
return true;
@@ -850,43 +841,6 @@
return false;
}
bool IsReceiverRejected(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
{
var status = GetEffectiveEnvelopeStatus(envelope);
var histories = envelope.Histories?.Where(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected).ToList() ?? [];
if (histories.Count == 0)
return false;
if (histories.Any(h => h.Receiver?.Id == receiver.ReceiverId))
return true;
var receiverEmail = receiver.Receiver?.EmailAddress;
if (!string.IsNullOrWhiteSpace(receiverEmail) &&
histories.Any(h => string.Equals(h.UserReference, receiverEmail, StringComparison.OrdinalIgnoreCase)))
return true;
if (!string.IsNullOrWhiteSpace(receiver.Name) &&
histories.Any(h => string.Equals(h.UserReference, receiver.Name, StringComparison.OrdinalIgnoreCase)))
return true;
return status == EnvelopeStatus.EnvelopeRejected && (envelope.EnvelopeReceivers?.Count() ?? 0) == 1;
}
EnvelopeStatus GetEffectiveEnvelopeStatus(EnvelopeDto envelope)
{
var rawStatus = envelope.Status;
if (rawStatus is EnvelopeStatus.EnvelopeRejected or EnvelopeStatus.EnvelopeWithdrawn)
return rawStatus;
var hasRejectedHistory = (envelope.Histories ?? Enumerable.Empty<HistoryDto>())
.Any(h => h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected);
return hasRejectedHistory ? EnvelopeStatus.EnvelopeRejected : rawStatus;
}
DateTime GetHistoryDate(HistoryDto history)
{
return history.ActionDate ?? history.AddedWhen;
@@ -1029,18 +983,13 @@
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(CurrentGridKey, out var json))
{
var layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
SanitizeLayoutFilters(layout);
e.Layout = layout;
}
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
async Task OnGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
try
{
SanitizeLayoutFilters(e.Layout);
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(CurrentGridKey, json);
}
@@ -1050,55 +999,5 @@
}
}
// Keep column/order personalization, but never persist/restore data filters.
// Tab counters are based on tab datasets; persisted grid filters can hide rows and
// make the grid appear empty or incomplete.
static void SanitizeLayoutFilters(GridPersistentLayout? layout)
{
if (layout is null)
return;
ClearWritableProperty(layout, "FilterCriteria");
ClearWritableProperty(layout, "SearchText");
if (layout.Columns is not System.Collections.IEnumerable columns)
return;
foreach (var column in columns)
{
if (column is null)
continue;
ClearWritableProperty(column, "FilterCriteria");
ClearWritableProperty(column, "FilterValues");
ClearWritableProperty(column, "FilterValue");
ClearWritableProperty(column, "SearchText");
}
}
static void ClearWritableProperty(object target, string propertyName)
{
var prop = target.GetType()
.GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)
.FirstOrDefault(p => p.Name == propertyName && p.GetIndexParameters().Length == 0);
if (prop is null || !prop.CanWrite)
return;
var propertyType = prop.PropertyType;
object? value;
if (!propertyType.IsValueType || Nullable.GetUnderlyingType(propertyType) is not null)
{
value = null;
}
else
{
value = Activator.CreateInstance(propertyType);
}
prop.SetValue(target, value);
}
private sealed record StatusFilterItem(EnvelopeStatus Value, string Text);
}

View File

@@ -5,22 +5,27 @@
@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
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject AnnotationService AnnotationService
@inject AppVersionService AppVersion
@inject IMemoryCache MemoryCache
@inject ILogger<ReceiverPage> Logger
@inject IMediator Mediator
@implements IDisposable
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
@@ -128,15 +133,30 @@
</button>
}
<button class="pdf-toolbar__btn pdf-toolbar__btn--reject"
@onclick="OpenRejectPopup"
disabled="@_isRejectSubmitting"
title="Ablehnen"
@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"
@onclick="LogoutAsync"
disabled="@_isLoggingOut"
title="Abmelden"
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="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
<path fill-rule="evenodd" d="M10 15a1 1 0 0 0 1-1v-2a.5.5 0 0 1 1 0v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v2a.5.5 0 0 1-1 0V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h7z" />
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L14.293 7.5H6.5a.5.5 0 0 0 0 1h7.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z" />
</svg>
<span class="pdf-toolbar__btn-text">Ablehnen</span>
<span class="pdf-toolbar__btn-text">@(_isLoggingOut ? "Abmelden..." : "Abmelden")</span>
</button>
</div>
</div>
@@ -355,70 +375,52 @@
</FooterContentTemplate>
</DxPopup>
@* Rejection Popup *@
<DxPopup @bind-Visible="_rejectPopupVisible"
HeaderText="Dokument ablehnen"
Width="500px"
<DxPopup @bind-Visible="_sharePopupVisible"
HeaderText="Dokument teilen"
Width="520px"
MaxWidth="95vw"
ShowFooter="true"
CloseOnOutsideClick="false"
ShowCloseButton="false"
CloseOnEscape="false">
CloseOnOutsideClick="true">
<BodyContentTemplate>
<div style="display: flex; align-items: flex-start; gap: 0.9rem; padding: 0.2rem 0 0.8rem;">
<div style="flex-shrink: 0; width: 36px; height: 36px; border-radius: 999px; background: #fee2e2; color: #b91c1c; display: inline-flex; align-items: center; justify-content: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
</div>
<div style="min-width: 0;">
<div style="font-size: 0.95rem; font-weight: 600; color: #1f2937; margin-bottom: 0.3rem;">
Möchten Sie dieses Dokument wirklich ablehnen?
</div>
<div style="font-size: 0.84rem; color: #6b7280; line-height: 1.45;">
Diese Aktion markiert den Umschlag als abgelehnt und informiert den Absender.
</div>
</div>
<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>
<label for="rp-reject-reason" class="form-label" style="font-size: 0.875rem; font-weight: 500; color: #495057;">
Grund <span style="color: #6c757d; font-weight: 400;">(optional)</span>
</label>
<textarea id="rp-reject-reason"
class="form-control"
rows="4"
maxlength="1000"
value="@_rejectReason"
@oninput="args => _rejectReason = args.Value?.ToString() ?? string.Empty"
placeholder="Grund der Ablehnung"
style="border: 2px solid #e9ecef; border-radius: 8px; padding: 0.625rem; resize: vertical;"></textarea>
<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(_rejectValidationMessage))
@if (!string.IsNullOrWhiteSpace(_shareMessage))
{
<div style="background: #fee2e2; border-left: 4px solid #dc2626; padding: 0.75rem 1rem; margin-top: 1rem; border-radius: 4px;">
<span style="color: #b91c1c; font-size: 0.875rem; font-weight: 500;">@_rejectValidationMessage</span>
<div class="mt-3 alert @(_shareMessageIsError ? "alert-danger" : "alert-success") mb-0" role="alert">
@_shareMessage
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
<div class="d-flex justify-content-end gap-2 w-100">
<button class="btn btn-outline-secondary"
@onclick="CloseRejectPopup"
disabled="@_isRejectSubmitting"
style="border-radius: 6px; padding: 0.625rem 1.25rem; font-weight: 500;">
Zurück
@onclick="CloseSharePopup"
disabled="@_isSharing">
Schliessen
</button>
<button class="btn btn-danger"
@onclick="SubmitRejectAsync"
disabled="@_isRejectSubmitting"
style="border-radius: 6px; padding: 0.625rem 1.5rem; font-weight: 600;">
@if (_isRejectSubmitting)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
Ablehnen
<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>
@@ -468,12 +470,114 @@
string _signerFullName = string.Empty;
string _signerPosition = string.Empty;
string _signaturePlace = string.Empty;
bool _isLoggingOut = false;
// ----- Rejection popup state -----
bool _rejectPopupVisible;
bool _isRejectSubmitting;
string _rejectReason = string.Empty;
string? _rejectValidationMessage;
// ----- 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))
return;
_isLoggingOut = true;
await InvokeAsync(StateHasChanged);
try
{
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Receiver logout failed for {EnvelopeKey}", EnvelopeKey);
}
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()
@@ -485,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)
@@ -493,6 +603,12 @@
return;
}
if (await Mediator.IsSignedAsync(_receiverUser.EnvelopeUuid(), _receiverUser.ReceiverSignature()))
{
Navigation.NavigateTo("/envelope/signed-result", forceLoad: true);
return;
}
try
{
// Load PDF bytes via MediatR (uses authenticated user's claims)
@@ -511,11 +627,6 @@
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
if (_envelopeReceiver is null)
Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey);
else if (IsRejected(_envelopeReceiver))
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/rejected", forceLoad: true);
return;
}
// Build initial report (no signature image yet)
_report = BuildReport(_pdfBytes, _signatures, capturedSignature: null);
@@ -561,16 +672,6 @@
}
// ----- Report builder -----
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
{
var status = envelopeReceiver.Envelope?.Status;
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
return true;
return envelopeReceiver.Envelope?.Histories?.Any(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
}
/// <summary>
/// Builds an XtraReport wrapping the PDF bytes.
/// If a signature is captured and there are signature fields, the signature image is
@@ -804,56 +905,6 @@
forceLoad: true);
}
void OpenRejectPopup()
{
_rejectValidationMessage = null;
_rejectPopupVisible = true;
}
void CloseRejectPopup()
{
if (_isRejectSubmitting)
return;
_rejectPopupVisible = false;
}
async Task SubmitRejectAsync()
{
if (_isRejectSubmitting)
return;
_isRejectSubmitting = true;
_rejectValidationMessage = null;
await InvokeAsync(StateHasChanged);
try
{
var reason = string.IsNullOrWhiteSpace(_rejectReason)
? null
: _rejectReason.Trim();
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
_rejectValidationMessage = "Envelope-Schluessel fehlt.";
_isRejectSubmitting = false;
await InvokeAsync(StateHasChanged);
return;
}
await AnnotationService.RejectAsync(EnvelopeKey, reason);
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to reject envelope {EnvelopeKey}", EnvelopeKey);
_rejectValidationMessage = "Die Ablehnung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.";
_isRejectSubmitting = false;
await InvokeAsync(StateHasChanged);
}
}
async Task<string?> GetActiveSignatureDataUrlAsync()
{
if (_activeSignatureTab == SignatureTabDraw)

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

@@ -1,105 +0,0 @@
@page "/envelope/{EnvelopeKey}/rejected"
@rendermode InteractiveServer
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Constants
@using EnvelopeGenerator.Domain.Interfaces
@using Microsoft.Extensions.Localization
@inject NavigationManager Navigation
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject IStringLocalizer<Resource> UiLocalizer
<div class="envelope-viewer-layout" style="justify-content: center; align-items: center; padding: 1.5rem;">
<div style="width: min(920px, 100%); background: rgba(255,255,255,0.96); border-radius: 20px; box-shadow: 0 18px 48px rgba(15,23,42,0.22); overflow: hidden;">
<div style="padding: 1.2rem 1.4rem; border-bottom: 1px solid rgba(15,23,42,0.08); background: linear-gradient(135deg, rgba(239,68,68,0.12) 0%, rgba(220,38,38,0.08) 100%);">
<div style="font-size: 1rem; font-weight: 700; color: #991b1b;">@L("DocRejected", "Dokument abgelehnt")</div>
</div>
<div style="padding: 2rem 1.5rem;">
<div style="display: flex; justify-content: center; margin-bottom: 1rem;">
<div style="width: 90px; height: 90px; border-radius: 999px; background: #fee2e2; display: inline-flex; align-items: center; justify-content: center; color: #b91c1c;">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
</div>
</div>
<h2 style="text-align: center; font-size: 1.4rem; color: #7f1d1d; margin-bottom: 0.75rem;">
@(_isReadAndConfirm
? L("RejectionInfo1Confirmation", "Dieses Dokument wurde als nicht bestaetigt markiert.")
: L("RejectionInfo1", "Dieses Dokument wurde abgelehnt."))
</h2>
<p style="text-align: center; margin: 0 auto; max-width: 760px; color: #374151; line-height: 1.6;">
@((MarkupString)_messageLine2)
</p>
<div style="margin-top: 1.4rem; text-align: center;">
<a href="/" class="btn btn-outline-secondary" style="font-weight: 600;">Portal Startseite</a>
</div>
</div>
</div>
</div>
@code {
[Parameter] public string? EnvelopeKey { get; set; }
bool _isReadAndConfirm;
string _messageLine2 = string.Empty;
protected override async Task OnInitializedAsync()
{
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
Navigation.NavigateTo("/");
return;
}
var user = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
if (user is null)
{
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
return;
}
var envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
if (envelopeReceiver is null)
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return;
}
if (!IsRejected(envelopeReceiver))
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return;
}
_isReadAndConfirm = envelopeReceiver.Envelope?.IsReadAndConfirm() ?? false;
var senderName = envelopeReceiver.Envelope?.User is null
? string.Empty
: $"{envelopeReceiver.Envelope.User.Prename} {envelopeReceiver.Envelope.User.Name}".Trim();
var senderEmail = envelopeReceiver.Envelope?.User?.Email ?? string.Empty;
var envelopeTitle = envelopeReceiver.Envelope?.Title ?? string.Empty;
var template = L("RejectionInfo2", "Das Dokument \"{2}\" von {0} ({1}) wurde abgelehnt.");
_messageLine2 = string.Format(template, senderName, senderEmail, envelopeTitle);
}
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
{
var status = envelopeReceiver.Envelope?.Status;
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
return true;
return envelopeReceiver.Envelope?.Histories?.Any(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
}
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -100,12 +100,11 @@ public class AnnotationController : ControllerBase
/// <summary>
/// Rejects the document for the current receiver.
/// </summary>
/// <param name="envelopeKey">Envelope key route parameter.</param>
/// <param name="reason">Optional rejection reason.</param>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("reject/{envelopeKey}")]
[HttpPost("reject")]
[Obsolete("Use MediatR")]
public async Task<IActionResult> Reject([FromRoute] string envelopeKey, [FromBody] string? reason = null)
public async Task<IActionResult> Reject([FromBody] string? reason = null)
{
var signature = User.ReceiverSignature();
var uuid = User.EnvelopeUuid();

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 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
}
/// <summary>
/// Creates a new read-only receiver for the current envelope.
/// Creates a new read-only receiver for an envelope.
/// </summary>
/// <param name="envelopeKey">Envelope key (uuid+signature encoded).</param>
/// <param name="createDto">Creation payload.</param>
[HttpPost]
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("{envelopeKey}")]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
[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();
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);

View File

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

View File

@@ -67,7 +67,7 @@ public static class ReceiverClaimExtensions
/// Gets the authenticated receiver email address from the claims.
/// </summary>
public static string ReceiverMail(this ClaimsPrincipal user)
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email, ClaimTypes.Email);
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email);
/// <summary>
/// Gets the authenticated envelope identifier from the claims.
@@ -120,4 +120,4 @@ public static class ReceiverClaimExtensions
{
return Role.Receiver.Authorized.Any(user.IsInRole);
}
}
}

View File

@@ -609,29 +609,6 @@ body.resizing {
cursor: not-allowed;
}
.pdf-toolbar__btn--reject {
display: flex;
align-items: center;
gap: 0.375rem;
min-width: auto;
padding: 0.5rem 0.75rem;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.08) 100%);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #b91c1c;
}
.pdf-toolbar__btn--reject:hover:not(:disabled) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-color: transparent;
color: #ffffff;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
.pdf-toolbar__btn--reject:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pdf-toolbar__btn-text {
font-size: 0.813rem;
font-weight: 600;
@@ -1234,18 +1211,6 @@ body.resizing {
color: #a5b4fc;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(248, 113, 113, 0.4);
color: #fca5a5;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.35);
border-color: rgba(248, 113, 113, 0.6);
color: #ffffff;
}
/* Error container */
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
background: rgba(239, 68, 68, 0.12);