Compare commits

..

8 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
223017006b Update launch URLs in launchSettings.json
Updated the `applicationUrl` property in the "https (Blazor UI)"
profile of `launchSettings.json`. Changed the HTTPS port from
8089 to 8088 and the HTTP port from 5132 to 5131. This ensures
the development server uses the new port configuration and
avoids potential conflicts with other services.
2026-09-28 13:59:44 +02:00
ad9250c7b1 Merge branch 'master' of http://git.dd:3000/AppStd/EnvelopeGenerator
# Conflicts:
#	EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverSignedPage.razor
#	EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json
2026-09-28 13:53:40 +02:00
5cc47ca9c7 Add signed result page and update navigation flow
Introduced a new `/envelope/signed-result` page to confirm
successful document signing with localized messages and
a visually appealing UI. Updated `ReceiverSignedPage.razor`
to redirect users to the new page after signing.

Enhanced styles in `app.css` for the signed result page,
including responsive layout, theme-specific designs, and
typography improvements. Added support for localization
using `IStringLocalizer` with fallback strings in German.

Improves user experience by providing clear feedback and
a polished confirmation page after signing.
2026-09-28 13:46:48 +02:00
156988403a Add envelope receiver access code request feature
Introduced a feature to allow envelope receivers to request an
access code via email on the login page.

- Added `OnInitializedAsync` in `LoginReceiverPage.razor` to
  trigger access code requests using `AuthService`.
- Implemented `RequestEnvelopeReceiverAccessCodeAsync` in
  `AuthService.cs` to handle API calls for access code requests.
- Added a new endpoint in `AuthController` to process access
  code requests, including validation, history recording, and
  email dispatching.
- Registered `IEnvelopeMailService` and `AddDispatcher` in
  `Program.cs` to support email dispatch and database operations.
- Refactored `AuthController` constructor for readability and
  added necessary `using` directives.
- Improved logging and error handling for better traceability
  and robustness.

These changes ensure a seamless and secure process for envelope
receivers to request access codes, enhancing the user experience.
2026-09-28 13:35:32 +02:00
380a5f4c0c Add signature submission feature and pipeline updates
Introduced a new feature for submitting signatures, including
backend and frontend changes. Added `SignatureSubmitDto` and
`SubmitAsync` in `SignatureService` to handle signature data
submission. Implemented a new `Submit` endpoint in
`SignatureController` to validate and process signature payloads.

Updated pipeline behaviors to include `SaveSignatureBehavior`
and adjusted the execution order. Enhanced query logic in
`EnvelopeReceiverResolutionBehavior` and `SaveSignatureBehavior`
to align with the updated data model.

Refactored `EGDbContext` to improve trigger handling and logging.
Updated `ReceiverSignedPage.razor` to integrate `SignatureService`
and handle submission errors gracefully. Adjusted application
URLs in `launchSettings.json` and added new database triggers
in `appsettings.json`.
2026-09-28 12:21:44 +02:00
0f53d0509e remove depr API 2026-09-28 09:51:51 +02:00
21 changed files with 809 additions and 38 deletions

View File

@@ -49,7 +49,7 @@ public sealed record Signature
public string? Position public string? Position
{ {
get => _position; get => _position;
init => _position = string.IsNullOrWhiteSpace(value) ? value : null; init => _position = string.IsNullOrWhiteSpace(value) ? null : value;
} }
/// <summary> /// <summary>
@@ -62,4 +62,4 @@ public sealed record Signature
/// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025" /// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025"
/// </summary> /// </summary>
public required string Place { get; init; } public required string Place { get; init; }
} }

View File

