Compare commits
8 Commits
feat/edit-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 940d129c11 | |||
| 44bbdbb835 | |||
| 223017006b | |||
| ad9250c7b1 | |||
| 5cc47ca9c7 | |||
| 156988403a | |||
| 380a5f4c0c | |||
| 0f53d0509e |
@@ -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>
|
||||||
|
|||||||
@@ -65,15 +65,18 @@ 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>();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
{
|
||||||
|
var entityName = typeof(T).Name;
|
||||||
|
|
||||||
|
var configuredTriggerNames = _triggers
|
||||||
|
.Where(t => t.Key == entityName)
|
||||||
.SelectMany(t => t.Value)
|
.SelectMany(t => t.Value)
|
||||||
.ForEach(tName =>
|
.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>();
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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}.
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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": [],
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user