Compare commits
10 Commits
e960c02cd4
...
feat/serve
| Author | SHA1 | Date | |
|---|---|---|---|
| 96c7cd6e8a | |||
| 4047ba7c6b | |||
| 897860b2bf | |||
| 8933cff558 | |||
| 223017006b | |||
| ad9250c7b1 | |||
| 5cc47ca9c7 | |||
| 156988403a | |||
| 380a5f4c0c | |||
| 0f53d0509e |
@@ -236,6 +236,12 @@ Manual testing workflow:
|
||||
3. Test sender login at `/sender/login`
|
||||
4. Test receiver flow at `/envelope/login/{envelopeKey}`
|
||||
|
||||
## Technical Debt TODOs
|
||||
|
||||
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor` to reduce complexity.
|
||||
- Move sender dashboard business/status logic into dedicated services and extension methods (for example effective status resolution, tab classification, receiver signed/rejected detection, and grid-layout filter sanitation).
|
||||
- Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component.
|
||||
|
||||
## Database
|
||||
|
||||
**SQL Server** (DD_ECM)
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed record Signature
|
||||
public string? Position
|
||||
{
|
||||
get => _position;
|
||||
init => _position = string.IsNullOrWhiteSpace(value) ? value : null;
|
||||
init => _position = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -62,4 +62,4 @@ public sealed record Signature
|
||||
/// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025"
|
||||
/// </summary>
|
||||
public required string Place { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,18 +65,21 @@ public static class DependencyInjection
|
||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
|
||||
|
||||
// 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>();
|
||||
|
||||
// 2. DocStatusBehavior - Creates document status (executes third)
|
||||
// 3. DocStatusBehavior - Creates document status
|
||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>();
|
||||
|
||||
// 3. HistoryBehavior - Records history (executes fourth)
|
||||
// 4. HistoryBehavior - Records history
|
||||
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>();
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,11 @@ public class EnvelopeReceiverResolutionBehavior : IPipelineBehavior<SigningComma
|
||||
// If EnvelopeReceiver is not provided, query it from database
|
||||
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");
|
||||
|
||||
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.");
|
||||
|
||||
var elements = await _elementRepo
|
||||
.Where(e => e.Document.EnvelopeId == request.Envelope.Id)
|
||||
.Where(e => e.ReceiverId == request.Receiver.Id)
|
||||
.Where(e => e.Document.EnvelopeId == request.EnvelopeReceiver.EnvelopeId)
|
||||
.Where(e => e.ReceiverId == request.EnvelopeReceiver.ReceiverId)
|
||||
.ToListAsync(cancel);
|
||||
|
||||
foreach (var element in elements)
|
||||
|
||||
@@ -204,16 +204,24 @@ public abstract class EGDbContextBase : DbContext
|
||||
|
||||
#region Trigger
|
||||
// Configure entities to handle database triggers
|
||||
void AddTrigger<T>() where T : class => _triggers
|
||||
.Where(t => t.Key == typeof(T).Name)
|
||||
.SelectMany(t => t.Value)
|
||||
.ForEach(tName =>
|
||||
void AddTrigger<T>() where T : class
|
||||
{
|
||||
var entityName = typeof(T).Name;
|
||||
|
||||
var configuredTriggerNames = _triggers
|
||||
.Where(t => t.Key == entityName)
|
||||
.SelectMany(t => t.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
foreach (var triggerName in configuredTriggerNames)
|
||||
{
|
||||
#if NET
|
||||
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(tName));
|
||||
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(triggerName));
|
||||
#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
|
||||
AddTrigger<Config>();
|
||||
@@ -243,4 +251,4 @@ public abstract class EGDbContextBase : DbContext
|
||||
|
||||
#if NETFRAMEWORK
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -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;
|
||||
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)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
|
||||
@@ -26,4 +26,24 @@ public class AnnotationService(IHttpClientFactory httpClientFactory)
|
||||
var result = await response.Content.ReadFromJsonAsync<List<AnnotationDto>>(_jsonOptions, cancel);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
public async Task RejectAsync(string envelopeKey, string? reason, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
|
||||
var response = await http.PostAsJsonAsync(
|
||||
$"/api/Annotation/reject/{Uri.EscapeDataString(envelopeKey)}",
|
||||
reason,
|
||||
_jsonOptions,
|
||||
cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancel);
|
||||
throw new HttpRequestException(
|
||||
$"Failed to reject envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,20 @@ public class AuthService(IHttpClientFactory httpClientFactory)
|
||||
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>
|
||||
/// Submits the access code for the given envelope key.
|
||||
/// Calls POST /api/Auth/envelope-receiver/{key} with multipart/form-data.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Server.Client.Models;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
@@ -21,4 +22,32 @@ public class SignatureService(IHttpClientFactory httpClientFactory)
|
||||
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@
|
||||
var envelope = cellContext.DataItem as EnvelopeDto;
|
||||
if (envelope != null)
|
||||
{
|
||||
var statusInfo = GetStatusInfo(envelope.Status);
|
||||
var statusInfo = GetStatusInfo(GetEffectiveEnvelopeStatus(envelope));
|
||||
<div class="status-badge status-badge--@statusInfo.CssClass">
|
||||
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
|
||||
@statusInfo.Label
|
||||
@@ -314,12 +314,20 @@
|
||||
{
|
||||
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
|
||||
@{
|
||||
var isRejected = IsReceiverRejected(envelope, receiver);
|
||||
var isSigned = IsReceiverSigned(envelope, receiver);
|
||||
var accessCode = receiver.AccessCode;
|
||||
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
|
||||
}
|
||||
<span class="receiver-badge receiver-badge--@(isSigned ? "signed" : "unsigned")" style="min-width: 100px;">
|
||||
@if (isSigned)
|
||||
<span class="receiver-badge receiver-badge--@(isRejected ? "unsigned" : (isSigned ? "signed" : "unsigned"))" style="min-width: 100px;">
|
||||
@if (isRejected)
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
|
||||
</svg>
|
||||
<span>@GetStatusLabel(EnvelopeStatus.DocumentRejected)</span>
|
||||
}
|
||||
else if (isSigned)
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
||||
@@ -590,10 +598,11 @@
|
||||
{
|
||||
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
|
||||
|
||||
// Split into active and completed based on status
|
||||
// Split into active and completed based on effective status
|
||||
// (history-aware: rejected envelopes should not stay in active tab).
|
||||
var envelopes = _allEnvelopes.ToList();
|
||||
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
|
||||
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
|
||||
_activeEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsActive()).ToList();
|
||||
_completedEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsCompleted()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -745,13 +754,13 @@
|
||||
|
||||
bool IsEnvelopeSent(EnvelopeDto envelope)
|
||||
{
|
||||
var status = (EnvelopeStatus)envelope.Status;
|
||||
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||
return status >= EnvelopeStatus.EnvelopeQueued;
|
||||
}
|
||||
|
||||
bool IsEnvelopeEditable(EnvelopeDto envelope)
|
||||
{
|
||||
var status = (EnvelopeStatus)envelope.Status;
|
||||
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||
return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved;
|
||||
}
|
||||
|
||||
@@ -800,7 +809,7 @@
|
||||
IReadOnlyList<StatusFilterItem> GetStatusFilterItems(IEnumerable<EnvelopeDto>? source)
|
||||
{
|
||||
return (source ?? Enumerable.Empty<EnvelopeDto>())
|
||||
.Select(x => x.Status)
|
||||
.Select(GetEffectiveEnvelopeStatus)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.Select(x => new StatusFilterItem(x, GetStatusLabel(x)))
|
||||
@@ -818,7 +827,7 @@
|
||||
|
||||
bool IsReceiverSigned(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
|
||||
{
|
||||
var status = (EnvelopeStatus)envelope.Status;
|
||||
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||
if (status is EnvelopeStatus.EnvelopeCompletelySigned or EnvelopeStatus.MessageCompletionSent)
|
||||
return true;
|
||||
|
||||
@@ -841,6 +850,43 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
bool IsReceiverRejected(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
|
||||
{
|
||||
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||
var histories = envelope.Histories?.Where(h =>
|
||||
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected).ToList() ?? [];
|
||||
|
||||
if (histories.Count == 0)
|
||||
return false;
|
||||
|
||||
if (histories.Any(h => h.Receiver?.Id == receiver.ReceiverId))
|
||||
return true;
|
||||
|
||||
var receiverEmail = receiver.Receiver?.EmailAddress;
|
||||
if (!string.IsNullOrWhiteSpace(receiverEmail) &&
|
||||
histories.Any(h => string.Equals(h.UserReference, receiverEmail, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(receiver.Name) &&
|
||||
histories.Any(h => string.Equals(h.UserReference, receiver.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
|
||||
return status == EnvelopeStatus.EnvelopeRejected && (envelope.EnvelopeReceivers?.Count() ?? 0) == 1;
|
||||
}
|
||||
|
||||
EnvelopeStatus GetEffectiveEnvelopeStatus(EnvelopeDto envelope)
|
||||
{
|
||||
var rawStatus = envelope.Status;
|
||||
|
||||
if (rawStatus is EnvelopeStatus.EnvelopeRejected or EnvelopeStatus.EnvelopeWithdrawn)
|
||||
return rawStatus;
|
||||
|
||||
var hasRejectedHistory = (envelope.Histories ?? Enumerable.Empty<HistoryDto>())
|
||||
.Any(h => h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected);
|
||||
|
||||
return hasRejectedHistory ? EnvelopeStatus.EnvelopeRejected : rawStatus;
|
||||
}
|
||||
|
||||
DateTime GetHistoryDate(HistoryDto history)
|
||||
{
|
||||
return history.ActionDate ?? history.AddedWhen;
|
||||
@@ -983,13 +1029,18 @@
|
||||
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
|
||||
var prefs = UserPreferencesService.GetCached();
|
||||
if (prefs is not null && prefs.GridLayouts.TryGetValue(CurrentGridKey, out var json))
|
||||
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
|
||||
{
|
||||
var layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
|
||||
SanitizeLayoutFilters(layout);
|
||||
e.Layout = layout;
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
SanitizeLayoutFilters(e.Layout);
|
||||
var json = JsonSerializer.Serialize(e.Layout);
|
||||
await UserPreferencesService.SaveGridLayoutAsync(CurrentGridKey, json);
|
||||
}
|
||||
@@ -999,5 +1050,55 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Keep column/order personalization, but never persist/restore data filters.
|
||||
// Tab counters are based on tab datasets; persisted grid filters can hide rows and
|
||||
// make the grid appear empty or incomplete.
|
||||
static void SanitizeLayoutFilters(GridPersistentLayout? layout)
|
||||
{
|
||||
if (layout is null)
|
||||
return;
|
||||
|
||||
ClearWritableProperty(layout, "FilterCriteria");
|
||||
ClearWritableProperty(layout, "SearchText");
|
||||
|
||||
if (layout.Columns is not System.Collections.IEnumerable columns)
|
||||
return;
|
||||
|
||||
foreach (var column in columns)
|
||||
{
|
||||
if (column is null)
|
||||
continue;
|
||||
|
||||
ClearWritableProperty(column, "FilterCriteria");
|
||||
ClearWritableProperty(column, "FilterValues");
|
||||
ClearWritableProperty(column, "FilterValue");
|
||||
ClearWritableProperty(column, "SearchText");
|
||||
}
|
||||
}
|
||||
|
||||
static void ClearWritableProperty(object target, string propertyName)
|
||||
{
|
||||
var prop = target.GetType()
|
||||
.GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)
|
||||
.FirstOrDefault(p => p.Name == propertyName && p.GetIndexParameters().Length == 0);
|
||||
|
||||
if (prop is null || !prop.CanWrite)
|
||||
return;
|
||||
|
||||
var propertyType = prop.PropertyType;
|
||||
object? value;
|
||||
|
||||
if (!propertyType.IsValueType || Nullable.GetUnderlyingType(propertyType) is not null)
|
||||
{
|
||||
value = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
value = Activator.CreateInstance(propertyType);
|
||||
}
|
||||
|
||||
prop.SetValue(target, value);
|
||||
}
|
||||
|
||||
private sealed record StatusFilterItem(EnvelopeStatus Value, string Text);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@using EnvelopeGenerator.Server.Client.Models.Constants
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
|
||||
@using EnvelopeGenerator.Domain.Constants
|
||||
@using Microsoft.JSInterop
|
||||
@using DevExpress.Blazor
|
||||
@using System.Drawing
|
||||
@@ -16,6 +17,7 @@
|
||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||
@inject AnnotationService AnnotationService
|
||||
@inject AppVersionService AppVersion
|
||||
@inject IMemoryCache MemoryCache
|
||||
@inject ILogger<ReceiverPage> Logger
|
||||
@@ -125,6 +127,17 @@
|
||||
<span class="pdf-toolbar__btn-text">Unterschreiben</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button class="pdf-toolbar__btn pdf-toolbar__btn--reject"
|
||||
@onclick="OpenRejectPopup"
|
||||
disabled="@_isRejectSubmitting"
|
||||
title="Ablehnen"
|
||||
style="flex-shrink: 0;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
|
||||
</svg>
|
||||
<span class="pdf-toolbar__btn-text">Ablehnen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -342,6 +355,75 @@
|
||||
</FooterContentTemplate>
|
||||
</DxPopup>
|
||||
|
||||
@* Rejection Popup *@
|
||||
<DxPopup @bind-Visible="_rejectPopupVisible"
|
||||
HeaderText="Dokument ablehnen"
|
||||
Width="500px"
|
||||
MaxWidth="95vw"
|
||||
ShowFooter="true"
|
||||
CloseOnOutsideClick="false"
|
||||
ShowCloseButton="false"
|
||||
CloseOnEscape="false">
|
||||
<BodyContentTemplate>
|
||||
<div style="display: flex; align-items: flex-start; gap: 0.9rem; padding: 0.2rem 0 0.8rem;">
|
||||
<div style="flex-shrink: 0; width: 36px; height: 36px; border-radius: 999px; background: #fee2e2; color: #b91c1c; display: inline-flex; align-items: center; justify-content: center;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style="min-width: 0;">
|
||||
<div style="font-size: 0.95rem; font-weight: 600; color: #1f2937; margin-bottom: 0.3rem;">
|
||||
Möchten Sie dieses Dokument wirklich ablehnen?
|
||||
</div>
|
||||
<div style="font-size: 0.84rem; color: #6b7280; line-height: 1.45;">
|
||||
Diese Aktion markiert den Umschlag als abgelehnt und informiert den Absender.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="rp-reject-reason" class="form-label" style="font-size: 0.875rem; font-weight: 500; color: #495057;">
|
||||
Grund <span style="color: #6c757d; font-weight: 400;">(optional)</span>
|
||||
</label>
|
||||
<textarea id="rp-reject-reason"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
maxlength="1000"
|
||||
value="@_rejectReason"
|
||||
@oninput="args => _rejectReason = args.Value?.ToString() ?? string.Empty"
|
||||
placeholder="Grund der Ablehnung"
|
||||
style="border: 2px solid #e9ecef; border-radius: 8px; padding: 0.625rem; resize: vertical;"></textarea>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_rejectValidationMessage))
|
||||
{
|
||||
<div style="background: #fee2e2; border-left: 4px solid #dc2626; padding: 0.75rem 1rem; margin-top: 1rem; border-radius: 4px;">
|
||||
<span style="color: #b91c1c; font-size: 0.875rem; font-weight: 500;">@_rejectValidationMessage</span>
|
||||
</div>
|
||||
}
|
||||
</BodyContentTemplate>
|
||||
<FooterContentTemplate>
|
||||
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
|
||||
<button class="btn btn-outline-secondary"
|
||||
@onclick="CloseRejectPopup"
|
||||
disabled="@_isRejectSubmitting"
|
||||
style="border-radius: 6px; padding: 0.625rem 1.25rem; font-weight: 500;">
|
||||
Zurück
|
||||
</button>
|
||||
<button class="btn btn-danger"
|
||||
@onclick="SubmitRejectAsync"
|
||||
disabled="@_isRejectSubmitting"
|
||||
style="border-radius: 6px; padding: 0.625rem 1.5rem; font-weight: 600;">
|
||||
@if (_isRejectSubmitting)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
}
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</FooterContentTemplate>
|
||||
</DxPopup>
|
||||
|
||||
@code {
|
||||
// ----- Constants -----
|
||||
const string SignatureTabDraw = "draw";
|
||||
@@ -387,6 +469,12 @@
|
||||
string _signerPosition = string.Empty;
|
||||
string _signaturePlace = string.Empty;
|
||||
|
||||
// ----- Rejection popup state -----
|
||||
bool _rejectPopupVisible;
|
||||
bool _isRejectSubmitting;
|
||||
string _rejectReason = string.Empty;
|
||||
string? _rejectValidationMessage;
|
||||
|
||||
// ----- Lifecycle -----
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -423,6 +511,11 @@
|
||||
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
|
||||
if (_envelopeReceiver is null)
|
||||
Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey);
|
||||
else if (IsRejected(_envelopeReceiver))
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/rejected", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build initial report (no signature image yet)
|
||||
_report = BuildReport(_pdfBytes, _signatures, capturedSignature: null);
|
||||
@@ -468,6 +561,16 @@
|
||||
}
|
||||
|
||||
// ----- Report builder -----
|
||||
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
|
||||
{
|
||||
var status = envelopeReceiver.Envelope?.Status;
|
||||
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
|
||||
return true;
|
||||
|
||||
return envelopeReceiver.Envelope?.Histories?.Any(h =>
|
||||
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an XtraReport wrapping the PDF bytes.
|
||||
/// If a signature is captured and there are signature fields, the signature image is
|
||||
@@ -701,6 +804,56 @@
|
||||
forceLoad: true);
|
||||
}
|
||||
|
||||
void OpenRejectPopup()
|
||||
{
|
||||
_rejectValidationMessage = null;
|
||||
_rejectPopupVisible = true;
|
||||
}
|
||||
|
||||
void CloseRejectPopup()
|
||||
{
|
||||
if (_isRejectSubmitting)
|
||||
return;
|
||||
|
||||
_rejectPopupVisible = false;
|
||||
}
|
||||
|
||||
async Task SubmitRejectAsync()
|
||||
{
|
||||
if (_isRejectSubmitting)
|
||||
return;
|
||||
|
||||
_isRejectSubmitting = true;
|
||||
_rejectValidationMessage = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
try
|
||||
{
|
||||
var reason = string.IsNullOrWhiteSpace(_rejectReason)
|
||||
? null
|
||||
: _rejectReason.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||
{
|
||||
_rejectValidationMessage = "Envelope-Schluessel fehlt.";
|
||||
_isRejectSubmitting = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
await AnnotationService.RejectAsync(EnvelopeKey, reason);
|
||||
|
||||
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to reject envelope {EnvelopeKey}", EnvelopeKey);
|
||||
_rejectValidationMessage = "Die Ablehnung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.";
|
||||
_isRejectSubmitting = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
async Task<string?> GetActiveSignatureDataUrlAsync()
|
||||
{
|
||||
if (_activeSignatureTab == SignatureTabDraw)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
@page "/envelope/{EnvelopeKey}/rejected"
|
||||
@rendermode InteractiveServer
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Domain.Constants
|
||||
@using EnvelopeGenerator.Domain.Interfaces
|
||||
@using Microsoft.Extensions.Localization
|
||||
@inject NavigationManager Navigation
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||
@inject IStringLocalizer<Resource> UiLocalizer
|
||||
|
||||
<div class="envelope-viewer-layout" style="justify-content: center; align-items: center; padding: 1.5rem;">
|
||||
<div style="width: min(920px, 100%); background: rgba(255,255,255,0.96); border-radius: 20px; box-shadow: 0 18px 48px rgba(15,23,42,0.22); overflow: hidden;">
|
||||
<div style="padding: 1.2rem 1.4rem; border-bottom: 1px solid rgba(15,23,42,0.08); background: linear-gradient(135deg, rgba(239,68,68,0.12) 0%, rgba(220,38,38,0.08) 100%);">
|
||||
<div style="font-size: 1rem; font-weight: 700; color: #991b1b;">@L("DocRejected", "Dokument abgelehnt")</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 2rem 1.5rem;">
|
||||
<div style="display: flex; justify-content: center; margin-bottom: 1rem;">
|
||||
<div style="width: 90px; height: 90px; border-radius: 999px; background: #fee2e2; display: inline-flex; align-items: center; justify-content: center; color: #b91c1c;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="text-align: center; font-size: 1.4rem; color: #7f1d1d; margin-bottom: 0.75rem;">
|
||||
@(_isReadAndConfirm
|
||||
? L("RejectionInfo1Confirmation", "Dieses Dokument wurde als nicht bestaetigt markiert.")
|
||||
: L("RejectionInfo1", "Dieses Dokument wurde abgelehnt."))
|
||||
</h2>
|
||||
|
||||
<p style="text-align: center; margin: 0 auto; max-width: 760px; color: #374151; line-height: 1.6;">
|
||||
@((MarkupString)_messageLine2)
|
||||
</p>
|
||||
|
||||
<div style="margin-top: 1.4rem; text-align: center;">
|
||||
<a href="/" class="btn btn-outline-secondary" style="font-weight: 600;">Portal Startseite</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? EnvelopeKey { get; set; }
|
||||
|
||||
bool _isReadAndConfirm;
|
||||
string _messageLine2 = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||
{
|
||||
Navigation.NavigateTo("/");
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
|
||||
if (user is null)
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
|
||||
return;
|
||||
}
|
||||
|
||||
var envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
|
||||
if (envelopeReceiver is null)
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsRejected(envelopeReceiver))
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
_isReadAndConfirm = envelopeReceiver.Envelope?.IsReadAndConfirm() ?? false;
|
||||
|
||||
var senderName = envelopeReceiver.Envelope?.User is null
|
||||
? string.Empty
|
||||
: $"{envelopeReceiver.Envelope.User.Prename} {envelopeReceiver.Envelope.User.Name}".Trim();
|
||||
var senderEmail = envelopeReceiver.Envelope?.User?.Email ?? string.Empty;
|
||||
var envelopeTitle = envelopeReceiver.Envelope?.Title ?? string.Empty;
|
||||
|
||||
var template = L("RejectionInfo2", "Das Dokument \"{2}\" von {0} ({1}) wurde abgelehnt.");
|
||||
_messageLine2 = string.Format(template, senderName, senderEmail, envelopeTitle);
|
||||
}
|
||||
|
||||
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
|
||||
{
|
||||
var status = envelopeReceiver.Envelope?.Status;
|
||||
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
|
||||
return true;
|
||||
|
||||
return envelopeReceiver.Envelope?.Histories?.Any(h =>
|
||||
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
|
||||
}
|
||||
|
||||
string L(string key, string fallback)
|
||||
{
|
||||
var value = UiLocalizer[key];
|
||||
return value.ResourceNotFound ? fallback : value.Value;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||
@inject SignatureService SignatureService
|
||||
@inject AppVersionService AppVersion
|
||||
@inject IMemoryCache MemoryCache
|
||||
@inject ILogger<ReceiverSignedPage> Logger
|
||||
@@ -125,6 +126,12 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(_submitErrorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mb-0" role="alert" style="font-size: 0.85rem;">
|
||||
@_submitErrorMessage
|
||||
</div>
|
||||
}
|
||||
</BodyContentTemplate>
|
||||
<FooterContentTemplate>
|
||||
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
|
||||
@@ -164,17 +171,48 @@
|
||||
// ----- Submit / logout state -----
|
||||
bool _isLoggingOut = false;
|
||||
bool _submitConfirmVisible = false;
|
||||
string? _submitErrorMessage;
|
||||
|
||||
void OpenSubmitConfirmPopup() => _submitConfirmVisible = true;
|
||||
|
||||
async Task SubmitAndLogoutAsync()
|
||||
{
|
||||
if (_isLoggingOut) return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(EnvelopeKey) || _sig is null)
|
||||
return;
|
||||
|
||||
_isLoggingOut = true;
|
||||
_submitConfirmVisible = false;
|
||||
_submitErrorMessage = null;
|
||||
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()
|
||||
|
||||
@@ -100,11 +100,12 @@ public class AnnotationController : ControllerBase
|
||||
/// <summary>
|
||||
/// Rejects the document for the current receiver.
|
||||
/// </summary>
|
||||
/// <param name="envelopeKey">Envelope key route parameter.</param>
|
||||
/// <param name="reason">Optional rejection reason.</param>
|
||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||
[HttpPost("reject")]
|
||||
[HttpPost("reject/{envelopeKey}")]
|
||||
[Obsolete("Use MediatR")]
|
||||
public async Task<IActionResult> Reject([FromBody] string? reason = null)
|
||||
public async Task<IActionResult> Reject([FromRoute] string envelopeKey, [FromBody] string? reason = null)
|
||||
{
|
||||
var signature = User.ReceiverSignature();
|
||||
var uuid = User.EnvelopeUuid();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using DigitalData.Auth.Claims;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||
using EnvelopeGenerator.Server.Controllers.Interfaces;
|
||||
using EnvelopeGenerator.Server.Models;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -15,7 +18,9 @@ namespace EnvelopeGenerator.Server.Controllers;
|
||||
/// </summary>
|
||||
[Route("api/[controller]")]
|
||||
[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;
|
||||
|
||||
@@ -82,6 +87,64 @@ public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions,
|
||||
[HttpGet("check/envelope/{envelopeKey}")]
|
||||
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>
|
||||
/// Removes the per-envelope receiver cookie for the given envelope key.
|
||||
/// </summary>
|
||||
@@ -108,4 +171,4 @@ public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions,
|
||||
Response.Cookies.Delete(cookieName);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using EnvelopeGenerator.Server.Extensions;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Extensions;
|
||||
using EnvelopeGenerator.Application.DocReceiverElements.Commands;
|
||||
using EnvelopeGenerator.Application.Documents.Queries;
|
||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||
using EnvelopeGenerator.Application.Histories.Queries;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -18,13 +21,15 @@ namespace EnvelopeGenerator.Server.Controllers;
|
||||
public class SignatureController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<SignatureController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="SignatureController"/>.
|
||||
/// </summary>
|
||||
public SignatureController(IMediator mediator)
|
||||
public SignatureController(IMediator mediator, ILogger<SignatureController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
//TODO: update to use signature query
|
||||
@@ -54,4 +59,46 @@ public class SignatureController : ControllerBase
|
||||
else
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ public static class ReceiverClaimExtensions
|
||||
/// Gets the authenticated receiver email address from the claims.
|
||||
/// </summary>
|
||||
public static string ReceiverMail(this ClaimsPrincipal user)
|
||||
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email);
|
||||
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email, ClaimTypes.Email);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the authenticated envelope identifier from the claims.
|
||||
@@ -120,4 +120,4 @@ public static class ReceiverClaimExtensions
|
||||
{
|
||||
return Role.Receiver.Authorized.Any(user.IsInRole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ using NLog.Web;
|
||||
using NLog;
|
||||
using DigitalData.Auth.Claims;
|
||||
using EnvelopeGenerator.Server;
|
||||
using DigitalData.EmailProfilerDispatcher;
|
||||
|
||||
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
|
||||
logger.Info("EnvelopeGenerator.Server logging initialized!");
|
||||
@@ -323,6 +324,8 @@ try
|
||||
.AddEnvelopeGeneratorServices(config);
|
||||
#pragma warning restore CS0618
|
||||
|
||||
builder.Services.AddDispatcher<EGDbContext>();
|
||||
|
||||
// User Preferences (theme, dark mode, grid layouts — stored in TBDD_CACHE)
|
||||
// Registration handled by Infrastructure DependencyInjection (AddEnvelopeGeneratorInfrastructureServices)
|
||||
|
||||
@@ -346,6 +349,7 @@ try
|
||||
builder.Services.AddScoped<EnvelopeReceiverService>();
|
||||
builder.Services.AddScoped<SignatureService>();
|
||||
builder.Services.AddScoped<SignatureCacheService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Application.Common.Interfaces.Services.IEnvelopeMailService, EnvelopeGenerator.Application.Services.EnvelopeMailService>();
|
||||
builder.Services.AddSingleton<AppVersionService>();
|
||||
|
||||
// EnvelopeService with HttpClient factory (for SSR scenarios)
|
||||
|
||||
@@ -185,9 +185,10 @@
|
||||
"TimeLimit": "00:30:00"
|
||||
},
|
||||
"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" ],
|
||||
"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" ],
|
||||
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
||||
"Receiver": [],
|
||||
|
||||
@@ -345,6 +345,83 @@ article {
|
||||
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 ───────────────────────────────────────────────────────────────── */
|
||||
/* Heights are controlled by inline style="height: Xpx" from appsettings.json */
|
||||
|
||||
|
||||
@@ -609,6 +609,29 @@ body.resizing {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pdf-toolbar__btn--reject {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: auto;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.08) 100%);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.pdf-toolbar__btn--reject:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
border-color: transparent;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.pdf-toolbar__btn--reject:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pdf-toolbar__btn-text {
|
||||
font-size: 0.813rem;
|
||||
font-weight: 600;
|
||||
@@ -1211,6 +1234,18 @@ body.resizing {
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.35);
|
||||
border-color: rgba(248, 113, 113, 0.6);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Error container */
|
||||
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
|
||||
Reference in New Issue
Block a user