@@ -65,18 +65,21 @@ public static class DependencyInjection
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>(); cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
// 1. AnnotationBehavior - Saves annotations (executes second) // 1. AnnotationBehavior - Saves annotations (executes second)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SaveSignatureBehavior>();
// 2. AnnotationBehavior - Saves annotations for legacy web flow
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, AnnotationBehavior>(); cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, AnnotationBehavior>();
// 2. DocStatusBehavior - Creates document status (executes third) // 3. DocStatusBehavior - Creates document status
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>(); cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>();
// 3. HistoryBehavior - Records history (executes fourth) // 4. HistoryBehavior - Records history
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>(); cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>();
// 4. SendSignedMailBehavior - Sends notification email (executes LAST, only if all previous succeed) // 5. SendSignedMailBehavior - Sends notification email (executes LAST)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>(); cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
}); });
return services; return services;
} }
} }

View File

@@ -43,7 +43,11 @@ public class EnvelopeReceiverResolutionBehavior : IPipelineBehavior<SigningComma
// If EnvelopeReceiver is not provided, query it from database // If EnvelopeReceiver is not provided, query it from database
if (request.EnvelopeReceiver is null) if (request.EnvelopeReceiver is null)
{ {
var er = await _erRepo.Query.Where(request, notnull: true).SingleOrDefaultAsync(cancellationToken) var er = await _erRepo.Query
.Where(request, notnull: true)
.Include(x => x.Envelope)
.Include(x => x.Receiver)
.SingleOrDefaultAsync(cancellationToken)
?? throw new NotFoundException("EnvelopeReceiver not found"); ?? throw new NotFoundException("EnvelopeReceiver not found");
request.SetEnvelopeReceiver(_mapper.Map<EnvelopeReceiverDto>(er)); request.SetEnvelopeReceiver(_mapper.Map<EnvelopeReceiverDto>(er));

View File

@@ -50,8 +50,8 @@ public class SaveSignatureBehavior : IPipelineBehavior<SigningCommand, Unit>
throw new BadRequestException($"Signatures are required for saving signature behavior."); throw new BadRequestException($"Signatures are required for saving signature behavior.");
var elements = await _elementRepo var elements = await _elementRepo
.Where(e => e.Document.EnvelopeId == request.Envelope.Id) .Where(e => e.Document.EnvelopeId == request.EnvelopeReceiver.EnvelopeId)
.Where(e => e.ReceiverId == request.Receiver.Id) .Where(e => e.ReceiverId == request.EnvelopeReceiver.ReceiverId)
.ToListAsync(cancel); .ToListAsync(cancel);
foreach (var element in elements) foreach (var element in elements)

View File

@@ -204,16 +204,24 @@ public abstract class EGDbContextBase : DbContext
#region Trigger #region Trigger
// Configure entities to handle database triggers // Configure entities to handle database triggers
void AddTrigger<T>() where T : class => _triggers void AddTrigger<T>() where T : class
.Where(t => t.Key == typeof(T).Name) {
.SelectMany(t => t.Value) var entityName = typeof(T).Name;
.ForEach(tName =>
var configuredTriggerNames = _triggers
.Where(t => t.Key == entityName)
.SelectMany(t => t.Value)
.Distinct()
.ToList();
foreach (var triggerName in configuredTriggerNames)
{ {
#if NET #if NET
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(tName)); modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(triggerName));
#endif #endif
_logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", tName, typeof(T).Name); _logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", triggerName, entityName);
}); }
}
// TODO: call add trigger methods with attributes and reflection // TODO: call add trigger methods with attributes and reflection
AddTrigger<Config>(); AddTrigger<Config>();
@@ -243,4 +251,4 @@ public abstract class EGDbContextBase : DbContext
#if NETFRAMEWORK #if NETFRAMEWORK
} }
#endif #endif

View File

@@ -0,0 +1,10 @@
namespace EnvelopeGenerator.Server.Client.Models;
public sealed record SignatureSubmitDto
{
public required int Id { get; init; }
public required string DataUrl { get; init; }
public required string FullName { get; init; }
public string? Position { get; init; }
public required string Place { get; init; }
}

View File

