Compare commits

..

10 Commits

Author SHA1 Message Date
96c7cd6e8a Add "Technical Debt TODOs" section to AGENTS.md
Added a new section in AGENTS.md to document technical debt tasks.
Outlined three specific areas for improvement:
- Refactor `EnvelopeSenderPage.razor` to reduce complexity.
- Move sender dashboard logic to dedicated services/extensions.
- Keep `EnvelopeSenderPage.razor` focused on UI/state orchestration.

These changes aim to improve code modularity, maintainability, and
separation of concerns.
2026-09-30 16:34:06 +02:00
4047ba7c6b Improve envelope status handling and UI enhancements
Refactored status handling to use `GetEffectiveEnvelopeStatus`
for better accuracy and historical context. Updated methods
like `IsEnvelopeSent`, `IsEnvelopeEditable`, and others to
use effective status instead of raw status.

Enhanced receiver status display by adding logic for rejected
receivers (`IsReceiverRejected`) and updated the UI to show
rejection badges with icons and labels. Improved badge
rendering to differentiate between signed, unsigned, and
rejected states.

Improved envelope categorization by ensuring rejected
envelopes are not shown in the active tab. Introduced
`GetEffectiveEnvelopeStatus` to centralize status logic.

Sanitized grid layout filters to prevent persistence of
filter-related properties, ensuring grids appear complete
when restoring layouts. Added utility methods for clearing
filter properties dynamically.

Added comments and refactored code for better readability
and maintainability.
2026-09-30 16:33:19 +02:00
897860b2bf Add rejection handling and dedicated page for envelopes
Introduced a new rejection flow in `ReceiverPage.razor` to detect
rejected envelopes and redirect users to a dedicated rejection
page (`ReceiverRejectedPage.razor`).

- Added `IsRejected` helper method to check envelope rejection
  status based on current status and history.
- Created `ReceiverRejectedPage.razor` to display rejection
  messages, sender details, and navigation options.
- Implemented localized strings for dynamic rejection messages.
- Designed a visually distinct layout for the rejection page.
- Improved user experience with clear messaging and proper
  handling of invalid or unauthorized access.
2026-09-30 14:21:09 +02:00
8933cff558 Add envelope rejection feature with popup UI
Introduced a new feature to allow users to reject envelopes with an optional reason. Added a `RejectAsync` method in `AnnotationService` to handle rejection requests. Updated `ReceiverPage.razor` to include a "Reject" button in the PDF toolbar and a rejection popup for user input.

Implemented state management for the rejection popup, including visibility, submission status, and validation messages. Updated `AnnotationController` to accept `envelopeKey` as a route parameter for the rejection endpoint. Modified `ReceiverClaimExtensions` to support multiple email claim types.

Added new CSS styles for the "Reject" button, including hover effects and dark theme support. Enhanced error handling and logging for the rejection process.
2026-09-30 14:03:56 +02:00
223017006b Update launch URLs in launchSettings.json
Updated the `applicationUrl` property in the "https (Blazor UI)"
profile of `launchSettings.json`. Changed the HTTPS port from
8089 to 8088 and the HTTP port from 5132 to 5131. This ensures
the development server uses the new port configuration and
avoids potential conflicts with other services.
2026-09-28 13:59:44 +02:00
ad9250c7b1 Merge branch 'master' of http://git.dd:3000/AppStd/EnvelopeGenerator
# Conflicts:
#	EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverSignedPage.razor
#	EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json
2026-09-28 13:53:40 +02:00
5cc47ca9c7 Add signed result page and update navigation flow
Introduced a new `/envelope/signed-result` page to confirm
successful document signing with localized messages and
a visually appealing UI. Updated `ReceiverSignedPage.razor`
to redirect users to the new page after signing.

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

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

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

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

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

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

View File

@@ -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)

View File

@@ -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; }
}
}

View File

@@ -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;
}
}
}

View File

@@ -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));

View File

@@ -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)

View File

@@ -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

View File

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

View File

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

View File

@@ -144,6 +144,14 @@
bool IsLoading;
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")

View File

@@ -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);
}
}
}

View File

@@ -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.

View File

@@ -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}");
}
}
}

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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()

View File

@@ -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();

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -67,7 +67,7 @@ public static class ReceiverClaimExtensions
/// Gets the authenticated receiver email address from the claims.
/// </summary>
public static string ReceiverMail(this ClaimsPrincipal user)
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email);
=> 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);
}
}
}

View File

@@ -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)

View File

@@ -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": [],

View File

@@ -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 */

View File

@@ -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);