Compare commits
21 Commits
feat/edit-
...
feat/custo
| Author | SHA1 | Date | |
|---|---|---|---|
| 33c75ea7e1 | |||
| 513efa0833 | |||
| 0c81d79a9b | |||
| 696cd1b623 | |||
| 2434f67627 | |||
| 776700b247 | |||
| 7a114f47a6 | |||
| ed44d841e7 | |||
| 4bd0cc2e94 | |||
| 8a4963472c | |||
| 4d3f72a3ed | |||
| 1ebd43e3b0 | |||
| 19aeda5aef | |||
| 940d129c11 | |||
| 44bbdbb835 | |||
| 223017006b | |||
| ad9250c7b1 | |||
| 5cc47ca9c7 | |||
| 156988403a | |||
| 380a5f4c0c | |||
| 0f53d0509e |
16
EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs
Normal file
16
EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
public sealed class PdfPageImageDto
|
||||
{
|
||||
public int PageNumber { get; init; }
|
||||
|
||||
public double WidthPt { get; init; }
|
||||
|
||||
public double HeightPt { get; init; }
|
||||
|
||||
public int WidthPx { get; init; }
|
||||
|
||||
public int HeightPx { get; init; }
|
||||
|
||||
public string ImageDataUrl { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
public sealed class PdfRenderRequestDto
|
||||
{
|
||||
public byte[]? PdfBytes { get; init; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
|
||||
public interface IPdfPageImageRenderer
|
||||
{
|
||||
Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -735,7 +735,9 @@
|
||||
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Fehler beim Laden der Datei: {0}</value></data>
|
||||
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Bitte unterzeichnen Sie das beigefügte Dokument.</value></data>
|
||||
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Kein Dokument geladen</value></data>
|
||||
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Keine Seiten zur Anzeige verfügbar.</value></data>
|
||||
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Noch keine Empfänger hinzugefügt.</value></data>
|
||||
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Seite</value></data>
|
||||
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Signaturfeld platzieren</value></data>
|
||||
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Alle Signaturfelder entfernen</value></data>
|
||||
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Bitte wählen Sie eine PDF-Datei aus.</value></data>
|
||||
|
||||
@@ -735,7 +735,9 @@
|
||||
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Error loading file: {0}</value></data>
|
||||
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Please sign the attached document.</value></data>
|
||||
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>No document loaded</value></data>
|
||||
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>No pages to display.</value></data>
|
||||
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>No receivers added yet.</value></data>
|
||||
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
|
||||
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Place signature field</value></data>
|
||||
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Remove all signature fields</value></data>
|
||||
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Please select a PDF file.</value></data>
|
||||
|
||||
@@ -735,7 +735,9 @@
|
||||
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Erreur lors du chargement du fichier : {0}</value></data>
|
||||
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Veuillez signer le document ci-joint.</value></data>
|
||||
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Aucun document chargé</value></data>
|
||||
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Aucune page à afficher.</value></data>
|
||||
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Aucun destinataire ajouté pour le moment.</value></data>
|
||||
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
|
||||
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Placer un champ de signature</value></data>
|
||||
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Supprimer tous les champs de signature</value></data>
|
||||
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Veuillez sélectionner un fichier PDF.</value></data>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DevExpress.Document.Processor" Version="25.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
57
EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs
Normal file
57
EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using DevExpress.Drawing;
|
||||
using DevExpress.Pdf;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Doc;
|
||||
|
||||
public class PdfPageImageRenderer : IPdfPageImageRenderer
|
||||
{
|
||||
private const float RenderDpi = 110f;
|
||||
|
||||
public Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pdfBytes);
|
||||
|
||||
if (pdfBytes.Length == 0)
|
||||
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(Array.Empty<PdfPageImageDto>());
|
||||
|
||||
using var stream = new MemoryStream(pdfBytes, writable: false);
|
||||
using var processor = new PdfDocumentProcessor();
|
||||
processor.LoadDocument(stream);
|
||||
|
||||
var pages = processor.Document.Pages;
|
||||
if (pages.Count == 0)
|
||||
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(Array.Empty<PdfPageImageDto>());
|
||||
|
||||
var renderOptions = PdfPageRenderingParameters.CreateWithResolution(RenderDpi);
|
||||
var results = new List<PdfPageImageDto>(pages.Count);
|
||||
|
||||
for (var i = 0; i < pages.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var pageNumber = i + 1;
|
||||
var page = pages[i];
|
||||
var cropBox = page.CropBox;
|
||||
|
||||
using DXBitmap bitmap = processor.CreateDXBitmap(pageNumber, renderOptions);
|
||||
using var imageStream = new MemoryStream();
|
||||
bitmap.Save(imageStream, DXImageFormat.Png);
|
||||
|
||||
var dataUrl = $"data:image/png;base64,{Convert.ToBase64String(imageStream.ToArray())}";
|
||||
|
||||
results.Add(new PdfPageImageDto
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
WidthPt = cropBox.Width,
|
||||
HeightPt = cropBox.Height,
|
||||
WidthPx = bitmap.Width,
|
||||
HeightPx = bitmap.Height,
|
||||
ImageDataUrl = dataUrl
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(results);
|
||||
}
|
||||
}
|
||||
@@ -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,7 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
|
||||
|
||||
public sealed record SenderPdfFieldMoveRequest(
|
||||
int Index,
|
||||
int Page,
|
||||
double XPt,
|
||||
double YPt);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
|
||||
|
||||
public sealed record SenderPdfFieldOverlay(
|
||||
int Index,
|
||||
int Page,
|
||||
double XPt,
|
||||
double YPt,
|
||||
string ReceiverName,
|
||||
string Color);
|
||||
@@ -0,0 +1,204 @@
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Application.Common.Dto
|
||||
@using Microsoft.Extensions.Localization
|
||||
@inject IStringLocalizer<Resource> UiLocalizer
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<div class="pdf-editor-wrapper sender-image-editor sender-editor-pdf-viewer"
|
||||
style="cursor: @(IsPlacementMode ? "crosshair" : (_isDragging ? "grabbing" : "default"));"
|
||||
@onclick="HandleClickAsync"
|
||||
@onmousemove="OnEditorMouseMoveAsync"
|
||||
@onmouseup="OnEditorMouseUp"
|
||||
@onmouseleave="OnEditorMouseUp">
|
||||
@if (PageCount <= 0)
|
||||
{
|
||||
<div class="sender-image-editor__empty">@L("SenderEditor_NoPagesToDisplay", "Keine Seiten zur Anzeige verfügbar.")</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (Pages.Count == 0 && !string.IsNullOrWhiteSpace(PdfDataUrl))
|
||||
{
|
||||
<div class="sender-image-editor__native-preview">
|
||||
<iframe class="sender-image-editor__native-frame"
|
||||
src="@PdfDataUrl"
|
||||
title="PDF Preview"></iframe>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="sender-image-editor__pages">
|
||||
@for (var pageIndex = 0; pageIndex < PageCount; pageIndex++)
|
||||
{
|
||||
var pagePreview = pageIndex < Pages.Count ? Pages[pageIndex] : null;
|
||||
var pageNumber = pagePreview?.PageNumber ?? (pageIndex + 1);
|
||||
var widthPt = pagePreview?.WidthPt ?? 595.0;
|
||||
var heightPt = pagePreview?.HeightPt ?? 842.0;
|
||||
|
||||
<div class="sender-image-editor__page-block"
|
||||
data-editor-page="@pageNumber"
|
||||
data-page-width-pt="@widthPt"
|
||||
data-page-height-pt="@heightPt">
|
||||
<div class="sender-image-editor__page-canvas"
|
||||
data-editor-page-canvas="@pageNumber"
|
||||
style="@( _isDragging ? "cursor:grabbing;" : string.Empty)">
|
||||
@if (!string.IsNullOrWhiteSpace(pagePreview?.ImageDataUrl))
|
||||
{
|
||||
<img class="sender-image-editor__page-image"
|
||||
src="@(pagePreview!.ImageDataUrl)"
|
||||
alt="PDF Page @pageNumber"
|
||||
draggable="false" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="sender-image-editor__page-placeholder">@L("SenderEditor_PageLabel", "Seite") @pageNumber</div>
|
||||
}
|
||||
|
||||
@foreach (var field in Fields.Where(f => f.Page == pageNumber))
|
||||
{
|
||||
<div class="sender-image-editor__field"
|
||||
style="@GetFieldStyle(field, widthPt, heightPt)"
|
||||
title="@field.ReceiverName"
|
||||
@onmousedown="args => OnFieldMouseDownAsync(args, field)"
|
||||
@onmousedown:preventDefault="true"
|
||||
@onclick:stopPropagation="true">
|
||||
<span class="sender-image-editor__field-label">@field.ReceiverName</span>
|
||||
<button type="button"
|
||||
class="sender-image-editor__field-remove"
|
||||
title='@L("Delete", "Löschen")'
|
||||
draggable="false"
|
||||
@onclick:stopPropagation="true"
|
||||
@onclick="() => RemoveField.InvokeAsync(field.Index)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public bool IsPlacementMode { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int PageCount { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<PdfPageImageDto> Pages { get; set; } = Array.Empty<PdfPageImageDto>();
|
||||
|
||||
[Parameter]
|
||||
public string? PdfDataUrl { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<SenderPdfFieldOverlay> Fields { get; set; } = Array.Empty<SenderPdfFieldOverlay>();
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<MouseEventArgs> EditorClick { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> RemoveField { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<SenderPdfFieldMoveRequest> MoveField { get; set; }
|
||||
|
||||
private int? _draggedFieldIndex;
|
||||
private bool _isDragging;
|
||||
private double _grabOffsetXPt;
|
||||
private double _grabOffsetYPt;
|
||||
|
||||
private static string GetFieldStyle(SenderPdfFieldOverlay field, double pageWidthPt, double pageHeightPt)
|
||||
{
|
||||
var leftPercent = (field.XPt / pageWidthPt) * 100.0;
|
||||
var topPercent = (field.YPt / pageHeightPt) * 100.0;
|
||||
var widthPercent = (127.44 / pageWidthPt) * 100.0;
|
||||
var heightPercent = (141.12 / pageHeightPt) * 100.0;
|
||||
|
||||
return string.Join(' ',
|
||||
$"left:{leftPercent:F4}%;",
|
||||
$"top:{topPercent:F4}%;",
|
||||
$"width:{widthPercent:F4}%;",
|
||||
$"height:{heightPercent:F4}%;",
|
||||
$"border-color:{field.Color};",
|
||||
$"background-color:{field.Color}22;");
|
||||
}
|
||||
|
||||
private async Task HandleClickAsync(MouseEventArgs args)
|
||||
{
|
||||
if (EditorClick.HasDelegate)
|
||||
await EditorClick.InvokeAsync(args);
|
||||
}
|
||||
|
||||
private async Task OnFieldMouseDownAsync(MouseEventArgs args, SenderPdfFieldOverlay field)
|
||||
{
|
||||
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
|
||||
"envelopeEditor.getClickCoordsOnPdfPage",
|
||||
"sender-editor-pdf-viewer",
|
||||
args.ClientX,
|
||||
args.ClientY);
|
||||
|
||||
if (coords is null)
|
||||
return;
|
||||
|
||||
var pageNumber = coords.PageNumber;
|
||||
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
|
||||
var pointerXPt = coords.NormX * pageWidthPt;
|
||||
var pointerYPt = coords.NormY * pageHeightPt;
|
||||
|
||||
_draggedFieldIndex = field.Index;
|
||||
_isDragging = true;
|
||||
_grabOffsetXPt = pointerXPt - field.XPt;
|
||||
_grabOffsetYPt = pointerYPt - field.YPt;
|
||||
}
|
||||
|
||||
private async Task OnEditorMouseMoveAsync(MouseEventArgs args)
|
||||
{
|
||||
if (_draggedFieldIndex is null || !MoveField.HasDelegate)
|
||||
return;
|
||||
|
||||
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
|
||||
"envelopeEditor.getClickCoordsOnPdfPage",
|
||||
"sender-editor-pdf-viewer",
|
||||
args.ClientX,
|
||||
args.ClientY);
|
||||
|
||||
if (coords is null)
|
||||
return;
|
||||
|
||||
var pageNumber = coords.PageNumber;
|
||||
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
|
||||
|
||||
const double sigWidthPt = 1.77 * 72;
|
||||
const double sigHeightPt = 1.96 * 72;
|
||||
|
||||
var xPt = (coords.NormX * pageWidthPt) - _grabOffsetXPt;
|
||||
var yPt = (coords.NormY * pageHeightPt) - _grabOffsetYPt;
|
||||
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - sigWidthPt));
|
||||
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - sigHeightPt));
|
||||
|
||||
await MoveField.InvokeAsync(new SenderPdfFieldMoveRequest(_draggedFieldIndex.Value, pageNumber, xPt, yPt));
|
||||
}
|
||||
|
||||
private void OnEditorMouseUp(MouseEventArgs _)
|
||||
{
|
||||
_draggedFieldIndex = null;
|
||||
_isDragging = false;
|
||||
}
|
||||
|
||||
private (double WidthPt, double HeightPt) GetPageSize(int pageNumber)
|
||||
{
|
||||
var page = Pages.FirstOrDefault(p => p.PageNumber == pageNumber);
|
||||
return page is null ? (595.0, 842.0) : (page.WidthPt, page.HeightPt);
|
||||
}
|
||||
|
||||
private sealed record EditorCoords(double NormX, double NormY, int PageIndex, int PageNumber);
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
var value = UiLocalizer[key];
|
||||
return value.ResourceNotFound ? fallback : value.Value;
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,8 @@ builder.Services.AddScoped<SignatureCacheService>();
|
||||
builder.Services.AddSingleton<AppVersionService>();
|
||||
builder.Services.AddScoped<EnvelopeService>();
|
||||
builder.Services.AddScoped<LocalizationService>();
|
||||
builder.Services.AddScoped<ReceiverService>();
|
||||
builder.Services.AddScoped<PdfRenderService>();
|
||||
|
||||
// UI Preferences & Theme
|
||||
builder.Services.AddScoped<UserPreferencesService>();
|
||||
|
||||
@@ -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.
|
||||
@@ -72,6 +86,18 @@ public class AuthService(IHttpClientFactory httpClientFactory)
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shares the current envelope in read-only mode for the specified receiver and expiration date.
|
||||
/// Calls POST /api/readonly/{envelopeKey}.
|
||||
/// </summary>
|
||||
public async Task<bool> ShareEnvelopeReadOnlyAsync(string envelopeKey, string receiverMail, DateTime dateValid, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = CreateDefaultClient();
|
||||
var payload = new { receiverMail, dateValid };
|
||||
var response = await http.PostAsJsonAsync($"/api/readonly/{Uri.EscapeDataString(envelopeKey)}", payload, cancel);
|
||||
return response.IsSuccessStatusCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the per-envelope receiver cookie for the given envelope key.
|
||||
/// Calls POST /api/auth/logout/envelope/{envelopeKey}.
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public class DocumentService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the PDF bytes for the given envelope key from the API.
|
||||
@@ -29,4 +33,26 @@ public class DocumentService(IHttpClientFactory httpClientFactory)
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancel);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches sender document details (including signature elements) by envelope id.
|
||||
/// Uses GET /api/Document?EnvelopeId={envelopeId}.
|
||||
/// </summary>
|
||||
public async Task<DocumentDto?> GetSenderDocumentDetailsByEnvelopeIdAsync(int envelopeId, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var response = await http.GetAsync($"/api/Document?EnvelopeId={envelopeId}", cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||
throw new HttpRequestException(
|
||||
$"Failed to load sender document details. Status: {statusCode} ({reasonPhrase})",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<DocumentDto>(_jsonOptions, cancel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public class PdfRenderService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsync(byte[] pdfBytes, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
"/api/PdfRender/pages",
|
||||
new PdfRenderRequestDto { PdfBytes = pdfBytes },
|
||||
_jsonOptions,
|
||||
cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||
throw new HttpRequestException(
|
||||
$"Failed to render PDF pages. Status: {statusCode} ({reasonPhrase})",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<List<PdfPageImageDto>>(_jsonOptions, cancel) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public class ReceiverService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches receiver email suggestions from sender-authorized API.
|
||||
/// Calls GET /api/Receiver?EmailAddressSearch={term}&onlyEmailAddress=true.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> SearchReceiverEmailsAsync(string searchTerm, CancellationToken cancel = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchTerm))
|
||||
return [];
|
||||
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var url = $"/api/Receiver?EmailAddressSearch={Uri.EscapeDataString(searchTerm)}&onlyEmailAddress=true";
|
||||
var response = await http.GetAsync(url, cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var emails = await response.Content.ReadFromJsonAsync<List<string>>(JsonOptions, cancel);
|
||||
return emails ?? [];
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using EnvelopeGenerator.Server.Client.Models
|
||||
@using EnvelopeGenerator.Server.Client.Options
|
||||
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
|
||||
@using DevExpress.Blazor
|
||||
@using DevExpress.Blazor.PdfViewer
|
||||
@using DevExpress.Blazor.Reporting
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@page "/sender/editor"
|
||||
@page "/sender/editorL"
|
||||
@rendermode InteractiveServer
|
||||
@using DevExpress.Blazor.PdfViewer
|
||||
@using DevExpress.Blazor.Reporting.Models
|
||||
@@ -543,7 +543,7 @@
|
||||
var editQuery = EnvelopeId.HasValue && EnvelopeId.Value > 0
|
||||
? $"&envelopeId={EnvelopeId.Value}"
|
||||
: string.Empty;
|
||||
NavigationManager.NavigateTo($"/sender/editor?esid={sid}{editQuery}", forceLoad: false);
|
||||
NavigationManager.NavigateTo($"/sender/editorL?esid={sid}{editQuery}", forceLoad: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,7 +837,7 @@
|
||||
if (previousEnvelopeId != result.Id)
|
||||
{
|
||||
var sid = string.IsNullOrWhiteSpace(Esid) ? Guid.NewGuid().ToString("N") : Esid;
|
||||
NavigationManager.NavigateTo($"/sender/editor?esid={sid}&envelopeId={result.Id}", forceLoad: false);
|
||||
NavigationManager.NavigateTo($"/sender/editorL?esid={sid}&envelopeId={result.Id}", forceLoad: false);
|
||||
}
|
||||
|
||||
NavigationManager.NavigateTo("/sender", forceLoad: false);
|
||||
@@ -5,12 +5,18 @@
|
||||
@using EnvelopeGenerator.Server.Client.Models
|
||||
@using EnvelopeGenerator.Server.Client.Models.Constants
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using EnvelopeGenerator.Application.Common.Extensions
|
||||
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
|
||||
@using EnvelopeGenerator.Application.EnvelopeReceivers.Queries
|
||||
@using EnvelopeGenerator.Domain.Constants
|
||||
@using Microsoft.JSInterop
|
||||
@using DevExpress.Blazor
|
||||
@using System.Drawing
|
||||
@using System.Security.Claims
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Microsoft.Extensions.Caching.Memory
|
||||
@using MediatR
|
||||
@using EnvelopeGenerator.Server.Extensions
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||
@@ -19,6 +25,7 @@
|
||||
@inject AppVersionService AppVersion
|
||||
@inject IMemoryCache MemoryCache
|
||||
@inject ILogger<ReceiverPage> Logger
|
||||
@inject IMediator Mediator
|
||||
@implements IDisposable
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
@@ -125,6 +132,32 @@
|
||||
<span class="pdf-toolbar__btn-text">Unterschreiben</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (!(_envelopeReceiver?.Envelope?.ReadOnly ?? false))
|
||||
{
|
||||
<button class="pdf-toolbar__btn"
|
||||
@onclick="OpenSharePopup"
|
||||
title="Dokument teilen"
|
||||
style="flex-shrink: 0;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M13.5 1a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2zm-11 5a1.5 1.5 0 1 0 1.415 2H4a1.5 1.5 0 0 0-1.5-2zm11 5a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2z"/>
|
||||
<path d="M2.854 7.646a.5.5 0 0 1 .0-.707l9-5a.5.5 0 1 1 .486.874l-9 5a.5.5 0 0 1-.486-.167zm0 .708a.5.5 0 0 1 .486-.167l9 5a.5.5 0 0 1-.486.874l-9-5a.5.5 0 0 1 0-.707z"/>
|
||||
</svg>
|
||||
<span class="pdf-toolbar__btn-text">Teilen</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button class="pdf-toolbar__btn"
|
||||
@onclick="LogoutAsync"
|
||||
disabled="@_isLoggingOut"
|
||||
title="Abmelden"
|
||||
style="flex-shrink: 0;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M10 15a1 1 0 0 0 1-1v-2a.5.5 0 0 1 1 0v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v2a.5.5 0 0 1-1 0V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h7z" />
|
||||
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L14.293 7.5H6.5a.5.5 0 0 0 0 1h7.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z" />
|
||||
</svg>
|
||||
<span class="pdf-toolbar__btn-text">@(_isLoggingOut ? "Abmelden..." : "Abmelden")</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -342,6 +375,57 @@
|
||||
</FooterContentTemplate>
|
||||
</DxPopup>
|
||||
|
||||
<DxPopup @bind-Visible="_sharePopupVisible"
|
||||
HeaderText="Dokument teilen"
|
||||
Width="520px"
|
||||
MaxWidth="95vw"
|
||||
ShowFooter="true"
|
||||
CloseOnOutsideClick="true">
|
||||
<BodyContentTemplate>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="rp-share-mail" style="font-size: 0.9rem; font-weight: 500;">E-Mail</label>
|
||||
<input id="rp-share-mail"
|
||||
class="form-control"
|
||||
placeholder="user@mail.com"
|
||||
value="@_shareReceiverMail"
|
||||
@oninput="args => _shareReceiverMail = args.Value?.ToString() ?? string.Empty" />
|
||||
</div>
|
||||
|
||||
<div class="mb-1">
|
||||
<label class="form-label" for="rp-share-date" style="font-size: 0.9rem; font-weight: 500;">Gultig bis</label>
|
||||
<input id="rp-share-date"
|
||||
class="form-control"
|
||||
type="date"
|
||||
min="@DateTime.Today.AddDays(1).ToString("yyyy-MM-dd")"
|
||||
max="@DateTime.Today.AddDays(90).ToString("yyyy-MM-dd")"
|
||||
value="@_shareDateValid.ToString("yyyy-MM-dd")"
|
||||
@oninput="OnShareDateChanged" />
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_shareMessage))
|
||||
{
|
||||
<div class="mt-3 alert @(_shareMessageIsError ? "alert-danger" : "alert-success") mb-0" role="alert">
|
||||
@_shareMessage
|
||||
</div>
|
||||
}
|
||||
</BodyContentTemplate>
|
||||
<FooterContentTemplate>
|
||||
<div class="d-flex justify-content-end gap-2 w-100">
|
||||
<button class="btn btn-outline-secondary"
|
||||
@onclick="CloseSharePopup"
|
||||
disabled="@_isSharing">
|
||||
Schliessen
|
||||
</button>
|
||||
<button class="btn btn-primary"
|
||||
@onclick="ShareEnvelopeAsync"
|
||||
disabled="@_isSharing"
|
||||
style="background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%); border: none;">
|
||||
@(_isSharing ? "Senden..." : "Senden")
|
||||
</button>
|
||||
</div>
|
||||
</FooterContentTemplate>
|
||||
</DxPopup>
|
||||
|
||||
@code {
|
||||
// ----- Constants -----
|
||||
const string SignatureTabDraw = "draw";
|
||||
@@ -386,6 +470,114 @@
|
||||
string _signerFullName = string.Empty;
|
||||
string _signerPosition = string.Empty;
|
||||
string _signaturePlace = string.Empty;
|
||||
bool _isLoggingOut = false;
|
||||
|
||||
// ----- Share popup state -----
|
||||
bool _sharePopupVisible = false;
|
||||
bool _isSharing = false;
|
||||
string _shareReceiverMail = string.Empty;
|
||||
DateTime _shareDateValid = DateTime.Today.AddDays(7);
|
||||
string? _shareMessage;
|
||||
bool _shareMessageIsError = false;
|
||||
|
||||
async Task LogoutAsync()
|
||||
{
|
||||
if (_isLoggingOut || string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||
return;
|
||||
|
||||
_isLoggingOut = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
try
|
||||
{
|
||||
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Receiver logout failed for {EnvelopeKey}", EnvelopeKey);
|
||||
}
|
||||
|
||||
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
}
|
||||
|
||||
void OpenSharePopup()
|
||||
{
|
||||
_shareReceiverMail = string.Empty;
|
||||
_shareDateValid = DateTime.Today.AddDays(7);
|
||||
_shareMessage = null;
|
||||
_shareMessageIsError = false;
|
||||
_sharePopupVisible = true;
|
||||
}
|
||||
|
||||
void CloseSharePopup()
|
||||
{
|
||||
if (_isSharing)
|
||||
return;
|
||||
|
||||
_sharePopupVisible = false;
|
||||
}
|
||||
|
||||
void OnShareDateChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args)
|
||||
{
|
||||
if (DateTime.TryParse(args.Value?.ToString(), out var parsedDate))
|
||||
_shareDateValid = parsedDate.Date;
|
||||
}
|
||||
|
||||
async Task ShareEnvelopeAsync()
|
||||
{
|
||||
if (_isSharing)
|
||||
return;
|
||||
|
||||
var email = _shareReceiverMail.Trim();
|
||||
if (string.IsNullOrWhiteSpace(email) || !new EmailAddressAttribute().IsValid(email))
|
||||
{
|
||||
_shareMessage = "Bitte geben Sie eine gultige E-Mail-Adresse ein.";
|
||||
_shareMessageIsError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var minDate = DateTime.Today.AddDays(1);
|
||||
var maxDate = DateTime.Today.AddDays(90);
|
||||
if (_shareDateValid.Date < minDate || _shareDateValid.Date > maxDate)
|
||||
{
|
||||
_shareMessage = "Bitte wahlen Sie ein Datum zwischen morgen und 90 Tagen.";
|
||||
_shareMessageIsError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_isSharing = true;
|
||||
_shareMessage = null;
|
||||
_shareMessageIsError = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
try
|
||||
{
|
||||
var shared = await AuthService.ShareEnvelopeReadOnlyAsync(EnvelopeKey!, email, _shareDateValid.Date);
|
||||
if (shared)
|
||||
{
|
||||
_shareMessage = "Dokument wurde erfolgreich als Nur-Lesen freigegeben.";
|
||||
_shareMessageIsError = false;
|
||||
_shareReceiverMail = string.Empty;
|
||||
_shareDateValid = DateTime.Today.AddDays(7);
|
||||
}
|
||||
else
|
||||
{
|
||||
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
|
||||
_shareMessageIsError = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to share envelope in read-only mode for {EnvelopeKey}", EnvelopeKey);
|
||||
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
|
||||
_shareMessageIsError = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSharing = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Lifecycle -----
|
||||
protected override async Task OnInitializedAsync()
|
||||
@@ -397,6 +589,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (EnvelopeKey.TryDecode(out var decodedKeys) && decodedKeys.GetEncodeType() == EncodeType.EnvelopeReceiverReadOnly)
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/readonly/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Authorization — same pattern as EnvelopeReceiverPage
|
||||
_receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
|
||||
if (_receiverUser is null)
|
||||
@@ -405,6 +603,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (await Mediator.IsSignedAsync(_receiverUser.EnvelopeUuid(), _receiverUser.ReceiverSignature()))
|
||||
{
|
||||
Navigation.NavigateTo("/envelope/signed-result", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Load PDF bytes via MediatR (uses authenticated user's claims)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
@page "/envelope/readonly/{ReadOnlyKey}"
|
||||
@rendermode InteractiveServer
|
||||
@using DevExpress.Blazor.Reporting
|
||||
@using DevExpress.XtraReports.UI
|
||||
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly
|
||||
@using EnvelopeGenerator.Application.Common.Extensions
|
||||
@using EnvelopeGenerator.Application.Common.Interfaces.Services
|
||||
@using EnvelopeGenerator.Domain.Constants
|
||||
@inject EnvelopeGenerator.Server.Client.Services.AppVersionService AppVersion
|
||||
@inject ILogger<ReceiverReadOnlyPage> Logger
|
||||
@inject IEnvelopeReceiverReadOnlyService ReadOnlyService
|
||||
@inject IEnvelopeReceiverService EnvelopeReceiverService
|
||||
@inject IEnvelopeHistoryService HistoryService
|
||||
@implements IDisposable
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
<link href="_content/DevExpress.Blazor.Reporting.Viewer/css/dx-blazor-reporting-components.bs5.css" rel="stylesheet" />
|
||||
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
|
||||
|
||||
<div class="envelope-viewer-layout">
|
||||
<div class="envelope-action-bar">
|
||||
<div class="envelope-action-bar__inner" style="padding: 0.5rem 1.25rem;">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:1rem;">
|
||||
<div style="font-weight:600; color:#1f2937;">Dokumentansicht (Nur Lesen)</div>
|
||||
@if (_readOnly is not null)
|
||||
{
|
||||
<div style="font-size:0.8rem; color:#6b7280;">Gultig bis: @_readOnly.DateValid.ToString("dd.MM.yyyy")</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="envelope-content" style="padding:0; overflow:hidden;">
|
||||
@if (_isLoading)
|
||||
{
|
||||
<div class="d-flex justify-content-center align-items-center h-100">
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-white mb-3" style="width:3.5rem; height:3.5rem;" role="status"></div>
|
||||
<p class="text-white fw-semibold">Dokument wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(_errorMessage))
|
||||
{
|
||||
<div class="error-container">
|
||||
<div class="alert alert-danger shadow-lg mb-0">
|
||||
<h5 class="mb-2">Dokument nicht verfugbar</h5>
|
||||
<p class="mb-0">@_errorMessage</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_isExpired)
|
||||
{
|
||||
<div class="error-container">
|
||||
<div class="alert alert-warning shadow-lg mb-0">
|
||||
<h5 class="mb-2">Freigabe abgelaufen</h5>
|
||||
<p class="mb-0">Der Zeitraum fur diese Nur-Lesen-Freigabe ist abgelaufen.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_report is not null)
|
||||
{
|
||||
<DxReportViewer @ref="_reportViewer"
|
||||
Report="_report"
|
||||
RootCssClasses="w-100 h-100" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? ReadOnlyKey { get; set; }
|
||||
|
||||
bool _isLoading = true;
|
||||
bool _isExpired = false;
|
||||
string? _errorMessage;
|
||||
EnvelopeReceiverReadOnlyDto? _readOnly;
|
||||
|
||||
DxReportViewer? _reportViewer;
|
||||
XtraReport? _report;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ReadOnlyKey))
|
||||
{
|
||||
_errorMessage = "Freigabe-Schlussel fehlt.";
|
||||
_isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ReadOnlyKey.TryDecode(out var decodedKeys) || decodedKeys.GetEncodeType() != EncodeType.EnvelopeReceiverReadOnly)
|
||||
{
|
||||
_errorMessage = "Ungultiger Freigabe-Link.";
|
||||
_isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var readOnlyId = decodedKeys.ParseReadOnlyId();
|
||||
var readOnlyRes = await ReadOnlyService.ReadByIdAsync(readOnlyId);
|
||||
if (readOnlyRes.IsFailed)
|
||||
{
|
||||
_errorMessage = "Freigabe konnte nicht geladen werden.";
|
||||
_isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_readOnly = readOnlyRes.Data;
|
||||
|
||||
if (DateTime.Now > _readOnly.DateValid)
|
||||
{
|
||||
_isExpired = true;
|
||||
_isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_readOnly.Envelope is null || _readOnly.Receiver is null)
|
||||
{
|
||||
_errorMessage = "Freigabe ist unvollstandig.";
|
||||
_isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var erRes = await EnvelopeReceiverService.ReadByUuidSignatureAsync(_readOnly.Envelope.Uuid, _readOnly.Receiver.Signature);
|
||||
if (erRes.IsFailed)
|
||||
{
|
||||
_errorMessage = "Dokument wurde nicht gefunden.";
|
||||
_isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var envelopeReceiver = erRes.Data;
|
||||
var documentBytes = envelopeReceiver.Envelope?.Documents?.FirstOrDefault()?.ByteData;
|
||||
if (documentBytes is not { Length: > 0 })
|
||||
{
|
||||
_errorMessage = "Dokumentdaten konnten nicht geladen werden.";
|
||||
_isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_report = BuildReport(documentBytes);
|
||||
|
||||
try
|
||||
{
|
||||
var historyResult = await HistoryService.RecordAsync((int)_readOnly.EnvelopeId, _readOnly.AddedWho, EnvelopeStatus.EnvelopeViewed);
|
||||
if (historyResult.IsFailed)
|
||||
Logger.LogWarning("Failed to record EnvelopeViewed history for read-only key {ReadOnlyKey}", ReadOnlyKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to record read-only history for {ReadOnlyKey}", ReadOnlyKey);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Unexpected error in read-only page for {ReadOnlyKey}", ReadOnlyKey);
|
||||
_errorMessage = "Beim Laden ist ein unerwarteter Fehler aufgetreten.";
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
}
|
||||
|
||||
static XtraReport BuildReport(byte[] pdfBytes)
|
||||
{
|
||||
var report = new XtraReport
|
||||
{
|
||||
PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4,
|
||||
Landscape = false,
|
||||
Margins = new System.Drawing.Printing.Margins(0, 0, 0, 0)
|
||||
};
|
||||
|
||||
var detail = new DetailBand { HeightF = 0f };
|
||||
report.Bands.Add(detail);
|
||||
|
||||
detail.Controls.Add(new XRPdfContent
|
||||
{
|
||||
Source = pdfBytes,
|
||||
GenerateOwnPages = true
|
||||
});
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_report?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||
@inject EnvelopeGenerator.Server.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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,114 +0,0 @@
|
||||
@page "/envelope/DxPdfViewer"
|
||||
@rendermode InteractiveServer
|
||||
@using System.IO
|
||||
@using DevExpress.Blazor
|
||||
@using System.Reflection
|
||||
@using DevExpress.Blazor.PdfViewer
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
|
||||
<style>
|
||||
.custom-drop-zone {
|
||||
padding: 0 !important;
|
||||
border-style: dashed;
|
||||
border-width: 2px !important;
|
||||
height: 230px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(183, 183, 183, 0.1);
|
||||
}
|
||||
|
||||
.custom-drop-zone.custom-drop-zone-hover {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.custom-drop-zone svg {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.custom-drop-zone > *:not(#overviewDemoSelectButton) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pdf-viewer {
|
||||
height: 800px !important;
|
||||
min-height: 800px !important;
|
||||
}
|
||||
|
||||
.pdf-viewer .dxbrv-surface-wrapper,
|
||||
.pdf-viewer .dxbrv-document-surface {
|
||||
height: 100% !important;
|
||||
min-height: 750px !important;
|
||||
}
|
||||
|
||||
.pdf-viewer .dxbrv-report-preview-content {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
min-width: 200px !important;
|
||||
min-height: 200px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="overviewDemoDropZone" class="card custom-drop-zone rounded-3 w-100 m-0">
|
||||
<span class="drop-file-icon mb-3"></span>
|
||||
<span class="drop-file-label">Drag and Drop File Here</span><span class="m-1">or</span>
|
||||
<DxButton Id="overviewDemoSelectButton"
|
||||
CssClass="m-1"
|
||||
RenderStyle="ButtonRenderStyle.Primary"
|
||||
Text="Select File" />
|
||||
</div>
|
||||
<DxFileInput @ref="fileInput"
|
||||
AcceptedFileTypes="@ALLOWED_FILE_TYPES"
|
||||
AllowedFileExtensions="@ALLOWED_FILE_TYPES"
|
||||
CssClass="w-100"
|
||||
ExternalDropZoneCssSelector="#overviewDemoDropZone"
|
||||
ExternalDropZoneDragOverCssClass="custom-drop-zone-hover"
|
||||
ExternalSelectButtonCssSelector="#overviewDemoSelectButton"
|
||||
FilesUploading="OnFilesUploading"
|
||||
MaxFileSize="2000000">
|
||||
</DxFileInput>
|
||||
|
||||
@if (DocumentContent != null && DocumentContent.Length > 0)
|
||||
{
|
||||
<div class="alert alert-success mt-3">
|
||||
PDF loaded: @DocumentContent.Length bytes
|
||||
</div>
|
||||
<DxPdfViewer CssClass="w-100 pdf-viewer" DocumentContent="@DocumentContent" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info mt-3">
|
||||
Please upload a PDF file to view it.
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
readonly List<string> ALLOWED_FILE_TYPES = new List<string> { ".pdf" };
|
||||
DxFileInput fileInput;
|
||||
byte[] DocumentContent { get; set; }
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Assembly assembly = Assembly.GetExecutingAssembly();
|
||||
Stream stream = assembly.GetManifestResourceStream("EnvelopeGenerator.Server.Resources.Invoice.pdf");
|
||||
if (stream != null)
|
||||
{
|
||||
using (stream)
|
||||
using (var binaryReader = new BinaryReader(stream))
|
||||
DocumentContent = binaryReader.ReadBytes((int)stream.Length);
|
||||
}
|
||||
}
|
||||
protected async Task OnFilesUploading(FilesUploadingEventArgs args)
|
||||
{
|
||||
using (MemoryStream stream = new MemoryStream())
|
||||
{
|
||||
IFileInputSelectedFile file = args.Files[0];
|
||||
await file.OpenReadStream(file.Size).CopyToAsync(stream);
|
||||
DocumentContent = stream.ToArray();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
@page "/envelope/{EnvelopeKey}/DxReportViewer"
|
||||
@rendermode InteractiveServer
|
||||
@using XtraReport = DevExpress.XtraReports.UI.XtraReport
|
||||
@using DevExpress.Blazor.Reporting
|
||||
@using Microsoft.Extensions.Options
|
||||
@using EnvelopeGenerator.Server.Client.Options
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@inject InMemoryReportStorageWebExtension ReportStorage
|
||||
@inject DocumentService DocumentService
|
||||
@inject IOptions<ApiOptions> AppOptions
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
<link href="_content/DevExpress.Blazor.Reporting.Viewer/css/dx-blazor-reporting-components.bs5.css" rel="stylesheet" />
|
||||
|
||||
|
||||
@if (_report is not null) {
|
||||
<DxReportViewer Report="_report" RootCssClasses="w-100 h-100" Zoom="1.3" />
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string EnvelopeKey { get; init; } = null!;
|
||||
|
||||
XtraReport? _report = null;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_report = await CreateReport();
|
||||
}
|
||||
|
||||
async Task<XtraReport> CreateReport()
|
||||
{
|
||||
if (AppOptions.Value.UsePredefinedReports)
|
||||
{
|
||||
return Client.PredefinedReports.ReportsFactory.GetReport("LargeDatasetReport");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
var pdfBytes = await DocumentService.GetDocumentAsync(EnvelopeKey);
|
||||
if (pdfBytes is null || pdfBytes.Length == 0)
|
||||
throw new InvalidOperationException($"No PDF bytes found for EnvelopeKey: {EnvelopeKey}");
|
||||
|
||||
var report = new XtraReport();
|
||||
var detail = new DevExpress.XtraReports.UI.DetailBand();
|
||||
report.Bands.Add(detail);
|
||||
detail.Controls.Add(new DevExpress.XtraReports.UI.XRPdfContent { Source = pdfBytes, GenerateOwnPages = true });
|
||||
return report;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
@page "/envelope/Embed"
|
||||
@rendermode InteractiveServer
|
||||
@using System.IO
|
||||
@using DevExpress.Blazor
|
||||
@using System.Reflection
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
|
||||
<style>
|
||||
.custom-drop-zone {
|
||||
padding: 0 !important;
|
||||
border-style: dashed;
|
||||
border-width: 2px !important;
|
||||
height: 230px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(183, 183, 183, 0.1);
|
||||
}
|
||||
|
||||
.custom-drop-zone.custom-drop-zone-hover {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.custom-drop-zone svg {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.custom-drop-zone > *:not(#overviewDemoSelectButton) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pdf-viewer {
|
||||
height: 800px !important;
|
||||
min-height: 800px !important;
|
||||
}
|
||||
|
||||
.pdf-viewer .dxbrv-surface-wrapper,
|
||||
.pdf-viewer .dxbrv-document-surface {
|
||||
height: 100% !important;
|
||||
min-height: 750px !important;
|
||||
}
|
||||
|
||||
.pdf-viewer .dxbrv-report-preview-content {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
min-width: 200px !important;
|
||||
min-height: 200px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="overviewDemoDropZone" class="card custom-drop-zone rounded-3 w-100 m-0">
|
||||
<span class="drop-file-icon mb-3"></span>
|
||||
<span class="drop-file-label">Drag and Drop File Here</span><span class="m-1">or</span>
|
||||
<DxButton Id="overviewDemoSelectButton"
|
||||
CssClass="m-1"
|
||||
RenderStyle="ButtonRenderStyle.Primary"
|
||||
Text="Select File" />
|
||||
</div>
|
||||
<DxFileInput @ref="fileInput"
|
||||
AcceptedFileTypes="@ALLOWED_FILE_TYPES"
|
||||
AllowedFileExtensions="@ALLOWED_FILE_TYPES"
|
||||
CssClass="w-100"
|
||||
ExternalDropZoneCssSelector="#overviewDemoDropZone"
|
||||
ExternalDropZoneDragOverCssClass="custom-drop-zone-hover"
|
||||
ExternalSelectButtonCssSelector="#overviewDemoSelectButton"
|
||||
FilesUploading="OnFilesUploading"
|
||||
MaxFileSize="2000000">
|
||||
</DxFileInput>
|
||||
|
||||
@if (DocumentContent != null && DocumentContent.Length > 0)
|
||||
{
|
||||
<div class="alert alert-success mt-3">
|
||||
PDF loaded: @DocumentContent.Length bytes
|
||||
</div>
|
||||
<embed src="@GetPdfDataUrl()" type="application/pdf" class="w-100 pdf-viewer" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info mt-3">
|
||||
Please upload a PDF file to view it.
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
readonly List<string> ALLOWED_FILE_TYPES = new List<string> { ".pdf" };
|
||||
DxFileInput fileInput;
|
||||
byte[] DocumentContent { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Assembly assembly = Assembly.GetExecutingAssembly();
|
||||
Stream stream = assembly.GetManifestResourceStream("EnvelopeGenerator.Server.Resources.Invoice.pdf");
|
||||
if (stream != null)
|
||||
{
|
||||
using (stream)
|
||||
using (var binaryReader = new BinaryReader(stream))
|
||||
DocumentContent = binaryReader.ReadBytes((int)stream.Length);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task OnFilesUploading(FilesUploadingEventArgs args)
|
||||
{
|
||||
using (MemoryStream stream = new MemoryStream())
|
||||
{
|
||||
IFileInputSelectedFile file = args.Files[0];
|
||||
await file.OpenReadStream(file.Size).CopyToAsync(stream);
|
||||
DocumentContent = stream.ToArray();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetPdfDataUrl()
|
||||
{
|
||||
if (DocumentContent == null || DocumentContent.Length == 0)
|
||||
return string.Empty;
|
||||
|
||||
string base64 = Convert.ToBase64String(DocumentContent);
|
||||
return $"data:application/pdf;base64,{base64}#toolbar=0&navpanes=0&scrollbar=1";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Redirects legacy envelope key links to Blazor routes.
|
||||
/// </summary>
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[Route("EnvelopeKey")]
|
||||
public class EnvelopeKeyRedirectController : Controller
|
||||
{
|
||||
/// <summary>
|
||||
/// Redirects /EnvelopeKey/{*path} links to /envelope/{path}.
|
||||
/// </summary>
|
||||
[HttpGet("{*path}")]
|
||||
public IActionResult RedirectToEnvelopePath([FromRoute] string path)
|
||||
=> Redirect($"/envelope/{path}");
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using DigitalData.Auth.Claims;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class PdfRenderController(IPdfPageImageRenderer renderer) : ControllerBase
|
||||
{
|
||||
[HttpPost("pages")]
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
public async Task<IActionResult> RenderPages([FromBody] PdfRenderRequestDto? request, CancellationToken cancel)
|
||||
{
|
||||
if (request?.PdfBytes is not { Length: > 0 } bytes)
|
||||
return BadRequest("Missing PDF bytes.");
|
||||
|
||||
IReadOnlyList<PdfPageImageDto> pages = await renderer.RenderPagesAsImagesAsync(bytes, cancel);
|
||||
return Ok(pages);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using DigitalData.Core.Abstraction.Application.DTO;
|
||||
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using EnvelopeGenerator.Server.Extensions;
|
||||
using MediatR;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
@@ -33,24 +35,36 @@ public class ReadOnlyController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new read-only receiver for the current envelope.
|
||||
/// Creates a new read-only receiver for an envelope.
|
||||
/// </summary>
|
||||
/// <param name="envelopeKey">Envelope key (uuid+signature encoded).</param>
|
||||
/// <param name="createDto">Creation payload.</param>
|
||||
[HttpPost]
|
||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||
[HttpPost("{envelopeKey}")]
|
||||
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
|
||||
[Obsolete("Use MediatR")]
|
||||
public async Task<IActionResult> CreateAsync([FromBody] EnvelopeReceiverReadOnlyCreateDto createDto)
|
||||
public async Task<IActionResult> CreateAsync(
|
||||
[FromRoute] string envelopeKey,
|
||||
[FromBody] EnvelopeReceiverReadOnlyCreateDto createDto,
|
||||
[FromServices] IMediator mediator,
|
||||
CancellationToken cancel)
|
||||
{
|
||||
var authReceiverMail = User.ReceiverMail();
|
||||
if (authReceiverMail is null)
|
||||
var envelopeReceiver = await mediator.Send(new ReadEnvelopeReceiverQuery { Key = envelopeKey }, cancel);
|
||||
var authEnvelopeReceiver = envelopeReceiver.FirstOrDefault();
|
||||
|
||||
if (authEnvelopeReceiver?.Envelope is null)
|
||||
return NotFound();
|
||||
|
||||
var addedWho = User.FindFirstValue(ClaimTypes.Email)
|
||||
?? authEnvelopeReceiver.Receiver?.EmailAddress;
|
||||
if (string.IsNullOrWhiteSpace(addedWho))
|
||||
{
|
||||
_logger.LogError("EmailAddress claim is not found in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
|
||||
return Unauthorized();
|
||||
_logger.LogError("Receiver e-mail is missing in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
|
||||
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
var envelopeId = User.EnvelopeId();
|
||||
var envelopeId = authEnvelopeReceiver.EnvelopeId;
|
||||
|
||||
createDto.AddedWho = authReceiverMail;
|
||||
createDto.AddedWho = addedWho;
|
||||
createDto.EnvelopeId = envelopeId;
|
||||
|
||||
var creationRes = await _readOnlyService.CreateAsync(createDto: createDto);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<Authors>Digital Data GmbH</Authors>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>EnvelopeGenerator.Server</Product>
|
||||
<Version>1.2.0-beta</Version>
|
||||
<FileVersion>1.2.0.0</FileVersion>
|
||||
<AssemblyVersion>1.2.0.0</AssemblyVersion>
|
||||
<Version>1.4.0-beta</Version>
|
||||
<FileVersion>1.4.0.0</FileVersion>
|
||||
<AssemblyVersion>1.4.0.0</AssemblyVersion>
|
||||
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
@@ -21,6 +21,7 @@
|
||||
<ProjectReference Include="..\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -25,6 +25,9 @@ using NLog.Web;
|
||||
using NLog;
|
||||
using DigitalData.Auth.Claims;
|
||||
using EnvelopeGenerator.Server;
|
||||
using DigitalData.EmailProfilerDispatcher;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
using EnvelopeGenerator.Infrastructure.Doc;
|
||||
|
||||
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
|
||||
logger.Info("EnvelopeGenerator.Server logging initialized!");
|
||||
@@ -323,6 +326,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)
|
||||
|
||||
@@ -338,6 +343,8 @@ try
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.LocalizationService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ReceiverService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfRenderService>();
|
||||
|
||||
// Business Services (Server specific)
|
||||
builder.Services.AddScoped<DocumentService>();
|
||||
@@ -346,6 +353,8 @@ try
|
||||
builder.Services.AddScoped<EnvelopeReceiverService>();
|
||||
builder.Services.AddScoped<SignatureService>();
|
||||
builder.Services.AddScoped<SignatureCacheService>();
|
||||
builder.Services.AddScoped<IPdfPageImageRenderer, PdfPageImageRenderer>();
|
||||
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 */
|
||||
|
||||
|
||||
@@ -56,6 +56,138 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sender-image-editor {
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.sender-image-editor__pages {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.sender-image-editor__native-preview {
|
||||
max-width: 980px;
|
||||
margin: 0 auto 1rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.sender-image-editor__native-frame {
|
||||
width: 100%;
|
||||
min-height: 70vh;
|
||||
border: 0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.sender-image-editor__page-block {
|
||||
background: #e2e8f0;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.sender-image-editor__page-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 595 / 842;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.sender-image-editor__page-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sender-image-editor__page-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.01em;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
}
|
||||
|
||||
.sender-image-editor__field {
|
||||
position: absolute;
|
||||
border: 2px solid;
|
||||
border-radius: 8px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
padding: 0.2rem 0.3rem;
|
||||
text-align: left;
|
||||
line-height: 1.1;
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.35rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sender-image-editor__field:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sender-image-editor__field-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sender-image-editor__field-remove {
|
||||
border: none;
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
color: #ffffff;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
min-width: 1.1rem;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sender-image-editor__field-remove:hover {
|
||||
background: rgba(220, 38, 38, 0.9);
|
||||
}
|
||||
|
||||
.sender-image-editor__field:hover {
|
||||
filter: brightness(0.96);
|
||||
}
|
||||
|
||||
.sender-image-editor__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 320px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sender-editor-pdf-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -25,9 +25,10 @@ window.envelopeEditor = {
|
||||
}
|
||||
|
||||
// --- Candidate page elements (ordered by preference) ---
|
||||
// DxPdfViewer renders individual pages as .dxbl-pdfv-page elements.
|
||||
// DxReportViewer uses .dxbrv-report-preview-content-img as fallback.
|
||||
// Sender editor custom renderer uses data-editor-page blocks.
|
||||
// Legacy Dx viewers remain as fallback selectors.
|
||||
const pageSelectors = [
|
||||
'[data-editor-page]',
|
||||
'.dxbl-pdfv-page',
|
||||
'.dxbrv-report-preview-page',
|
||||
'.dxbrv-report-preview-content-img',
|
||||
@@ -89,7 +90,8 @@ window.envelopeEditor = {
|
||||
return {
|
||||
normX: normX,
|
||||
normY: normY,
|
||||
pageIndex: targetIndex
|
||||
pageIndex: targetIndex,
|
||||
pageNumber: parseInt(targetPage.getAttribute('data-editor-page') || String(targetIndex + 1), 10)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
EnvelopeGenerator.Server\publish.bat = EnvelopeGenerator.Server\publish.bat
|
||||
SENDER_SAVE_EDIT_MIGRATION_PLAN.md = SENDER_SAVE_EDIT_MIGRATION_PLAN.md
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj", "{4E6C54DA-576D-0955-2564-9EC890BB8279}"
|
||||
@@ -51,6 +52,8 @@ Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.Service_l
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Service", "EnvelopeGenerator.Service\EnvelopeGenerator.Service.csproj", "{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Infrastructure.Doc", "EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj", "{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -113,6 +116,10 @@ Global
|
||||
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -136,6 +143,7 @@ Global
|
||||
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
|
||||
{83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
||||
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}
|
||||
|
||||
217
SENDER_EDITOR_WASM_PDF_EDITOR_IMPLEMENTATION_PLAN_en.md
Normal file
217
SENDER_EDITOR_WASM_PDF_EDITOR_IMPLEMENTATION_PLAN_en.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Sender Editor PDF Component Migration Plan
|
||||
|
||||
This plan is prepared to remove the DevExpress PDF viewer dependency from `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderEditorPage.razor` and move to a new WASM-based PDF editor component while preserving the current page design.
|
||||
|
||||
## 0) Scope and Non-Negotiable Rules
|
||||
|
||||
- **Scope**: Only the PDF editor/viewer area will change; the page action bar, popups, receiver management, and overall visual language must remain intact.
|
||||
- **Render mode target**: The `/sender/editor` flow will be implemented in `EnvelopeGenerator.Server.Client` and run in WebAssembly.
|
||||
- **Data compatibility**: The final `CreateEnvelopeCommand` payload shape, field units (`X`, `Y` in inches), receiver-field mapping, and save/send behavior must stay unchanged.
|
||||
- **To be removed**: `esid` query param + `IMemoryCache`-based editor session persistence will be removed completely.
|
||||
- **Library constraint**: `pdf.js` and DevExpress PDF viewer will not be used. For PDF rasterization, first verify whether DevExpress provides a suitable capability; if not, use a reliable, popular MIT-licensed alternative.
|
||||
- **Server-side pattern rule**: All new server-side work must follow the existing architecture; **Clean Architecture layers** and **MediatR pattern** must be preserved.
|
||||
- **Language rule (code side)**: All code and any code comments must always be in English.
|
||||
- **Localization rule**: UI text must not be hardcoded; use `IStringLocalizer<Resource>`. For every new key, add German, English, and French translations in `EnvelopeGenerator.Application/Resources/Resource.de-DE.resx`, `EnvelopeGenerator.Application/Resources/Resource.en-US.resx`, and `EnvelopeGenerator.Application/Resources/Resource.fr-FR.resx`.
|
||||
|
||||
---
|
||||
|
||||
## 1) Current Behavior Inventory (Parity Baseline)
|
||||
|
||||
**Goal:** Lock down all behaviors that must not break after migration.
|
||||
|
||||
**Tasks**
|
||||
- List the current editor flow:
|
||||
- PDF upload,
|
||||
- receiver add,
|
||||
- signature field placement,
|
||||
- field clearing,
|
||||
- draft save,
|
||||
- send.
|
||||
- Clarify current coordinate conversion:
|
||||
- Viewer click -> PDF points,
|
||||
- Save payload -> inches (`pt / 72`).
|
||||
- Capture current validation messages and popup behavior.
|
||||
- During this inventory phase, continuously add findings into this markdown under the relevant sections so it remains a living document.
|
||||
|
||||
**Output**
|
||||
- A short “parity checklist” is prepared and used throughout development.
|
||||
|
||||
---
|
||||
|
||||
## 2) Architecture Decisions and Packaging
|
||||
|
||||
**Goal:** Define technical boundaries of the new solution.
|
||||
|
||||
**Tasks**
|
||||
- Define the new reusable component:
|
||||
- Suggested name: `SenderPdfImageEditor`.
|
||||
- Location: `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/`.
|
||||
- Define PDF rasterization strategy:
|
||||
- Convert PDF -> image pages on the server side.
|
||||
- First confirm whether this conversion is possible with DevExpress.
|
||||
- If DevExpress does not provide a suitable solution, choose a popular MIT-licensed engine (e.g., `Docnet.Core` / PDFium-based).
|
||||
- Document license validation and production suitability.
|
||||
- Draft API contract:
|
||||
- endpoint(s) returning page images after PDF upload/read,
|
||||
- page metadata (width/height in pt),
|
||||
- optional re-raster endpoint for higher zoom levels.
|
||||
|
||||
**Output**
|
||||
- “Component contract + API contract” is finalized.
|
||||
|
||||
---
|
||||
|
||||
## 3) Copy `/sender/editor` to WASM Page
|
||||
|
||||
**Goal:** Run the editor in `Server.Client` using SPA behavior.
|
||||
|
||||
**Tasks**
|
||||
- Create a new page:
|
||||
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/EnvelopeSenderEditorPage.razor`
|
||||
- Temporary route: `/sender/editor2`
|
||||
- Keep the existing server-side `/sender/editor` page during development; both pages run in parallel.
|
||||
- Copy existing toolbar/receiver/popup markup as-is as much as possible.
|
||||
- Integrate client services (`EnvelopeService`, auth checks, receiver suggestion calls).
|
||||
|
||||
**Output**
|
||||
- WASM page is accessible, old UI layout is preserved, and PDF area works with a placeholder component.
|
||||
|
||||
---
|
||||
|
||||
## 4) Page-by-Page PDF to Image Conversion Service (With Priority Order)
|
||||
|
||||
**Goal:** Convert PDF into page-image list with near-identical visual fidelity.
|
||||
|
||||
**Tasks**
|
||||
- Add new application/service layer:
|
||||
- PDF bytes input,
|
||||
- raster output per page (PNG preferred for stable quality/transparency).
|
||||
- Evaluate client-side (WASM) rasterization feasibility if it is possible and does not hurt performance.
|
||||
- Priority order:
|
||||
1. DevExpress feasibility,
|
||||
2. If not suitable, MIT-licensed alternative,
|
||||
3. If needed, client-side rasterization option.
|
||||
- Return metadata:
|
||||
- page index,
|
||||
- image URL or base64,
|
||||
- page width/height (pt),
|
||||
- native image size in px.
|
||||
- Performance:
|
||||
- reasonable initial DPI (e.g., 144),
|
||||
- lazy/high-res render option for higher zoom levels.
|
||||
|
||||
**Output**
|
||||
- “PDF -> image pages + page metrics” endpoint/service is functional.
|
||||
|
||||
---
|
||||
|
||||
## 5) New Image-based PDF Viewer Features
|
||||
|
||||
**Goal:** Provide viewer UX similar to DevExpress viewer.
|
||||
|
||||
**Tasks**
|
||||
- Implement two display modes in `SenderPdfImageEditor`:
|
||||
- **Continuous mode**: pages stacked vertically,
|
||||
- **Single-page mode**: one page with next/prev navigation.
|
||||
- Add zoom controls:
|
||||
- zoom in/out,
|
||||
- fit width,
|
||||
- fit page.
|
||||
- Add page navigation:
|
||||
- page selector (n / total),
|
||||
- keyboard shortcuts (optional, phase 2).
|
||||
- Preserve UI consistency:
|
||||
- keep current sender editor color language and button style.
|
||||
|
||||
**Output**
|
||||
- Image-based viewer is fully functional.
|
||||
|
||||
---
|
||||
|
||||
## 6) Signature Field Management via DOM Overlay
|
||||
|
||||
**Goal:** Manage signature fields in a movable DOM layer instead of burning them into PDF bytes.
|
||||
|
||||
**Tasks**
|
||||
- Place an overlay layer on top of each page image.
|
||||
- Signature box lifecycle:
|
||||
- add (by click),
|
||||
- drag (move),
|
||||
- remove.
|
||||
- Normalize all interactions as page-relative coordinates.
|
||||
- Rendering:
|
||||
- receiver color,
|
||||
- receiver name,
|
||||
- box/badge style close to current placeholder visuals.
|
||||
- Preserve multi-receiver / multi-field support and receiver-field linkage.
|
||||
|
||||
**Output**
|
||||
- Users can move and delete each signature field independently.
|
||||
|
||||
---
|
||||
|
||||
## 7) Coordinate and Payload Parity (Critical)
|
||||
|
||||
**Goal:** Keep backend data type/unit behavior exactly unchanged.
|
||||
|
||||
**Tasks**
|
||||
- Convert overlay coordinates -> PDF points:
|
||||
- `xPt = normalizedX * pageWidthPt`
|
||||
- `yPt = normalizedY * pageHeightPt`
|
||||
- Keep points -> inches conversion on save/send:
|
||||
- `X = xPt / 72`, `Y = yPt / 72`.
|
||||
- Use the same DTO shape in `CreateEnvelopeCommand`.
|
||||
- Keep existing validation rules unchanged.
|
||||
|
||||
**Output**
|
||||
- Full backend contract parity with the old system.
|
||||
|
||||
---
|
||||
|
||||
## 8) Remove Session Cache Logic
|
||||
|
||||
**Goal:** Fully remove `esid` and memory cache dependency.
|
||||
|
||||
**Tasks**
|
||||
- Remove `SupplyParameterFromQuery(Name = "esid")`.
|
||||
- Remove `IMemoryCache` injection and `PersistSession` flow.
|
||||
- Manage editor state using component state + loaded envelope data only.
|
||||
- Clarify reset behavior after successful save/send.
|
||||
|
||||
**Output**
|
||||
- Session-id-based cache layer is removed completely.
|
||||
|
||||
---
|
||||
|
||||
## 9) Testing and Acceptance Criteria
|
||||
|
||||
**Goal:** Verify migration is complete and safe.
|
||||
|
||||
**Critical manual tests**
|
||||
- PDF upload -> are pages visually accurate?
|
||||
- Continuous/single-page mode transitions work correctly?
|
||||
- Overlay boxes keep correct position across zoom levels?
|
||||
- Add/drag/remove works stably on all pages?
|
||||
- Draft save and send payload format remains identical?
|
||||
- In edit mode (existing envelope), fields load and remain editable?
|
||||
|
||||
**Definition of done**
|
||||
- DevExpress PDF viewer is fully removed from the editor.
|
||||
- Users can edit signature box positions after placing them.
|
||||
- Backend contract and coordinate units remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 10) Suggested Implementation Sequence (Short Sprint Plan)
|
||||
|
||||
1. **WASM page copy + route stabilization**
|
||||
2. **PDF raster API + renderer integration**
|
||||
3. **Image viewer (zoom + navigation + modes)**
|
||||
4. **Overlay signature layer (add/drag/remove)**
|
||||
5. **Save/Send payload parity verification**
|
||||
6. **Session/cache removal cleanup**
|
||||
7. **End-to-end testing and UI polish**
|
||||
|
||||
### Implementation Progress Notes
|
||||
|
||||
- Localization requirement clarified: all new Sender Editor UI strings must go through `IStringLocalizer<Resource>` with DE/EN/FR resource entries.
|
||||
Reference in New Issue
Block a user