@@ -0,0 +1,29 @@
@page "/envelope/signed-result"
@rendermode InteractiveWebAssembly
@using EnvelopeGenerator.Application.Resources
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<Resource> UiLocalizer
<div class="receiver-signed-result-page">
<div class="receiver-signed-result-card">
<div class="receiver-signed-result-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" fill="currentColor" viewBox="0 0 16 16">
<path d="M2.5 8a5.5 5.5 0 0 1 8.25-4.764.5.5 0 0 0 .5-.866A6.5 6.5 0 1 0 14.5 8a.5.5 0 0 0-1 0 5.5 5.5 0 1 1-11 0z" />
<path d="M15.354 3.354a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l7-7z" />
</svg>
</div>
<h1 class="receiver-signed-result-title">@L("DocumentSuccessfullySigned", "Dokument erfolgreich unterzeichnet")</h1>
<p class="receiver-signed-result-message">
@L("DocumentSignedConfirmationMessage", "Vielen Dank. Ihre Unterschrift wurde erfolgreich gespeichert und an die beteiligten Parteien übermittelt.")
</p>
</div>
</div>
@code {
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -144,6 +144,14 @@
bool IsLoading; bool IsLoading;
EnvelopeLoginResult? LoginResult; EnvelopeLoginResult? LoginResult;
protected override async Task OnInitializedAsync()
{
if (string.IsNullOrWhiteSpace(EnvelopeKey))
return;
_ = await AuthService.RequestEnvelopeReceiverAccessCodeAsync(EnvelopeKey);
}
async Task OnKeyDownAsync(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e) async Task OnKeyDownAsync(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e)
{ {
if (e.Key == "Enter") if (e.Key == "Enter")

View File

@@ -33,6 +33,20 @@ public class AuthService(IHttpClientFactory httpClientFactory)
return response.StatusCode == HttpStatusCode.OK; return response.StatusCode == HttpStatusCode.OK;
} }
/// <summary>
/// Triggers access code e-mail request flow for the receiver login page.
/// Calls POST /api/Auth/envelope-receiver/{key}/request-access-code.
/// </summary>
public async Task<bool> RequestEnvelopeReceiverAccessCodeAsync(string envelopeKey, CancellationToken cancel = default)
{
using var http = CreateDefaultClient();
var response = await http.PostAsync(
$"/api/Auth/envelope-receiver/{Uri.EscapeDataString(envelopeKey)}/request-access-code",
null,
cancel);
return response.IsSuccessStatusCode;
}
/// <summary> /// <summary>
/// Submits the access code for the given envelope key. /// Submits the access code for the given envelope key.
/// Calls POST /api/Auth/envelope-receiver/{key} with multipart/form-data. /// Calls POST /api/Auth/envelope-receiver/{key} with multipart/form-data.
@@ -72,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

@@ -1,6 +1,7 @@
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Server.Client.Models; using EnvelopeGenerator.Server.Client.Models;
namespace EnvelopeGenerator.Server.Client.Services; namespace EnvelopeGenerator.Server.Client.Services;
@@ -21,4 +22,32 @@ public class SignatureService(IHttpClientFactory httpClientFactory)
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel); var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
return result ?? []; return result ?? [];
} }
public async Task SubmitAsync(string envelopeKey, IEnumerable<SignatureSubmitDto> signatures, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var payload = signatures
.Select(signature => new Signature
{
Id = signature.Id,
DataUrl = signature.DataUrl,
FullName = signature.FullName,
Position = signature.Position,
Place = signature.Place
})
.ToList();
var response = await http.PostAsJsonAsync(
$"/api/Signature/submit/{Uri.EscapeDataString(envelopeKey)}",
payload,
cancel);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancel);
throw new HttpRequestException(
$"Failed to submit signature for envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}");
}
}
} }

View File

