# Conflicts: # EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverSignedPage.razor # EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json
444 lines
19 KiB
Plaintext
444 lines
19 KiB
Plaintext
@page "/envelope/{EnvelopeKey}/signed"
|
|
@rendermode InteractiveServer
|
|
@using DevExpress.Blazor.Reporting
|
|
@using DevExpress.XtraReports.UI
|
|
@using EnvelopeGenerator.Server.Client.Models
|
|
@using EnvelopeGenerator.Server.Client.Services
|
|
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
|
|
@using EnvelopeGenerator.Application.Resources
|
|
@using Microsoft.Extensions.Caching.Memory
|
|
@using Microsoft.Extensions.Localization
|
|
@using System.Security.Claims
|
|
@inject NavigationManager Navigation
|
|
@inject IJSRuntime JSRuntime
|
|
@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
|
|
@inject IStringLocalizer<Resource> UiLocalizer
|
|
@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="flex-direction: column; align-items: stretch; padding: 0.35rem 1.5rem; gap: 0.35rem;">
|
|
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem;">
|
|
<div style="flex: 0 1 auto; min-width: 0; display: flex; align-items: center; gap: 0.75rem;">
|
|
@if (_envelopeReceiver is not null)
|
|
{
|
|
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
|
@(_envelopeReceiver.Envelope?.Title ?? L("Document", "Dokument"))
|
|
</div>
|
|
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName))
|
|
{
|
|
<span style="font-size: 0.7rem; color: #6b7280; white-space: nowrap;">
|
|
@L("From", "Von") <span style="font-weight: 500; color: #374151;">@_envelopeReceiver.Envelope.User.FullName</span>
|
|
</span>
|
|
}
|
|
}
|
|
else
|
|
{
|
|
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937;">@L("SignedDocument", "Signiertes Dokument")</div>
|
|
}
|
|
</div>
|
|
|
|
@* Right: Submit button *@
|
|
<div style="flex: 0 0 auto;">
|
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change pdf-toolbar__btn--signature-change-active"
|
|
@onclick="OpenSubmitConfirmPopup"
|
|
disabled="@_isLoggingOut"
|
|
title="@L("Complete", "Abschließen")"
|
|
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.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
|
|
</svg>
|
|
<span class="pdf-toolbar__btn-text">@L("Complete", "Abschließen")</span>
|
|
</button>
|
|
</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">
|
|
<span class="visually-hidden">@L("Loading", "Lädt...")</span>
|
|
</div>
|
|
<p class="text-white fw-semibold">@L("DocumentLoading", "Dokument wird geladen...")</p>
|
|
</div>
|
|
</div>
|
|
}
|
|
else if (_errorMessage is not null)
|
|
{
|
|
<div class="error-container">
|
|
<div class="alert alert-danger shadow-lg">
|
|
<div class="d-flex align-items-start">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="me-3 flex-shrink-0" viewBox="0 0 16 16">
|
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
|
|
</svg>
|
|
<div>
|
|
<h5 class="mb-2">@L("Error", "Fehler")</h5>
|
|
<p class="mb-0">@_errorMessage</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
else if (_report is not null)
|
|
{
|
|
<DxReportViewer Report="_report" RootCssClasses="w-100 h-100" />
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
@* Submit confirmation popup *@
|
|
<DxPopup @bind-Visible="_submitConfirmVisible"
|
|
HeaderText="@L("ConfirmSignature", "Unterschrift bestätigen")"
|
|
Width="440px"
|
|
MaxWidth="95vw"
|
|
ShowFooter="true"
|
|
CloseOnOutsideClick="false"
|
|
ShowCloseButton="false"
|
|
CloseOnEscape="false">
|
|
<BodyContentTemplate>
|
|
<div style="display: flex; align-items: flex-start; gap: 1rem; padding: 0.5rem 0;">
|
|
<div style="flex-shrink: 0; width: 40px; height: 40px; background: #d1fae5; border-radius: 50%; display: flex; align-items: center; justify-content: center;">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#065f46" viewBox="0 0 16 16">
|
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<p style="margin: 0 0 0.4rem; font-weight: 600; color: #1f2937; font-size: 0.95rem;">
|
|
@L("ReceiverSigned_ConfirmQuestion", "Möchten Sie das Dokument verbindlich unterschreiben?")
|
|
</p>
|
|
<p style="margin: 0; color: #6b7280; font-size: 0.85rem; line-height: 1.5;">
|
|
@L("ReceiverSigned_ConfirmBody", "Diese Aktion kann nicht rückgängig gemacht werden. Mit der Bestätigung erklären Sie, das oben angezeigte Dokument elektronisch unterzeichnet zu haben. Das unterzeichnete Dokument wird anschließend an alle beteiligten Parteien übermittelt.")
|
|
</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;">
|
|
<button class="btn btn-outline-secondary"
|
|
@onclick="() => _submitConfirmVisible = false"
|
|
style="border-radius: 6px; padding: 0.5rem 1.25rem; font-weight: 500;">
|
|
@L("Cancel", "Abbrechen")
|
|
</button>
|
|
<button class="btn btn-primary"
|
|
@onclick="SubmitAndLogoutAsync"
|
|
disabled="@_isLoggingOut"
|
|
style="background: linear-gradient(135deg, #059669 0%, #047857 100%); border: none; border-radius: 6px; padding: 0.5rem 1.5rem; font-weight: 600; box-shadow: 0 2px 4px rgba(5, 150, 105, 0.3);">
|
|
@if (_isLoggingOut)
|
|
{
|
|
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
|
}
|
|
@L("Complete", "Abschließen")
|
|
</button>
|
|
</div>
|
|
</FooterContentTemplate>
|
|
</DxPopup>
|
|
|
|
@code {
|
|
[Parameter] public string? EnvelopeKey { get; set; }
|
|
|
|
[SupplyParameterFromQuery(Name = "sid")]
|
|
public string? Sid { get; set; }
|
|
|
|
bool _isLoading = true;
|
|
string? _errorMessage;
|
|
ClaimsPrincipal? _receiverUser;
|
|
EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto? _envelopeReceiver;
|
|
IReadOnlyList<SignatureDto> _signatures = [];
|
|
XtraReport? _report;
|
|
SignatureCaptureDto? _sig;
|
|
|
|
// ----- 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);
|
|
|
|
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()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
|
{
|
|
_errorMessage = L("ReceiverSigned_EnvelopeKeyMissing", "Envelope-Schlüssel fehlt.");
|
|
_isLoading = false;
|
|
return;
|
|
}
|
|
|
|
_receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
|
|
if (_receiverUser is null)
|
|
{
|
|
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
|
|
return;
|
|
}
|
|
|
|
// Read signature from IMemoryCache
|
|
if (!string.IsNullOrWhiteSpace(Sid)
|
|
&& MemoryCache.TryGetValue(Sid, out SignatureCaptureDto? cached)
|
|
&& cached is not null)
|
|
{
|
|
_sig = cached;
|
|
}
|
|
|
|
// Cache miss or missing sid — redirect back to report page
|
|
if (_sig is null)
|
|
{
|
|
Logger.LogWarning(
|
|
"[SignedPage] Cache miss or no sid={Sid} for {EnvelopeKey}, redirecting to report page.",
|
|
Sid, EnvelopeKey);
|
|
Navigation.NavigateTo(
|
|
$"/envelope/{Uri.EscapeDataString(EnvelopeKey)}",
|
|
forceLoad: true);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var pdfBytes = await PageDataService.GetDocumentOfReceiverAsync(_receiverUser);
|
|
if (pdfBytes is not { Length: > 0 })
|
|
{
|
|
_errorMessage = L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden.");
|
|
_isLoading = false;
|
|
return;
|
|
}
|
|
|
|
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
|
|
_signatures = await PageDataService.GetSignaturesAsync(_receiverUser);
|
|
|
|
// Burn signature image + info onto PDF via PdfSharp
|
|
if (_sig is not null && _signatures.Count > 0)
|
|
pdfBytes = DrawSignaturesOnPdf(pdfBytes, _signatures, _sig);
|
|
|
|
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();
|
|
report.Bands.Add(detail);
|
|
detail.Controls.Add(new XRPdfContent
|
|
{
|
|
Source = pdfBytes,
|
|
GenerateOwnPages = true,
|
|
});
|
|
_report = report;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_errorMessage = string.Format(L("ErrorWithReason", "Fehler: {0}"), ex.Message);
|
|
Logger.LogError(ex, "Error loading signed page for {EnvelopeKey}", EnvelopeKey);
|
|
}
|
|
|
|
_isLoading = false;
|
|
await InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
{
|
|
if (!firstRender) return;
|
|
|
|
if (_sig is not null)
|
|
{
|
|
await JSRuntime.InvokeVoidAsync("console.log",
|
|
$"[SignedPage] sid={Sid} | FullName={_sig.FullName} | Place={_sig.Place} | Position={_sig.Position} | DataUrl.Length={_sig.DataUrl?.Length ?? 0}");
|
|
}
|
|
else
|
|
{
|
|
await JSRuntime.InvokeVoidAsync("console.log",
|
|
$"[SignedPage] Cache miss or no sid. sid={Sid}");
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_report?.Dispose();
|
|
}
|
|
|
|
// ----- PDF signature rendering -----
|
|
|
|
/// <summary>
|
|
/// Uses PdfSharp to burn the captured signature onto the PDF at each signature field.
|
|
/// Layout per field (top-left origin, Y down, units = PDF points):
|
|
/// [top 65%] signature image
|
|
/// [separator line]
|
|
/// [bottom 35%] FullName (bold) / Position (optional) / Place, Date
|
|
/// </summary>
|
|
static byte[] DrawSignaturesOnPdf(
|
|
byte[] pdfBytes,
|
|
IReadOnlyList<SignatureDto> signatures,
|
|
SignatureCaptureDto sig)
|
|
{
|
|
var imgBytes = DataUrlToBytes(sig.DataUrl);
|
|
if (imgBytes is not { Length: > 0 }) return pdfBytes;
|
|
|
|
using var inputMs = new System.IO.MemoryStream(pdfBytes);
|
|
using var outputMs = new System.IO.MemoryStream();
|
|
|
|
var document = PdfSharp.Pdf.IO.PdfReader.Open(
|
|
inputMs, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Modify);
|
|
|
|
const double sigW = 1.77 * 72; // 127.44 pt
|
|
const double sigH = 1.96 * 72; // 141.12 pt
|
|
const double imgRatio = 0.52; // top 52% = image
|
|
const double lineH = 11.5; // fixed row height matching font size (bold 7.5pt + normal 6.5pt)
|
|
const double bgPad = 3.0; // background box padding around content (pt)
|
|
|
|
var black = PdfSharp.Drawing.XColor.FromArgb(255, 20, 20, 20);
|
|
var darkGray = PdfSharp.Drawing.XColor.FromArgb(255, 80, 80, 80);
|
|
var lineColor = PdfSharp.Drawing.XColor.FromArgb(180, 100, 100, 120);
|
|
|
|
var bgColor = PdfSharp.Drawing.XColor.FromArgb(255, 255, 253, 240);
|
|
var bgBrush = new PdfSharp.Drawing.XSolidBrush(bgColor);
|
|
|
|
var fontBold = new PdfSharp.Drawing.XFont("Arial", 7.5, PdfSharp.Drawing.XFontStyleEx.Bold);
|
|
var fontNormal = new PdfSharp.Drawing.XFont("Arial", 6.5, PdfSharp.Drawing.XFontStyleEx.Regular);
|
|
var linePen = new PdfSharp.Drawing.XPen(lineColor, 0.5);
|
|
|
|
var fmtLeft = new PdfSharp.Drawing.XStringFormat
|
|
{
|
|
Alignment = PdfSharp.Drawing.XStringAlignment.Near,
|
|
LineAlignment = PdfSharp.Drawing.XLineAlignment.Near,
|
|
};
|
|
|
|
var date = DateTime.Now.ToString("dd.MM.yyyy");
|
|
|
|
foreach (var field in signatures)
|
|
{
|
|
int pageIndex = field.Page - 1;
|
|
if (pageIndex < 0 || pageIndex >= document.PageCount) continue;
|
|
|
|
var page = document.Pages[pageIndex];
|
|
|
|
using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page);
|
|
|
|
double x = field.X;
|
|
double y = field.Y;
|
|
|
|
// --- Calculate layout positions first (needed for bg rect) ---
|
|
double imgH = sigH * imgRatio;
|
|
double lineY = y + imgH + 1.0; // 1pt gap between image and separator
|
|
double textY = lineY + 1.5; // 1.5pt gap below separator line
|
|
double padding = 3;
|
|
|
|
// Row 1: FullName
|
|
double row1Y = textY;
|
|
// Row 2: Position (optional)
|
|
double row2Y = row1Y + lineH;
|
|
// Row 3: Place, Date — immediately after row2 regardless of position
|
|
double row3Y = !string.IsNullOrWhiteSpace(sig.Position) ? row2Y + lineH : row2Y;
|
|
double contentBottom = row3Y + lineH;
|
|
|
|
// --- Background rectangle sized to actual content (not full sigH) ---
|
|
var bgRect = new PdfSharp.Drawing.XRect(
|
|
x - bgPad,
|
|
y - bgPad,
|
|
sigW + bgPad * 2,
|
|
(contentBottom - y) + bgPad * 2);
|
|
gfx.DrawRectangle(bgBrush, bgRect);
|
|
|
|
// --- Image area ---
|
|
var imgRect = new PdfSharp.Drawing.XRect(x, y, sigW, imgH);
|
|
using var imgStream = new System.IO.MemoryStream(imgBytes);
|
|
var xImg = PdfSharp.Drawing.XImage.FromStream(imgStream);
|
|
gfx.DrawImage(xImg, imgRect);
|
|
|
|
// --- Separator line ---
|
|
gfx.DrawLine(linePen, x + 2, lineY, x + sigW - 2, lineY);
|
|
|
|
// --- Text rows ---
|
|
// Row 1: FullName (bold)
|
|
var nameRect = new PdfSharp.Drawing.XRect(x + padding, row1Y, sigW - padding * 2, lineH);
|
|
gfx.DrawString(sig.FullName, fontBold, new PdfSharp.Drawing.XSolidBrush(black), nameRect, fmtLeft);
|
|
|
|
// Row 2: Position (optional)
|
|
if (!string.IsNullOrWhiteSpace(sig.Position))
|
|
{
|
|
var posRect = new PdfSharp.Drawing.XRect(x + padding, row2Y, sigW - padding * 2, lineH);
|
|
gfx.DrawString(sig.Position, fontNormal, new PdfSharp.Drawing.XSolidBrush(darkGray), posRect, fmtLeft);
|
|
}
|
|
|
|
// Row 3: Place, Date
|
|
var placeDate = $"{sig.Place}, {date}";
|
|
var dateRect = new PdfSharp.Drawing.XRect(x + padding, row3Y, sigW - padding * 2, lineH);
|
|
gfx.DrawString(placeDate, fontNormal, new PdfSharp.Drawing.XSolidBrush(darkGray), dateRect, fmtLeft);
|
|
}
|
|
|
|
document.Save(outputMs);
|
|
return outputMs.ToArray();
|
|
}
|
|
|
|
static byte[]? DataUrlToBytes(string? dataUrl)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(dataUrl)) return null;
|
|
var comma = dataUrl.IndexOf(',');
|
|
if (comma < 0) return null;
|
|
return Convert.FromBase64String(dataUrl[(comma + 1)..]);
|
|
}
|
|
|
|
string L(string key, string fallback)
|
|
{
|
|
var value = UiLocalizer[key];
|
|
return value.ResourceNotFound ? fallback : value.Value;
|
|
}
|
|
}
|