@@ -5,12 +5,18 @@
@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.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 EnvelopeGenerator.Server.Extensions
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService @inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@@ -19,6 +25,7 @@
@inject AppVersionService AppVersion @inject AppVersionService AppVersion
@inject IMemoryCache MemoryCache @inject IMemoryCache MemoryCache
@inject ILogger<ReceiverPage> Logger @inject ILogger<ReceiverPage> Logger
@inject IMediator Mediator
@implements IDisposable @implements IDisposable
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" /> <link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
@@ -125,6 +132,32 @@
<span class="pdf-toolbar__btn-text">Unterschreiben</span> <span class="pdf-toolbar__btn-text">Unterschreiben</span>
</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"
@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 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">@(_isLoggingOut ? "Abmelden..." : "Abmelden")</span>
</button>
</div> </div>
</div> </div>
@@ -342,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";
@@ -386,6 +470,114 @@
string _signerFullName = string.Empty; string _signerFullName = string.Empty;
string _signerPosition = string.Empty; string _signerPosition = string.Empty;
string _signaturePlace = string.Empty; 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))
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 ----- // ----- Lifecycle -----
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -397,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)
@@ -405,6 +603,12 @@
return; return;
} }
if (await Mediator.IsSignedAsync(_receiverUser.EnvelopeUuid(), _receiverUser.ReceiverSignature()))
{
Navigation.NavigateTo("/envelope/signed-result", forceLoad: true);
return;
}
try try
{ {
// Load PDF bytes via MediatR (uses authenticated user's claims) // Load PDF bytes via MediatR (uses authenticated user's claims)

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

@@ -14,6 +14,7 @@
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService @inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService @inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService @inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject SignatureService SignatureService
@inject AppVersionService AppVersion @inject AppVersionService AppVersion
@inject IMemoryCache MemoryCache @inject IMemoryCache MemoryCache
@inject ILogger<ReceiverSignedPage> Logger @inject ILogger<ReceiverSignedPage> Logger
@@ -125,6 +126,12 @@
</p> </p>
</div> </div>
</div> </div>
@if (!string.IsNullOrWhiteSpace(_submitErrorMessage))
{
<div class="alert alert-danger mb-0" role="alert" style="font-size: 0.85rem;">
@_submitErrorMessage
</div>
}
</BodyContentTemplate> </BodyContentTemplate>
<FooterContentTemplate> <FooterContentTemplate>
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;"> <div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
@@ -164,17 +171,48 @@
// ----- Submit / logout state ----- // ----- Submit / logout state -----
bool _isLoggingOut = false; bool _isLoggingOut = false;
bool _submitConfirmVisible = false; bool _submitConfirmVisible = false;
string? _submitErrorMessage;
void OpenSubmitConfirmPopup() => _submitConfirmVisible = true; void OpenSubmitConfirmPopup() => _submitConfirmVisible = true;
async Task SubmitAndLogoutAsync() async Task SubmitAndLogoutAsync()
{ {
if (_isLoggingOut) return; if (_isLoggingOut) return;
if (string.IsNullOrWhiteSpace(EnvelopeKey) || _sig is null)
return;
_isLoggingOut = true; _isLoggingOut = true;
_submitConfirmVisible = false; _submitConfirmVisible = false;
_submitErrorMessage = null;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey!);
Navigation.NavigateTo("/", forceLoad: true); try
{
if (_signatures.Count == 0)
throw new InvalidOperationException("No signature fields found for receiver.");
var payload = _signatures.Select(field => new SignatureSubmitDto
{
Id = field.Id,
DataUrl = _sig.DataUrl,
FullName = _sig.FullName,
Position = string.IsNullOrWhiteSpace(_sig.Position) ? null : _sig.Position,
Place = _sig.Place
}).ToList();
await SignatureService.SubmitAsync(EnvelopeKey, payload);
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
Navigation.NavigateTo("/envelope/signed-result", forceLoad: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to finalize signature for {EnvelopeKey}", EnvelopeKey);
_submitErrorMessage = "Die Unterschrift konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.";
_isLoggingOut = false;
await InvokeAsync(StateHasChanged);
}
} }
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()

View File

@@ -1,7 +1,10 @@
using DigitalData.Auth.Claims; using DigitalData.Auth.Claims;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Server.Controllers.Interfaces; using EnvelopeGenerator.Server.Controllers.Interfaces;
using EnvelopeGenerator.Server.Models; using EnvelopeGenerator.Server.Models;
using EnvelopeGenerator.Domain.Constants; using EnvelopeGenerator.Domain.Constants;
using MediatR;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -15,7 +18,9 @@ namespace EnvelopeGenerator.Server.Controllers;
/// </summary> /// </summary>
[Route("api/[controller]")] [Route("api/[controller]")]
[ApiController] [ApiController]
public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions, IAuthorizationService authService) : ControllerBase, IAuthController public partial class AuthController(
IOptions<AuthTokenKeys> authTokenKeyOptions,
IAuthorizationService authService) : ControllerBase, IAuthController
{ {
private readonly AuthTokenKeys authTokenKeys = authTokenKeyOptions.Value; private readonly AuthTokenKeys authTokenKeys = authTokenKeyOptions.Value;
@@ -82,6 +87,64 @@ public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions,
[HttpGet("check/envelope/{envelopeKey}")] [HttpGet("check/envelope/{envelopeKey}")]
public IActionResult CheckEnvelopeReceiver([FromRoute] string envelopeKey) => Ok(); public IActionResult CheckEnvelopeReceiver([FromRoute] string envelopeKey) => Ok();
/// <summary>
/// Sends the receiver access code e-mail on the first envelope login-page visit.
/// The operation is idempotent: if the code was already requested, it returns 200 without re-sending.
/// </summary>
/// <param name="key">Envelope receiver key.</param>
/// <response code="200">Access code is already available or requested successfully.</response>
/// <response code="404">Envelope receiver key was not found.</response>
/// <response code="500">Access code could not be persisted or e-mail could not be sent.</response>
[AllowAnonymous]
[HttpPost("envelope-receiver/{key}/request-access-code")]
public async Task<IActionResult> RequestEnvelopeReceiverAccessCode(
[FromRoute] string key,
[FromServices] IMediator mediator,
[FromServices] IEnvelopeHistoryService historyService,
[FromServices] IEnvelopeMailService mailService,
[FromServices] ILogger<AuthController> logger,
CancellationToken cancel)
{
var envelopeReceiver = (await mediator.Send(new ReadEnvelopeReceiverQuery { Key = key }, cancel)).SingleOrDefault();
if (envelopeReceiver is null || envelopeReceiver.Envelope is null || envelopeReceiver.Receiver is null)
return NotFound();
if (envelopeReceiver.Envelope.UseAccessCode != true)
return Ok();
var userReference = envelopeReceiver.Receiver.EmailAddress;
if (await historyService.IsSigned(envelopeReceiver.EnvelopeId, userReference))
return Ok();
var accessCodeAlreadyRequested = await historyService.AccessCodeAlreadyRequested(envelopeReceiver.EnvelopeId, userReference);
if (accessCodeAlreadyRequested)
return Ok();
var historyResult = await historyService.RecordAsync(envelopeReceiver.EnvelopeId, userReference, EnvelopeStatus.AccessCodeRequested);
if (historyResult.IsFailed)
{
logger.LogWarning(
"Failed to record AccessCodeRequested history for envelope {EnvelopeId} and receiver {ReceiverEmail}.",
envelopeReceiver.EnvelopeId,
userReference);
return StatusCode(StatusCodes.Status500InternalServerError);
}
var mailResult = await mailService.SendAccessCodeAsync(envelopeReceiver);
if (mailResult.IsFailed)
{
logger.LogWarning(
"Failed to send access code mail for envelope {EnvelopeId} and receiver {ReceiverEmail}.",
envelopeReceiver.EnvelopeId,
userReference);
return StatusCode(StatusCodes.Status500InternalServerError);
}
return Ok();
}
/// <summary> /// <summary>
/// Removes the per-envelope receiver cookie for the given envelope key. /// Removes the per-envelope receiver cookie for the given envelope key.
/// </summary> /// </summary>
@@ -108,4 +171,4 @@ public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions,
Response.Cookies.Delete(cookieName); Response.Cookies.Delete(cookieName);
return Ok(); return Ok();
} }
} }

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

@@ -1,7 +1,10 @@
using EnvelopeGenerator.Server.Extensions; using EnvelopeGenerator.Server.Extensions;
using EnvelopeGenerator.Application.Common.Dto; using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Extensions; using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.DocReceiverElements.Commands;
using EnvelopeGenerator.Application.Documents.Queries; using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Histories.Queries;
using EnvelopeGenerator.Domain.Constants; using EnvelopeGenerator.Domain.Constants;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -18,13 +21,15 @@ namespace EnvelopeGenerator.Server.Controllers;
public class SignatureController : ControllerBase public class SignatureController : ControllerBase
{ {
private readonly IMediator _mediator; private readonly IMediator _mediator;
private readonly ILogger<SignatureController> _logger;
/// <summary> /// <summary>
/// Initializes a new instance of <see cref="SignatureController"/>. /// Initializes a new instance of <see cref="SignatureController"/>.
/// </summary> /// </summary>
public SignatureController(IMediator mediator) public SignatureController(IMediator mediator, ILogger<SignatureController> logger)
{ {
_mediator = mediator; _mediator = mediator;
_logger = logger;
} }
//TODO: update to use signature query //TODO: update to use signature query
@@ -54,4 +59,46 @@ public class SignatureController : ControllerBase
else else
return Ok(rcvSignatures); return Ok(rcvSignatures);
} }
/// <summary>
/// Finalizes the receiver signing flow and persists signature data to TBSIG_DOCUMENT_RECEIVER_ELEMENT.
/// Also triggers status/history/mail side effects through SigningCommand pipeline.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("submit/{envelopeKey}")]
public async Task<IActionResult> Submit(
[FromRoute] string envelopeKey,
[FromBody] IEnumerable<Signature>? signatures,
CancellationToken cancel)
{
if (signatures is null)
return BadRequest("Signature payload is required.");
var uuid = User.EnvelopeUuid();
var receiverSignature = User.ReceiverSignature();
if (await _mediator.IsSignedAsync(uuid, receiverSignature, cancel))
return Problem(statusCode: StatusCodes.Status409Conflict);
if (await _mediator.AnyHistoryAsync(uuid, new[] { EnvelopeStatus.EnvelopeRejected, EnvelopeStatus.DocumentRejected }, cancel))
return Problem(statusCode: StatusCodes.Status423Locked);
try
{
await _mediator.Send(new SigningCommand
{
Envelope = new() { Uuid = uuid },
Receiver = new() { Signature = receiverSignature },
ReceiverAppType = ReceiverAppType.ReceiverUI,
Signatures = signatures.ToList()
}, cancel);
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to submit signatures for envelope {EnvelopeKey}", envelopeKey);
throw;
}
}
} }

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>

View File

@@ -25,6 +25,7 @@ using NLog.Web;
using NLog; using NLog;
using DigitalData.Auth.Claims; using DigitalData.Auth.Claims;
using EnvelopeGenerator.Server; using EnvelopeGenerator.Server;
using DigitalData.EmailProfilerDispatcher;
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger(); var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
logger.Info("EnvelopeGenerator.Server logging initialized!"); logger.Info("EnvelopeGenerator.Server logging initialized!");
@@ -323,6 +324,8 @@ try
.AddEnvelopeGeneratorServices(config); .AddEnvelopeGeneratorServices(config);
#pragma warning restore CS0618 #pragma warning restore CS0618
builder.Services.AddDispatcher<EGDbContext>();
// User Preferences (theme, dark mode, grid layouts — stored in TBDD_CACHE) // User Preferences (theme, dark mode, grid layouts — stored in TBDD_CACHE)
// Registration handled by Infrastructure DependencyInjection (AddEnvelopeGeneratorInfrastructureServices) // Registration handled by Infrastructure DependencyInjection (AddEnvelopeGeneratorInfrastructureServices)
@@ -346,6 +349,7 @@ try
builder.Services.AddScoped<EnvelopeReceiverService>(); builder.Services.AddScoped<EnvelopeReceiverService>();
builder.Services.AddScoped<SignatureService>(); builder.Services.AddScoped<SignatureService>();
builder.Services.AddScoped<SignatureCacheService>(); builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddScoped<EnvelopeGenerator.Application.Common.Interfaces.Services.IEnvelopeMailService, EnvelopeGenerator.Application.Services.EnvelopeMailService>();
builder.Services.AddSingleton<AppVersionService>(); builder.Services.AddSingleton<AppVersionService>();
// EnvelopeService with HttpClient factory (for SSR scenarios) // EnvelopeService with HttpClient factory (for SSR scenarios)

View File

@@ -185,9 +185,10 @@
"TimeLimit": "00:30:00" "TimeLimit": "00:30:00"
}, },
"DbTriggerParams": { "DbTriggerParams": {
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ], "DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ],
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"EnvelopeHistory": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ], "EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ], "EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
"Receiver": [], "Receiver": [],

View File

@@ -345,6 +345,83 @@ article {
font-weight: 500; font-weight: 500;
} }
/* Signed result page */
.receiver-signed-result-page {
min-height: calc(100vh - 36px);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
background: linear-gradient(160deg, #1e2e3e 0%, #2c3e50 40%, #3498db 100%);
}
.receiver-signed-result-card {
width: min(680px, 100%);
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 18px;
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.24);
padding: 2rem 1.5rem;
text-align: center;
}
.receiver-signed-result-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 92px;
height: 92px;
border-radius: 50%;
color: #0f8f58;
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
border: 1px solid #86efac;
margin-bottom: 1rem;
}
.receiver-signed-result-title {
margin: 0;
color: #1f2937;
font-size: 1.7rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.receiver-signed-result-message {
margin: 0.85rem auto 0;
max-width: 58ch;
color: #4b5563;
font-size: 1rem;
line-height: 1.62;
}
[data-sf-theme="purple"] .receiver-signed-result-page {
background: linear-gradient(160deg, #1a0533 0%, #3b0764 45%, #6b21a8 100%);
}
[data-sf-theme="blazing-dark"] .receiver-signed-result-page {
background: linear-gradient(160deg, #0a0f1e 0%, #111827 50%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .receiver-signed-result-card {
background: rgba(15, 20, 40, 0.94);
border-color: rgba(129, 140, 248, 0.28);
box-shadow: 0 20px 46px rgba(0, 0, 0, 0.45);
}
[data-sf-theme="blazing-dark"] .receiver-signed-result-title {
color: rgba(255, 255, 255, 0.92);
}
[data-sf-theme="blazing-dark"] .receiver-signed-result-message {
color: rgba(255, 255, 255, 0.68);
}
[data-sf-theme="blazing-dark"] .receiver-signed-result-icon {
color: #86efac;
background: rgba(16, 185, 129, 0.16);
border-color: rgba(110, 231, 183, 0.35);
}
/* ── Logos ───────────────────────────────────────────────────────────────── */ /* ── Logos ───────────────────────────────────────────────────────────────── */
/* Heights are controlled by inline style="height: Xpx" from appsettings.json */ /* Heights are controlled by inline style="height: Xpx" from appsettings.json */