Compare commits

..

23 Commits

Author SHA1 Message Date
77379265c2 Add dotnet-tools.json and publish script for .NET 8
Added a `dotnet-tools.json` file to define a .NET tool manifest, specifying the `dotnet-ef` tool with version `10.0.9`.

Introduced a `publish.bat` script for publishing the `EnvelopeGenerator.Server` project as a self-contained application targeting `win-x64` and .NET 8. The script handles cleaning, publishing, output verification, and provides deployment instructions for IIS.

Updated the `EnvelopeGenerator.sln` file to include a reference to the `publish.bat` script under the `EnvelopeGenerator.Server` project using a `SolutionItems` section.
2026-07-02 15:53:49 +02:00
9b68c09b0b Update project version to 1.0.1-beta
Updated `<Version>`, `<FileVersion>`, and `<AssemblyVersion>` in `EnvelopeGenerator.Server.csproj` to reflect a minor version update (1.0.0-beta -> 1.0.1-beta). This likely includes bug fixes, small improvements, or other non-breaking changes.
2026-07-02 15:51:36 +02:00
cd6bf05352 Add self-contained deployment and production config
Updated the IISProfileNet8.pubxml file to enable self-contained deployment by setting `<SelfContained>` to `true`. Target runtime was specified as `win-x64` using the `<RuntimeIdentifier>` property. Configured the environment for production by adding the `<EnvironmentName>` property set to `Production`.
2026-07-02 15:50:39 +02:00
b2c205b160 Add Publish & Deployment Guide to README.md
Added a detailed "Publish & Deployment Guide" to the README for the `EnvelopeGenerator.Server` project. The guide includes:

- Table of contents for easy navigation.
- Explanation of differences between `EnvelopeGenerator.Server` and a standard ASP.NET Core API.
- Justification for self-contained publishing and its benefits.
- Recommended `dotnet publish` command with parameter explanations.
- IIS configuration steps, including Application Pool settings, `web.config` adjustments, and module requirements.
- Verification checklist for published output files.
- Instructions for setting up logging and recycling the IIS Application Pool.
- Directory structure overview post-publish.
- Troubleshooting common deployment errors with solutions.

This guide ensures developers and administrators can successfully publish and deploy the application.
2026-07-02 15:44:36 +02:00
33439f4a65 Update .gitignore and modify server files
The `.gitignore` file was updated to ignore `FodyWeavers.xsd`.
Added a new directory `/publish-output` to the server folder.
The file `tekh_softHSM_test.md` was removed and re-added,
indicating potential modifications or reorganization.
2026-07-02 15:44:05 +02:00
b5cdf79799 Update .gitignore, add files, and modify configurations
Updated `.gitignore` to include `MigrationBackup/`. Added new files: `FodyWeavers.xsd`, `annotations.json`, and two markdown files for SoftHSM testing. Modified `/EnvelopeGenerator.Web/.config/dotnet-tools.json`, `/EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/.vscode`, and `/EnvelopeGenerator.Tests.Application/Services/BugFixTests.cs` to reflect configuration and testing updates.
2026-07-02 12:44:12 +02:00
72915ac001 Add default descending sort to ID column in grid
Updated the `DxGridDataColumn` for the `Id` field in
`EnvelopeSenderPage.razor` to include default sorting.
The column now has `SortIndex=0` and `SortOrder=GridColumnSortOrder.Descending`,
ensuring the grid sorts by ID in descending order by default.
2026-07-02 02:13:56 +02:00
e4d7a0d8f3 Update project metadata and versioning scheme
Updated `<Version>` to `1.0.0-beta` to reflect a pre-release version. Aligned `<FileVersion>` and `<AssemblyVersion>` with the new versioning scheme (`1.0.0.0`). Replaced `<PackageOutputPath>` with `<Copyright>` for better metadata alignment. No changes to `<DocumentationFile>`.
2026-07-02 02:13:15 +02:00
d5f9de06f3 Update project metadata and enable XML documentation
Replaced `<TargetFramework>` with `<TargetFrameworks>` to allow for future multi-targeting. Enabled XML documentation file generation with `<GenerateDocumentationFile>`. Added project metadata including `<PackageId>`, `<Authors>`, `<Company>`, `<Product>`, and versioning properties (`<Version>`, `<FileVersion>`, `<AssemblyVersion>`). Included copyright notice and documentation file path configuration.
2026-07-02 02:05:47 +02:00
bbc129178f Add IIS publish profile for .NET 8.0 deployment
A new `IISProfileNet8.pubxml` file was added to configure publishing settings for a .NET 8.0 application. Key configurations include:

- Publish method set to `Package`.
- Build configuration set to `Release` and platform to `Any CPU`.
- Automatic site launch after publishing enabled.
- `App_Data` folder included in the package.
- Unique project identifier defined.
- Build package location specified with a version placeholder.
- Package created as a single file.
- IIS application path set to `EnvelopeGenerator`.
- Deployment target set to `IISWebDeployPackage`.
- Target framework set to .NET 8.0.
2026-07-02 02:03:37 +02:00
40c95100ff Add Title and Message fields to Envelope editor
Introduced input fields for "Title" and "Message" in the
EnvelopeSenderEditorPage, allowing users to specify metadata
for envelopes. The "Title" field is required and validated,
while the "Message" field is optional with a default value.

Updated the save logic to validate the title and display
appropriate error messages. Persisted the "Title" and
"Message" fields in the session cache via the
`EditorSessionData` record.

Modified the `CreateEnvelopeReceiverCommand` to use the
user-provided "Title" and "Message" values. Adjusted the UI
layout and styling to accommodate the new fields, ensuring
a seamless user experience.
2026-07-02 02:00:14 +02:00
1b2731b4b2 Simplify URL structure and update logging dependencies
Updated navigation logic and routes to remove the `/report` suffix, simplifying the URL structure for envelope-related pages.

- Updated `LoginReceiverPage.razor` and `ReceiverSignedPage.razor` to redirect to `/envelope/{EnvelopeKey}`.
- Changed `ReceiverPage.razor` route to `/envelope/{EnvelopeKey}`.
- Updated `ILogger` dependencies in `ReceiverPage.razor` and `ReceiverSignedPage.razor` to reflect new class names.
- Modified `EnvelopeReceiverPage.razor` route to `/envelope/{EnvelopeKey}/deprc`, indicating deprecation or restructuring.
2026-07-02 01:59:48 +02:00
ae56902758 Add SenderAuthCookieHandler for cookie-based JWT auth
Added a custom DelegatingHandler, SenderAuthCookieHandler, to forward the browser's Cookie header to outgoing HttpClient requests in Blazor Server. Registered the handler as a transient service and integrated it into the named HttpClient pipeline for internal API calls. This enables Blazor Server components to make authenticated API calls using cookie-based JWT authentication (AuthScheme.Sender).
2026-07-02 01:48:25 +02:00
e80059d34e Add envelope creation functionality
Introduced the ability to create envelopes with documents and receivers via a new `CreateAsync` method in `EnvelopeReceiverService`. Integrated this functionality into `EnvelopeSenderEditorPage.razor` with UI updates, including a save button spinner, validation checks, and a result popup for success or error feedback.

- Added `CreateAsync` method to handle `POST /api/EnvelopeReceiver` API calls.
- Injected `EnvelopeReceiverService` into `EnvelopeSenderEditorPage.razor`.
- Implemented save logic with validation for PDF upload, receivers, and signature fields.
- Added success and error popups for user feedback.
- Improved logging for envelope creation and validation warnings.
- Refactored save logic for better readability and maintainability.
2026-07-02 01:44:53 +02:00
a2443032c5 Add logging for stored procedure failure cases
Added warning logs in `EnvelopeReceiverController` to handle
cases where stored procedures return `OUT_SUCCESS=false`.
For `PRSIG_API_ADD_DOC_RECEIVER_ELEM`, log `DOC_ID`,
`RECEIVER_ID`, and `Page`. For `PRSIG_API_ADD_HISTORY_STATE`,
log `EnvelopeUuid`. These changes enhance error visibility
and debugging.
2026-07-02 01:12:44 +02:00
3d2fe532e5 Change @OUT_RECEIVER_ID type from int to bigint
Updated the SQL query in `EnvelopeReceiverAddReadSQL.cs` to change the data type of the `@OUT_RECEIVER_ID` variable from `int` to `bigint`. This modification ensures support for larger identifier values, addressing potential limitations of the previous `int` type.
2026-07-02 01:12:28 +02:00
5e83aa26c9 Refactor authentication and enhance logging
Updated `Authorize` attributes in multiple controllers to use
`AuthenticationSchemes = AuthScheme.Sender` instead of
`Policy = AuthPolicy.Sender`, reflecting a shift in the
authentication mechanism.

Added detailed logging in `EnvelopeReceiverController` to handle
cases where stored procedures return `OUT_SUCCESS=false`,
providing contextual information for debugging.

Removed unused SQL code and declarations in
`EnvelopeReceiverController` to improve code readability and
maintainability.
2026-07-02 01:11:09 +02:00
2c789cd4c0 Add dynamic color support for receivers
Introduced a `Color` property to `ReceiverDraft` and `SignatureFieldDraft` models, enabling dynamic color assignment from a predefined palette (`ReceiverPalette`). Updated the UI to reflect receiver-specific colors in the sender-receiver chips, placement mode hint bar, and signature placement button.

Refactored PDF rendering logic to dynamically derive visual styles (fill, border, and text colors) from receiver colors. Added a `HexToXColor` utility for converting hex color strings to `PdfSharp.Drawing.XColor`.

Removed hardcoded visual styles and replaced them with dynamic, receiver-specific styling. Simplified receiver addition logic to automatically assign colors from the palette. These changes improve clarity and maintainability while enhancing the user experience.
2026-07-01 23:27:38 +02:00
9ecfe08e2e Move session ID logic to OnAfterRenderAsync
The session ID generation and redirection logic was moved from
OnInitialized to OnAfterRenderAsync to address issues with
NavigationException during SSR prerendering. OnInitialized is
now intentionally left empty, and the new OnAfterRenderAsync
method ensures the session ID is appended to the URL only after
the first interactive render, when SignalR is connected and
NavigateTo is safe.
2026-07-01 23:10:39 +02:00
74da6e37b0 Refactor and enhance PDF signature editor
- Added `IMemoryCache` for session persistence and caching.
- Introduced session persistence via query parameter (`esid`).
- Replaced overlay-based click handling with normalized PDF coordinates.
- Added `PdfSharp` integration to render signature placeholders.
- Updated button behavior for receiver-specific signature placement.
- Improved receiver popup validation and email suggestion handling.
- Removed unused overlay synchronization logic.
- Refactored CSS for better PDF viewer layout and toolbar alignment.
- Enhanced logging with additional context for signature actions.
- General code cleanup for readability and maintainability.
2026-07-01 22:50:47 +02:00
10f65e583a Enhance IndexPage UI and navigation functionality
- Injected `NavigationManager` into `IndexPage.razor` to enable navigation.
- Improved layout alignment with additional classes in `<div>`.
- Added a new button for navigation to `/sender` with icons and text.
- Introduced a feature badge for "PDF-Export" with an SVG icon.
- Updated `.home-btn-primary` styles in `app.css`:
  - Ensured text color is always white with `!important`.
  - Made text bolder by increasing `font-weight` to `700`.
2026-07-01 20:09:44 +02:00
0cdc8f1191 Add "Cancel" button and navigation to sender page
Added the `NavigationManager` service injection to the
`EnvelopeSenderEditorPage.razor` file to enable navigation.
Introduced a "Cancel" button in the toolbar, styled with
`pdf-toolbar__btn pdf-toolbar__btn--reset`, which triggers
the `Cancel` method on click. The `Cancel` method navigates
the user to the `/sender` route, improving the user experience
by providing a clear way to cancel the current operation.
2026-07-01 19:28:30 +02:00
56ccab6377 Refactor email suggestion handling logic
Refactored the handling of email suggestions to separate selection
and commitment logic. Updated `<DxListBox>`'s `ValueChanged`
handler to use `OnReceiverEmailSuggestionCommittedAsync` for
finalizing selections. Introduced `SelectReceiverEmailSuggestion`
as a synchronous helper method for managing selection updates.
Centralized clearing of suggestions in the new
`OnReceiverEmailSuggestionCommittedAsync` method. Simplified
keyboard navigation logic by replacing asynchronous calls with
synchronous selection handling. These changes improve code
clarity and reduce unnecessary asynchronous operations.
2026-07-01 19:15:40 +02:00
29 changed files with 1187 additions and 336 deletions

4
.gitignore vendored
View File

@@ -365,3 +365,7 @@ FodyWeavers.xsd
/EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/.vscode /EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/.vscode
/EnvelopeGenerator.Tests.Application/Services/BugFixTests.cs /EnvelopeGenerator.Tests.Application/Services/BugFixTests.cs
/EnvelopeGenerator.Tests.Application/annotations.json /EnvelopeGenerator.Tests.Application/annotations.json
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/TekH - SoftHSM Test.md
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md

View File

@@ -214,6 +214,10 @@ public class EnvelopeReceiverController : ControllerBase
if (reader.Read()) if (reader.Read())
{ {
bool outSuccess = reader.GetBoolean(0); bool outSuccess = reader.GetBoolean(0);
if (!outSuccess)
_logger.LogWarning(
"PRSIG_API_ADD_DOC_RECEIVER_ELEM returned OUT_SUCCESS=false. DOC_ID={DocId}, RECEIVER_ID={ReceiverId}, Page={Page}",
document.Id, rcv.Id, sign.Page);
} }
} }
#endregion #endregion
@@ -221,8 +225,6 @@ public class EnvelopeReceiverController : ControllerBase
#region Create history #region Create history
// ENV_UID, STATUS_ID, USER_ID, // ENV_UID, STATUS_ID, USER_ID,
string sql_hist = @" string sql_hist = @"
USE [DD_ECM]
DECLARE @OUT_SUCCESS bit; DECLARE @OUT_SUCCESS bit;
EXEC [dbo].[PRSIG_API_ADD_HISTORY_STATE] EXEC [dbo].[PRSIG_API_ADD_HISTORY_STATE]
@@ -244,6 +246,10 @@ public class EnvelopeReceiverController : ControllerBase
if (reader.Read()) if (reader.Read())
{ {
bool outSuccess = reader.GetBoolean(0); bool outSuccess = reader.GetBoolean(0);
if (!outSuccess)
_logger.LogWarning(
"PRSIG_API_ADD_HISTORY_STATE returned OUT_SUCCESS=false. EnvelopeUuid={EnvelopeUuid}",
envelope.Uuid);
} }
} }
#endregion #endregion

View File

@@ -13,7 +13,7 @@ public class EnvelopeReceiverAddReadSQL : ISQL<Envelope>
/// ENV_UID, EMAIL_ADRESS, SALUTATION, PHONE, /// ENV_UID, EMAIL_ADRESS, SALUTATION, PHONE,
/// </summary> /// </summary>
public string Raw => @" public string Raw => @"
DECLARE @OUT_RECEIVER_ID int DECLARE @OUT_RECEIVER_ID bigint
EXEC [dbo].[PRSIG_API_CREATE_RECEIVER] EXEC [dbo].[PRSIG_API_CREATE_RECEIVER]
{0}, {0},

View File

@@ -138,7 +138,9 @@
SelectedDataItemChanged="@OnSelectedEnvelopeChanged" SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
CustomizeElement="OnCustomizeElement"> CustomizeElement="OnCustomizeElement">
<Columns> <Columns>
<DxGridDataColumn FieldName="Id" Caption="ID"> <DxGridDataColumn FieldName="Id" Caption="ID"
SortIndex="0"
SortOrder="GridColumnSortOrder.Descending">
<CellDisplayTemplate Context="cellContext"> <CellDisplayTemplate Context="cellContext">
@((cellContext.DataItem as EnvelopeDto)?.Id) @((cellContext.DataItem as EnvelopeDto)?.Id)
</CellDisplayTemplate> </CellDisplayTemplate>
@@ -234,7 +236,9 @@
SelectedDataItemChanged="@OnSelectedEnvelopeChanged" SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
CustomizeElement="OnCustomizeElement"> CustomizeElement="OnCustomizeElement">
<Columns> <Columns>
<DxGridDataColumn FieldName="Id" Caption="ID"> <DxGridDataColumn FieldName="Id" Caption="ID"
SortIndex="0"
SortOrder="GridColumnSortOrder.Descending">
<CellDisplayTemplate Context="cellContext"> <CellDisplayTemplate Context="cellContext">
@((cellContext.DataItem as EnvelopeDto)?.Id) @((cellContext.DataItem as EnvelopeDto)?.Id)
</CellDisplayTemplate> </CellDisplayTemplate>

View File

@@ -1,5 +1,6 @@
@page "/" @page "/"
@inject IJSRuntime JS @inject IJSRuntime JS
@inject NavigationManager Navigation
@rendermode InteractiveWebAssembly @rendermode InteractiveWebAssembly
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" /> <link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
@@ -26,7 +27,18 @@
<span id="home-description"></span> <span id="home-description"></span>
</p> </p>
<div class="mt-4 pt-3 border-top"> <div class="mt-4 pt-3 border-top d-flex flex-column align-items-center gap-4">
<button class="home-btn-primary btn px-4 py-2 d-flex align-items-center gap-2" @onclick='() => Navigation.NavigateTo("/sender")'>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
</svg>
Zur Umschlag-Übersicht
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
<div class="d-flex flex-wrap justify-content-center gap-3"> <div class="d-flex flex-wrap justify-content-center gap-3">
<div class="home-feature-badge"> <div class="home-feature-badge">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
@@ -48,6 +60,7 @@
PDF-Export PDF-Export
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -161,7 +161,7 @@
if (result == EnvelopeLoginResult.Success) if (result == EnvelopeLoginResult.Success)
{ {
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/report", forceLoad: true); Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return; return;
} }

View File

@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
using EnvelopeGenerator.Server.Client.Models; using EnvelopeGenerator.Server.Client.Models;
namespace EnvelopeGenerator.Server.Client.Services; namespace EnvelopeGenerator.Server.Client.Services;
@@ -9,6 +10,7 @@ namespace EnvelopeGenerator.Server.Client.Services;
/// <summary> /// <summary>
/// Retrieves the <see cref="EnvelopeReceiverDto"/> for the authenticated receiver /// Retrieves the <see cref="EnvelopeReceiverDto"/> for the authenticated receiver
/// from <c>GET /api/EnvelopeReceiver/{envelopeKey}</c>. /// from <c>GET /api/EnvelopeReceiver/{envelopeKey}</c>.
/// Also creates new envelopes via <c>POST /api/EnvelopeReceiver</c>.
/// </summary> /// </summary>
public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory) public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory)
{ {
@@ -37,4 +39,28 @@ public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory)
return await response.Content.ReadFromJsonAsync<EnvelopeReceiverDto>(_jsonOptions, cancel); return await response.Content.ReadFromJsonAsync<EnvelopeReceiverDto>(_jsonOptions, cancel);
} }
/// <summary>
/// Creates a new envelope with document and receivers via <c>POST /api/EnvelopeReceiver</c>.
/// Requires sender authentication cookie to be present in the request.
/// </summary>
/// <exception cref="HttpRequestException">Thrown when the API request fails.</exception>
public async Task<CreateEnvelopeReceiverResponse?> CreateAsync(
CreateEnvelopeReceiverCommand request,
CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.PostAsJsonAsync("/api/EnvelopeReceiver", request, _jsonOptions, cancel);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancel);
throw new HttpRequestException(
$"Fehler beim Erstellen des Umschlags. Status: {(int)response.StatusCode} – {body}",
null,
response.StatusCode);
}
return await response.Content.ReadFromJsonAsync<CreateEnvelopeReceiverResponse>(_jsonOptions, cancel);
}
} }

View File

@@ -3,15 +3,21 @@
@using DevExpress.Blazor.PdfViewer @using DevExpress.Blazor.PdfViewer
@using DevExpress.Blazor.Reporting.Models @using DevExpress.Blazor.Reporting.Models
@using DevExpress.Blazor @using DevExpress.Blazor
@using EnvelopeGenerator.Application.EnvelopeReceivers.Commands
@using EnvelopeGenerator.Server.Client.Services @using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Server.Services @using EnvelopeGenerator.Server.Services
@using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Forms
@using Microsoft.Extensions.Caching.Memory
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
@inject AppVersionService AppVersion @inject AppVersionService AppVersion
@inject ILogger<EnvelopeSenderEditorPage> Logger @inject ILogger<EnvelopeSenderEditorPage> Logger
@inject EnvelopeReceiverPageDataService ReceiverPageDataService @inject EnvelopeReceiverPageDataService ReceiverPageDataService
@inject EnvelopeReceiverService EnvelopeReceiverService
@inject IMemoryCache MemoryCache
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" /> <link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Viewer/css/dx-blazor-viewer-components.bs5.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" /> <link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script> <script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
@@ -22,14 +28,55 @@
<div class="envelope-action-bar__inner" <div class="envelope-action-bar__inner"
style="flex-direction: row; align-items: center; padding: 0.35rem 1.5rem; gap: 0.75rem;"> style="flex-direction: row; align-items: center; padding: 0.35rem 1.5rem; gap: 0.75rem;">
@* Left: Title *@ @* Left: Title + meta inputs + receivers *@
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0.75rem;"> <div style="flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0.5rem;">
<div style="display: flex; align-items: center; gap: 0.75rem; min-width: 0; flex-wrap: wrap;">
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937; white-space: nowrap;"> @* ── Title & Message inputs ── *@
Neues Dokument <div style="display: flex; align-items: flex-start; gap: 0.5rem; flex-wrap: wrap;">
@* Titel *@
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 1 1 180px; min-width: 140px;">
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.05em;">
Titel <span style="color:#dc2626;">*</span>
</label>
<input type="text"
value="@_envelopeTitle"
@oninput="e => _envelopeTitle = e.Value?.ToString() ?? string.Empty"
placeholder="z.B. Arbeitsvertrag 2025"
maxlength="120"
style="font-size: 0.78rem; font-weight: 500; color: #1f2937;
border: 1px solid #d1d5db; border-radius: 5px;
padding: 0.3rem 0.55rem; outline: none; width: 100%;
background: #fff;
@(_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle) ? "border-color:#dc2626;" : "")" />
@if (_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle))
{
<span style="font-size:0.6rem;color:#dc2626;">Pflichtfeld</span>
}
</div> </div>
@* Nachricht *@
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 2 1 260px; min-width: 180px;">
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.05em;">
Nachricht <span style="color:#6b7280;font-weight:400;">(optional)</span>
</label>
<input type="text"
value="@_envelopeMessage"
@oninput="e => _envelopeMessage = e.Value?.ToString() ?? string.Empty"
placeholder="Bitte unterzeichnen Sie das beigefügte Dokument."
maxlength="500"
style="font-size: 0.78rem; color: #1f2937;
border: 1px solid #d1d5db; border-radius: 5px;
padding: 0.3rem 0.55rem; outline: none; width: 100%;
background: #fff;" />
</div>
@* PDF filename badge *@
@if (_pdfLoaded) @if (_pdfLoaded)
{ {
<div style="display:flex;align-items:flex-end;gap:0.4rem;padding-bottom:0.05rem;flex-shrink:0;">
<span style="font-size: 0.7rem; color: #6b7280;">@_fileName</span> <span style="font-size: 0.7rem; color: #6b7280;">@_fileName</span>
@if (_signatureFields.Count > 0) @if (_signatureFields.Count > 0)
{ {
@@ -39,6 +86,7 @@
@_signatureFields.Count Signaturfeld@(_signatureFields.Count != 1 ? "er" : "") @_signatureFields.Count Signaturfeld@(_signatureFields.Count != 1 ? "er" : "")
</span> </span>
} }
</div>
} }
</div> </div>
@@ -70,7 +118,8 @@
<div class="sender-receivers-list"> <div class="sender-receivers-list">
@foreach (var receiver in _receivers) @foreach (var receiver in _receivers)
{ {
<div class="sender-receiver-chip"> <div class="sender-receiver-chip"
style="border-left: 3px solid @receiver.Color;">
<div class="sender-receiver-chip__body"> <div class="sender-receiver-chip__body">
<div class="sender-receiver-chip__name">@receiver.FullName</div> <div class="sender-receiver-chip__name">@receiver.FullName</div>
<div class="sender-receiver-chip__email">@receiver.Email</div> <div class="sender-receiver-chip__email">@receiver.Email</div>
@@ -80,9 +129,11 @@
} }
</div> </div>
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact" <button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact
@onclick="() => AddSignatureForReceiver(receiver)" @(_pendingReceiverForPlacement?.Id == receiver.Id ? "pdf-toolbar__btn--signature-change-active" : "")"
title="Signatur hinzufügen"> @onclick="() => ActivatePlacementForReceiver(receiver)"
style="@(_pendingReceiverForPlacement?.Id == receiver.Id ? $"background:{receiver.Color};color:#fff;border-color:{receiver.Color};" : $"color:{receiver.Color};border-color:{receiver.Color};")"
title="Signaturfeld platzieren">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" /> <path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" />
</svg> </svg>
@@ -98,6 +149,12 @@
@* Right: Buttons *@ @* Right: Buttons *@
<div style="display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;"> <div style="display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;">
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
@onclick="Cancel"
title="Abbrechen">
Abbrechen
</button>
@* PDF Upload *@ @* PDF Upload *@
<label class="pdf-toolbar__btn" <label class="pdf-toolbar__btn"
title="PDF hochladen" title="PDF hochladen"
@@ -114,23 +171,11 @@
@if (_pdfLoaded) @if (_pdfLoaded)
{ {
@* Toggle placement mode *@
<button class="pdf-toolbar__btn @(_placementMode ? "pdf-toolbar__btn--signature-change-active" : "pdf-toolbar__btn--signature-change")"
@onclick="TogglePlacementMode"
title="@(_placementMode ? "Platzierungsmodus beenden" : "Signaturfeld hinzufügen")">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" />
</svg>
<span class="pdf-toolbar__btn-text">
@(_placementMode ? "Abbrechen" : "+ Signaturfeld")
</span>
</button>
@* Clear all fields *@ @* Clear all fields *@
@if (_signatureFields.Count > 0) @if (_signatureFields.Count > 0)
{ {
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset" <button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
@onclick="ClearAllFields" @onclick="ClearAllFieldsAsync"
title="Alle Signaturfelder entfernen"> title="Alle Signaturfelder entfernen">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" /> <path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
@@ -142,11 +187,20 @@
@* Save *@ @* Save *@
<button class="pdf-toolbar__btn pdf-toolbar__btn--success" <button class="pdf-toolbar__btn pdf-toolbar__btn--success"
@onclick="SaveAsync" @onclick="SaveAsync"
disabled="@_isSaving"
title="Speichern" title="Speichern"
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;"> style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
@if (_isSaving)
{
<span class="spinner-border spinner-border-sm me-1" role="status"
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" 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" /> <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> </svg>
}
Speichern Speichern
</button> </button>
} }
@@ -154,11 +208,15 @@
</div> </div>
@* Placement mode hint bar *@ @* Placement mode hint bar *@
@if (_placementMode) @if (_pendingReceiverForPlacement is not null)
{ {
<div style="background: #4F46E5; color: white; font-size: 0.75rem; font-weight: 500; <div style="background: @_pendingReceiverForPlacement.Color; color: white; font-size: 0.75rem; font-weight: 500;
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;"> padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
📌 Klicken Sie auf die gewünschte Stelle im Dokument, um ein Signaturfeld zu platzieren. 📌 Klicken Sie auf die Stelle im Dokument für <strong>@_pendingReceiverForPlacement.FullName</strong>.
&nbsp;<button @onclick="CancelPlacement"
style="background:none;border:none;color:white;cursor:pointer;font-size:0.75rem;text-decoration:underline;padding:0;">
Abbrechen
</button>
</div> </div>
} }
</div> </div>
@@ -202,63 +260,13 @@
} }
else else
{ {
@* PDF viewer + overlay wrapper *@ @* PDF viewer — click capture active only in placement mode *@
<div id="pdf-editor-wrapper" class="pdf-editor-wrapper" <div class="pdf-editor-wrapper"
style="position: relative; width: 100%; height: 100%; overflow: auto;"> style="cursor: @(_pendingReceiverForPlacement is not null ? "crosshair" : "default");"
@onclick="OnPdfAreaClickAsync">
@* DxPdfViewer — zoom fixed to 1.0 for reliable coordinate mapping *@
<DxPdfViewer @ref="_pdfViewer" <DxPdfViewer @ref="_pdfViewer"
DocumentContent="@_pdfBytes" DocumentContent="@_pdfBytes"
ZoomLevel="1.0"
IsSinglePagePreview="false"
CssClass="sender-editor-pdf-viewer" /> CssClass="sender-editor-pdf-viewer" />
@* Transparent overlay for click capture (active only in placement mode) *@
<div id="pdf-editor-overlay"
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;
z-index: 20;
pointer-events: @(_placementMode ? "auto" : "none");
cursor: @(_placementMode ? "crosshair" : "default");"
@onclick="OnOverlayClickAsync">
@* Render placed signature field placeholders *@
@foreach (var field in _signatureFields)
{
var f = field; // capture for lambda
<div style="position: absolute;
left: @(f.DisplayX.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))px;
top: @(f.DisplayY.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))px;
width: @(SigDisplayW)px;
height: @(SigDisplayH)px;
border: 2px dashed #4F46E5;
background: rgba(79,70,229,0.10);
border-radius: 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
pointer-events: auto;
cursor: default;"
@onclick:stopPropagation="true">
<span style="font-size: 0.6rem; font-weight: 700; color: #4F46E5;
letter-spacing: 0.05em; text-transform: uppercase;">
Unterschrift
</span>
<span style="font-size: 0.55rem; color: #6d28d9; opacity: 0.8;">
S.@f.Page
</span>
<button @onclick="() => RemoveField(f)"
style="position: absolute; top: 2px; right: 4px;
background: none; border: none; cursor: pointer;
color: #6d28d9; font-size: 0.75rem; line-height: 1;
padding: 0; opacity: 0.7;"
title="Entfernen">
×
</button>
</div>
}
</div>
</div> </div>
} }
</div> </div>
@@ -312,7 +320,7 @@
TValue="string" TValue="string"
Data="@_receiverEmailSuggestions" Data="@_receiverEmailSuggestions"
Value="@_selectedReceiverEmailSuggestion" Value="@_selectedReceiverEmailSuggestion"
ValueChanged="OnReceiverEmailSuggestionSelectedAsync" ValueChanged="OnReceiverEmailSuggestionCommittedAsync"
SelectionMode="ListBoxSelectionMode.Single" SelectionMode="ListBoxSelectionMode.Single"
CssClass="w-100" /> CssClass="w-100" />
</div> </div>
@@ -348,25 +356,116 @@
</FooterContentTemplate> </FooterContentTemplate>
</DxPopup> </DxPopup>
@* ── Save result popup (success + error) ── *@
<DxPopup @bind-Visible="_savePopupVisible"
HeaderText="@(_saveErrorMessage is null ? "Umschlag erstellt" : "Fehler")"
Width="460px"
MaxWidth="95vw"
ShowFooter="true"
CloseOnOutsideClick="false"
ShowCloseButton="false"
CloseOnEscape="false">
<BodyContentTemplate>
@if (_saveErrorMessage is null)
{
<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;">
Umschlag wurde erfolgreich erstellt.
</p>
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;">
Der Umschlag wurde gespeichert und die Empfänger wurden benachrichtigt.
</p>
</div>
</div>
}
else
{
<div style="display:flex;align-items:flex-start;gap:1rem;padding:0.5rem 0;">
<div style="flex-shrink:0;width:40px;height:40px;background:#fee2e2;border-radius:50%;
display:flex;align-items:center;justify-content:center;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#991b1b" 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>
<div>
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
Fehler beim Erstellen des Umschlags
</p>
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;word-break:break-word;">
@_saveErrorMessage
</p>
</div>
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
@if (_saveErrorMessage is null)
{
<button class="btn btn-primary"
@onclick="GoToDashboard"
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
Zur Übersicht
</button>
}
else
{
<button class="btn btn-outline-secondary"
@onclick="() => _savePopupVisible = false"
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
Schließen
</button>
}
</div>
</FooterContentTemplate>
</DxPopup>
@code { @code {
// ── Session query param — persists across SignalR reconnects ──
[SupplyParameterFromQuery(Name = "esid")]
public string? Esid { get; set; }
// ── Constants ── // ── Constants ──
// Signature field size in PDF points (fixed): 1.77" × 1.96" × 72 pt/inch // Signature field size in PDF points (fixed): 1.77" × 1.96"
const double SigWidthPt = 1.77 * 72; // 127.44 pt const double SigWidthPt = 1.77 * 72; // 127.44 pt
const double SigHeightPt = 1.96 * 72; // 141.12 pt const double SigHeightPt = 1.96 * 72; // 141.12 pt
// Display size of the overlay placeholder (pixels at zoom=1.0). // CssClass for DxPdfViewer — used by JS to locate page elements
// At zoom=1.0, 1 CSS px ≈ 1 pt in the DxPdfViewer render. const string ViewerCssClass = "sender-editor-pdf-viewer";
const double SigDisplayW = SigWidthPt;
const double SigDisplayH = SigHeightPt; // Cache TTL for editor session (30 min of inactivity)
static readonly TimeSpan SessionTtl = TimeSpan.FromMinutes(30);
// ── State ── // ── State ──
DxPdfViewer? _pdfViewer; DxPdfViewer? _pdfViewer;
byte[]? _pdfBytes;
bool _pdfLoaded = false; bool _pdfLoaded = false;
string _fileName = string.Empty; string _fileName = string.Empty;
string? _errorMessage; string? _errorMessage;
bool _placementMode = false; byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in)
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
List<SignatureFieldDraft> _signatureFields = []; List<SignatureFieldDraft> _signatureFields = [];
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
// ── Save state ──
bool _isSaving = false;
bool _savePopupVisible = false;
string? _saveErrorMessage = null;
// ── Envelope metadata ──
string _envelopeTitle = string.Empty;
string _envelopeMessage = string.Empty;
bool _titleTouched = false;
List<ReceiverDraft> _receivers = []; List<ReceiverDraft> _receivers = [];
bool _receiverPopupVisible; bool _receiverPopupVisible;
string _receiverDraftName = string.Empty; string _receiverDraftName = string.Empty;
@@ -377,8 +476,55 @@
bool _isReceiverEmailSearchRunning; bool _isReceiverEmailSearchRunning;
List<string> _receiverEmailSuggestions = []; List<string> _receiverEmailSuggestions = [];
int _receiverEmailSearchVersion; int _receiverEmailSearchVersion;
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new(); static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
// ── Cache key helper ──
string SessionKey => $"sender-editor:{Esid}";
// ── Lifecycle ──
protected override void OnInitialized()
{
// Intentionally empty.
// esid redirect is done in OnAfterRenderAsync to avoid NavigationException
// during SSR prerendering, where NavigateTo throws by design.
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
// Generate a session id on first interactive render and push it into the URL.
// At this point SignalR is connected and NavigateTo is safe.
if (string.IsNullOrWhiteSpace(Esid))
{
var sid = Guid.NewGuid().ToString("N");
NavigationManager.NavigateTo($"/sender/editor?esid={sid}", forceLoad: false);
}
}
protected override void OnParametersSet()
{
// After the esid is set via query param, try to restore from cache.
if (!string.IsNullOrWhiteSpace(Esid)
&& MemoryCache.TryGetValue(SessionKey, out EditorSessionData? cached)
&& cached is not null)
{
_originalPdfBytes = cached.OriginalPdfBytes;
_signatureFields = cached.Fields;
_fileName = cached.FileName;
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
_receivers = cached.Receivers;
_envelopeTitle = cached.Title;
_envelopeMessage = cached.Message;
// Redraw placeholders onto the original PDF
if (_pdfLoaded)
_pdfBytes = DrawPlaceholders(_originalPdfBytes!, _signatureFields);
}
}
// ── PDF upload ── // ── PDF upload ──
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e) async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
{ {
@@ -394,17 +540,22 @@
try try
{ {
// Max 50 MB
const long maxBytes = 50 * 1024 * 1024; const long maxBytes = 50 * 1024 * 1024;
using var ms = new System.IO.MemoryStream(); using var ms = new System.IO.MemoryStream();
await file.OpenReadStream(maxBytes).CopyToAsync(ms); await file.OpenReadStream(maxBytes).CopyToAsync(ms);
_pdfBytes = ms.ToArray();
_originalPdfBytes = ms.ToArray();
_fileName = file.Name; _fileName = file.Name;
_pdfLoaded = true; _pdfLoaded = true;
_signatureFields.Clear(); _signatureFields.Clear();
_placementMode = false; _pendingReceiverForPlacement = null;
Logger.LogInformation("PDF loaded: {Name} ({Size} bytes)", _fileName, _pdfBytes.Length); // Rendered PDF starts as the clean original (no placeholders yet)
_pdfBytes = _originalPdfBytes;
PersistSession();
Logger.LogInformation("PDF loaded: {Name} ({Size} bytes)", _fileName, _originalPdfBytes.Length);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -414,79 +565,300 @@
} }
// ── Placement mode ── // ── Placement mode ──
void TogglePlacementMode() => _placementMode = !_placementMode; void ActivatePlacementForReceiver(ReceiverDraft receiver)
void ClearAllFields()
{ {
_signatureFields.Clear(); // Toggle: clicking the same receiver again cancels placement
_placementMode = false; _pendingReceiverForPlacement = _pendingReceiverForPlacement?.Id == receiver.Id
? null
: receiver;
} }
void RemoveField(SignatureFieldDraft field) => _signatureFields.Remove(field); void CancelPlacement() => _pendingReceiverForPlacement = null;
// ── Overlay click → add signature field ── // ── PDF area click → place field ──
async Task OnOverlayClickAsync(MouseEventArgs e) async Task OnPdfAreaClickAsync(MouseEventArgs e)
{ {
if (!_placementMode) return; if (_pendingReceiverForPlacement is null) return;
if (!_pdfLoaded || _originalPdfBytes is null) return;
// Get overlay container bounds via JS // Ask JS for the normalised click position within the rendered PDF page
var coords = await JSRuntime.InvokeAsync<OverlayCoords>( var coords = await JSRuntime.InvokeAsync<NormalisedCoords?>(
"envelopeEditor.getClickCoords", "pdf-editor-overlay", "envelopeEditor.getClickCoordsOnPdfPage",
e.ClientX, e.ClientY); ViewerCssClass, e.ClientX, e.ClientY);
if (coords is null) return; if (coords is null)
// At zoom=1.0: container pixels ≈ PDF display pixels.
// DxPdfViewer renders at 96 dpi by default; PDF points = 72 dpi.
// Scale factor: 96/72 = 1.333 → px / 1.333 = pt
const double pxToPt = 72.0 / 96.0;
double xPt = coords.RelX * pxToPt;
double yPt = coords.RelY * pxToPt;
// Active page: DxPdfViewer.ActivePageIndex is 0-based
int page = (_pdfViewer?.ActivePageIndex ?? 0) + 1;
// Display position (px on overlay) — keep in px for CSS
double displayX = coords.RelX;
double displayY = coords.RelY;
// Prevent placing outside bounds
if (displayX < 0 || displayY < 0) return;
if (displayX + SigDisplayW > coords.ContainerW) displayX = coords.ContainerW - SigDisplayW;
if (displayY + SigDisplayH > coords.ContainerH) displayY = coords.ContainerH - SigDisplayH;
var field = new SignatureFieldDraft(xPt, yPt, page, displayX, displayY);
_signatureFields.Add(field);
Logger.LogInformation(
"Signature field added: Page={Page} X={X:F1}pt Y={Y:F1}pt",
page, xPt, yPt);
// Exit placement mode after one click (user can re-click button for next)
_placementMode = false;
}
// ── Save ──
async Task SaveAsync()
{ {
if (_signatureFields.Count == 0) Logger.LogWarning("[SenderEditor] getClickCoordsOnPdfPage returned null");
{
await JSRuntime.InvokeVoidAsync("console.log",
"[SenderEditor] No signature fields to save.");
return; return;
} }
foreach (var f in _signatureFields) // Read page dimensions from the original PDF via PdfSharp
double pageWidthPt;
double pageHeightPt;
try
{ {
await JSRuntime.InvokeVoidAsync("console.log", using var ms = new System.IO.MemoryStream(_originalPdfBytes);
$"[SenderEditor] Field: Page={f.Page} X={f.XPt:F2}pt ({f.XPt/72:F3}in) Y={f.YPt:F2}pt ({f.YPt/72:F3}in)"); var doc = PdfSharp.Pdf.IO.PdfReader.Open(ms, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Import);
int pageIndex = Math.Max(0, Math.Min(coords.PageIndex, doc.PageCount - 1));
var page = doc.Pages[pageIndex];
pageWidthPt = page.Width.Point;
pageHeightPt = page.Height.Point;
}
catch (Exception ex)
{
Logger.LogError(ex, "[SenderEditor] Failed to read page dimensions from PDF");
return;
} }
await JSRuntime.InvokeVoidAsync("console.log", // Convert normalised [0,1] → PDF points; clamp so box stays inside page
$"[SenderEditor] Total fields: {_signatureFields.Count}"); double xPt = coords.NormX * pageWidthPt;
double yPt = coords.NormY * pageHeightPt;
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - SigWidthPt));
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - SigHeightPt));
int page1Based = coords.PageIndex + 1;
var field = new SignatureFieldDraft(
XPt: xPt,
YPt: yPt,
Page: page1Based,
ReceiverName: _pendingReceiverForPlacement.FullName,
Color: _pendingReceiverForPlacement.Color);
_signatureFields.Add(field);
_pendingReceiverForPlacement = null;
// Burn all placeholders onto the original PDF and update the viewer
_pdfBytes = DrawPlaceholders(_originalPdfBytes, _signatureFields);
PersistSession();
Logger.LogInformation(
"[SenderEditor] Field added: Page={Page} X={X:F1}pt Y={Y:F1}pt Receiver={Receiver}",
page1Based, xPt, yPt, field.ReceiverName);
} }
// ── Remove a single field ──
async Task RemoveFieldAsync(SignatureFieldDraft field)
{
_signatureFields.Remove(field);
_pdfBytes = _originalPdfBytes is not null
? DrawPlaceholders(_originalPdfBytes, _signatureFields)
: _pdfBytes;
PersistSession();
await Task.CompletedTask;
}
// ── Clear all fields ──
async Task ClearAllFieldsAsync()
{
_signatureFields.Clear();
_pendingReceiverForPlacement = null;
_pdfBytes = _originalPdfBytes;
PersistSession();
await Task.CompletedTask;
}
void Cancel() => NavigationManager.NavigateTo("/sender");
void GoToDashboard()
{
// Clear session from cache on successful save so it is not restored if user returns
if (!string.IsNullOrWhiteSpace(Esid))
MemoryCache.Remove(SessionKey);
NavigationManager.NavigateTo("/sender");
}
// ── Save — POST /api/EnvelopeReceiver ──
async Task SaveAsync()
{
// ── Validation ──
_titleTouched = true;
if (!_pdfLoaded || _originalPdfBytes is null)
{
_saveErrorMessage = "Bitte laden Sie zuerst ein PDF-Dokument hoch.";
_savePopupVisible = true;
return;
}
if (string.IsNullOrWhiteSpace(_envelopeTitle))
{
_saveErrorMessage = "Bitte geben Sie einen Titel ein.";
_savePopupVisible = true;
return;
}
if (_receivers.Count == 0)
{
_saveErrorMessage = "Bitte fügen Sie mindestens einen Empfänger hinzu.";
_savePopupVisible = true;
return;
}
if (_signatureFields.Count == 0)
{
_saveErrorMessage = "Bitte platzieren Sie mindestens ein Signaturfeld.";
_savePopupVisible = true;
return;
}
// Warn if any receiver has no signature field, but don't block
var receiversWithoutField = _receivers
.Where(r => !_signatureFields.Any(f => f.ReceiverName == r.FullName))
.ToList();
if (receiversWithoutField.Count > 0)
{
Logger.LogWarning(
"[SenderEditor] Receivers without signature field: {Names}",
string.Join(", ", receiversWithoutField.Select(r => r.FullName)));
}
_isSaving = true;
_saveErrorMessage = null;
await InvokeAsync(StateHasChanged);
try
{
// ── Build request ──
// Document: use the ORIGINAL pdf (without placeholder burn-in) as base64
var docBase64 = Convert.ToBase64String(_originalPdfBytes);
// Build receivers list — each receiver gets their own signature fields
// Coordinate conversion: PDF points → inches (DB stores inches)
var receiversCmd = _receivers.Select(receiver =>
{
var fields = _signatureFields
.Where(f => f.ReceiverName == receiver.FullName)
.Select(f => new DocReceiverElementCreateDto(
X: f.XPt / 72.0,
Y: f.YPt / 72.0,
Page: f.Page))
.ToList();
return new ReceiverGetOrCreateCommand
{
EmailAddress = receiver.Email,
Salution = receiver.FullName,
PhoneNumber = string.IsNullOrWhiteSpace(receiver.PhoneNumber)
? null
: receiver.PhoneNumber,
DocReceiverElements = fields,
};
}).ToList();
var command = new CreateEnvelopeReceiverCommand
{
Title = _envelopeTitle.Trim(),
Message = string.IsNullOrWhiteSpace(_envelopeMessage)
? "Bitte unterzeichnen Sie das beigefügte Dokument."
: _envelopeMessage.Trim(),
TFAEnabled = false,
Document = new DocumentCreateCommand { DataAsBase64 = docBase64 },
Receivers = receiversCmd,
};
var result = await EnvelopeReceiverService.CreateAsync(command);
Logger.LogInformation(
"[SenderEditor] Envelope created. Id={Id} SentReceivers={Count}",
result?.Id, result?.SentReceiver.Count());
// Success — show popup; GoToDashboard clears cache and navigates
_saveErrorMessage = null;
_savePopupVisible = true;
}
catch (Exception ex)
{
Logger.LogError(ex, "[SenderEditor] Failed to create envelope");
_saveErrorMessage = ex.Message;
_savePopupVisible = true;
}
finally
{
_isSaving = false;
}
}
// ── Cache persistence ──
void PersistSession()
{
if (string.IsNullOrWhiteSpace(Esid)) return;
var data = new EditorSessionData(
OriginalPdfBytes: _originalPdfBytes ?? [],
Fields: [.. _signatureFields],
FileName: _fileName,
Receivers: [.. _receivers],
Title: _envelopeTitle,
Message: _envelopeMessage);
MemoryCache.Set(SessionKey, data, SessionTtl);
}
// ── PdfSharp: burn all placeholder boxes onto the original PDF ──
static byte[] DrawPlaceholders(byte[] originalPdf, IReadOnlyList<SignatureFieldDraft> fields)
{
if (fields.Count == 0) return originalPdf;
using var inputMs = new System.IO.MemoryStream(originalPdf);
using var outputMs = new System.IO.MemoryStream();
var document = PdfSharp.Pdf.IO.PdfReader.Open(
inputMs, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Modify);
foreach (var field in fields)
{
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);
// Derive colours from the receiver's hex colour
var fillBrush = new PdfSharp.Drawing.XSolidBrush(HexToXColor(field.Color, alpha: 35));
var borderPen = new PdfSharp.Drawing.XPen(HexToXColor(field.Color, alpha: 210), 1.5);
var textBrush = new PdfSharp.Drawing.XSolidBrush(HexToXColor(field.Color, alpha: 200));
var nameBrush = new PdfSharp.Drawing.XSolidBrush(HexToXColor(field.Color, alpha: 230));
var fontLabel = new PdfSharp.Drawing.XFont("Arial", 9, PdfSharp.Drawing.XFontStyleEx.Bold);
var fontName = new PdfSharp.Drawing.XFont("Arial", 7, PdfSharp.Drawing.XFontStyleEx.Regular);
var fmtCenter = new PdfSharp.Drawing.XStringFormat
{
Alignment = PdfSharp.Drawing.XStringAlignment.Center,
LineAlignment = PdfSharp.Drawing.XLineAlignment.Center,
};
var rect = new PdfSharp.Drawing.XRect(field.XPt, field.YPt, SigWidthPt, SigHeightPt);
gfx.DrawRectangle(fillBrush, rect);
gfx.DrawRectangle(borderPen, rect);
// "UNTERSCHRIFT" label centred in upper two-thirds
var labelRect = new PdfSharp.Drawing.XRect(
field.XPt, field.YPt, SigWidthPt, SigHeightPt * 0.65);
gfx.DrawString("UNTERSCHRIFT", fontLabel, textBrush, labelRect, fmtCenter);
// Receiver name centred in lower third
var nameRect = new PdfSharp.Drawing.XRect(
field.XPt + 4, field.YPt + SigHeightPt * 0.68,
SigWidthPt - 8, SigHeightPt * 0.30);
var displayName = field.ReceiverName.Length > 22
? field.ReceiverName[..19] + "..."
: field.ReceiverName;
gfx.DrawString(displayName, fontName, nameBrush, nameRect, fmtCenter);
}
document.Save(outputMs);
return outputMs.ToArray();
}
/// <summary>Converts a CSS hex colour string (e.g. "#4F46E5") to a PdfSharp XColor.</summary>
static PdfSharp.Drawing.XColor HexToXColor(string hex, int alpha)
{
var c = System.Drawing.ColorTranslator.FromHtml(hex);
return PdfSharp.Drawing.XColor.FromArgb(alpha, c.R, c.G, c.B);
}
// ── Receiver popup ──
void OpenAddReceiverPopup() void OpenAddReceiverPopup()
{ {
_receiverDraftName = string.Empty; _receiverDraftName = string.Empty;
@@ -522,37 +894,32 @@
{ {
if (_receiverEmailSuggestions.Count == 0) if (_receiverEmailSuggestions.Count == 0)
{ {
if (e.Key == "Escape") if (e.Key == "Escape") _selectedReceiverEmailSuggestion = null;
_selectedReceiverEmailSuggestion = null;
return; return;
} }
var currentIndex = _selectedReceiverEmailSuggestion is null var currentIndex = _selectedReceiverEmailSuggestion is null
? -1 ? -1
: _receiverEmailSuggestions.FindIndex(email => string.Equals(email, _selectedReceiverEmailSuggestion, StringComparison.OrdinalIgnoreCase)); : _receiverEmailSuggestions.FindIndex(em =>
string.Equals(em, _selectedReceiverEmailSuggestion, StringComparison.OrdinalIgnoreCase));
if (e.Key == "ArrowDown") if (e.Key == "ArrowDown")
{ {
var nextIndex = currentIndex < _receiverEmailSuggestions.Count - 1 ? currentIndex + 1 : 0; var next = currentIndex < _receiverEmailSuggestions.Count - 1 ? currentIndex + 1 : 0;
await OnReceiverEmailSuggestionSelectedAsync(_receiverEmailSuggestions[nextIndex]); SelectReceiverEmailSuggestion(_receiverEmailSuggestions[next]);
} }
else if (e.Key == "ArrowUp") else if (e.Key == "ArrowUp")
{ {
var nextIndex = currentIndex > 0 ? currentIndex - 1 : _receiverEmailSuggestions.Count - 1; var next = currentIndex > 0 ? currentIndex - 1 : _receiverEmailSuggestions.Count - 1;
await OnReceiverEmailSuggestionSelectedAsync(_receiverEmailSuggestions[nextIndex]); SelectReceiverEmailSuggestion(_receiverEmailSuggestions[next]);
} }
else if (e.Key == "Enter") else if (e.Key == "Enter")
{ {
var selectedValue = currentIndex >= 0 && currentIndex < _receiverEmailSuggestions.Count var sel = currentIndex >= 0 && currentIndex < _receiverEmailSuggestions.Count
? _receiverEmailSuggestions[currentIndex] ? _receiverEmailSuggestions[currentIndex]
: _receiverEmailSuggestions.FirstOrDefault(); : _receiverEmailSuggestions.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(sel))
if (!string.IsNullOrWhiteSpace(selectedValue)) await OnReceiverEmailSuggestionCommittedAsync(sel);
{
await OnReceiverEmailSuggestionSelectedAsync(selectedValue);
_receiverEmailSuggestions.Clear();
}
} }
else if (e.Key == "Escape") else if (e.Key == "Escape")
{ {
@@ -561,14 +928,18 @@
} }
} }
Task OnReceiverEmailSuggestionSelectedAsync(string? value) void SelectReceiverEmailSuggestion(string? value)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value)) return;
return Task.CompletedTask;
_selectedReceiverEmailSuggestion = value.Trim(); _selectedReceiverEmailSuggestion = value.Trim();
_receiverDraftEmail = _selectedReceiverEmailSuggestion; _receiverDraftEmail = _selectedReceiverEmailSuggestion;
_receiverPopupValidationMessage = null; _receiverPopupValidationMessage = null;
}
Task OnReceiverEmailSuggestionCommittedAsync(string? value)
{
SelectReceiverEmailSuggestion(value);
_receiverEmailSuggestions.Clear();
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -593,19 +964,17 @@
try try
{ {
var results = await ReceiverPageDataService.SearchReceiverEMailsAsync(_receiverDraftEmail); var results = await ReceiverPageDataService.SearchReceiverEMailsAsync(_receiverDraftEmail);
if (searchVersion != _receiverEmailSearchVersion) return;
if (searchVersion != _receiverEmailSearchVersion)
return;
_receiverEmailSuggestions = results _receiverEmailSuggestions = results
.Where(email => !string.IsNullOrWhiteSpace(email)) .Where(em => !string.IsNullOrWhiteSpace(em))
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(email => email) .OrderBy(em => em)
.Take(12) .Take(12)
.ToList(); .ToList();
_selectedReceiverEmailSuggestion = _receiverEmailSuggestions.FirstOrDefault(email => _selectedReceiverEmailSuggestion = _receiverEmailSuggestions.FirstOrDefault(em =>
string.Equals(email, _receiverDraftEmail, StringComparison.OrdinalIgnoreCase)); string.Equals(em, _receiverDraftEmail, StringComparison.OrdinalIgnoreCase));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -634,50 +1003,54 @@
_receiverPopupValidationMessage = "Bitte geben Sie einen Vor- und Nachnamen ein."; _receiverPopupValidationMessage = "Bitte geben Sie einen Vor- und Nachnamen ein.";
return Task.CompletedTask; return Task.CompletedTask;
} }
if (string.IsNullOrWhiteSpace(email)) if (string.IsNullOrWhiteSpace(email))
{ {
_receiverPopupValidationMessage = "Bitte geben Sie eine E-Mail-Adresse ein."; _receiverPopupValidationMessage = "Bitte geben Sie eine E-Mail-Adresse ein.";
return Task.CompletedTask; return Task.CompletedTask;
} }
if (!ReceiverEmailValidator.IsValid(email)) if (!ReceiverEmailValidator.IsValid(email))
{ {
_receiverPopupValidationMessage = "Bitte geben Sie eine gültige E-Mail-Adresse ein."; _receiverPopupValidationMessage = "Bitte geben Sie eine gültige E-Mail-Adresse ein.";
return Task.CompletedTask; return Task.CompletedTask;
} }
if (_receivers.Any(r => string.Equals(r.Email, email, StringComparison.OrdinalIgnoreCase)))
if (_receivers.Any(receiver => string.Equals(receiver.Email, email, StringComparison.OrdinalIgnoreCase)))
{ {
_receiverPopupValidationMessage = "Diese E-Mail-Adresse wurde bereits hinzugefügt."; _receiverPopupValidationMessage = "Diese E-Mail-Adresse wurde bereits hinzugefügt.";
return Task.CompletedTask; return Task.CompletedTask;
} }
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber)); _receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber,
ReceiverPalette[_receivers.Count % ReceiverPalette.Length]));
PersistSession();
CloseAddReceiverPopup(); CloseAddReceiverPopup();
return Task.CompletedTask; return Task.CompletedTask;
} }
void AddSignatureForReceiver(ReceiverDraft receiver)
{
Logger.LogInformation("Signature placement requested for receiver {Email}", receiver.Email);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!_pdfLoaded || _errorMessage is not null)
return;
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.syncOverlayToPage",
"pdf-editor-wrapper",
"pdf-editor-overlay");
}
// ── Models ── // ── Models ──
record SignatureFieldDraft(double XPt, double YPt, int Page, double DisplayX, double DisplayY); record SignatureFieldDraft(double XPt, double YPt, int Page, string ReceiverName, string Color);
record OverlayCoords(double RelX, double RelY, double ContainerW, double ContainerH); record NormalisedCoords(double NormX, double NormY, int PageIndex);
record ReceiverDraft(Guid Id, string FullName, string Email, string PhoneNumber); record ReceiverDraft(Guid Id, string FullName, string Email, string PhoneNumber, string Color);
record EditorSessionData(
byte[] OriginalPdfBytes,
List<SignatureFieldDraft> Fields,
string FileName,
List<ReceiverDraft> Receivers,
string Title,
string Message);
// ── Receiver colour palette (cycles when > 8 receivers) ──
static readonly string[] ReceiverPalette =
[
"#4F46E5", // indigo
"#059669", // emerald
"#DC2626", // red
"#D97706", // amber
"#7C3AED", // violet
"#0891B2", // cyan
"#BE185D", // pink
"#65A30D", // lime
];
} }

View File

@@ -1,4 +1,4 @@
@page "/envelope/{EnvelopeKey}/report" @page "/envelope/{EnvelopeKey}"
@rendermode InteractiveServer @rendermode InteractiveServer
@using DevExpress.Blazor.Reporting @using DevExpress.Blazor.Reporting
@using DevExpress.XtraReports.UI @using DevExpress.XtraReports.UI
@@ -18,7 +18,7 @@
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService @inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject AppVersionService AppVersion @inject AppVersionService AppVersion
@inject IMemoryCache MemoryCache @inject IMemoryCache MemoryCache
@inject ILogger<EnvelopeReceiverReportPage> Logger @inject ILogger<ReceiverPage> Logger
@implements IDisposable @implements IDisposable
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" /> <link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />

View File

@@ -14,7 +14,7 @@
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService @inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject AppVersionService AppVersion @inject AppVersionService AppVersion
@inject IMemoryCache MemoryCache @inject IMemoryCache MemoryCache
@inject ILogger<EnvelopeReceiverReportSignedPage> Logger @inject ILogger<ReceiverSignedPage> Logger
@implements IDisposable @implements IDisposable
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" /> <link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
@@ -205,7 +205,7 @@
"[SignedPage] Cache miss or no sid={Sid} for {EnvelopeKey}, redirecting to report page.", "[SignedPage] Cache miss or no sid={Sid} for {EnvelopeKey}, redirecting to report page.",
Sid, EnvelopeKey); Sid, EnvelopeKey);
Navigation.NavigateTo( Navigation.NavigateTo(
$"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/report", $"/envelope/{Uri.EscapeDataString(EnvelopeKey)}",
forceLoad: true); forceLoad: true);
return; return;
} }

View File

@@ -1,4 +1,4 @@
@page "/envelope/{EnvelopeKey}" @page "/envelope/{EnvelopeKey}/deprc"
@rendermode InteractiveServer @rendermode InteractiveServer
@using EnvelopeGenerator.Server.Client.Models @using EnvelopeGenerator.Server.Client.Models
@using EnvelopeGenerator.Server.Client.Models.Constants @using EnvelopeGenerator.Server.Client.Models.Constants

View File

@@ -40,7 +40,7 @@ public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions,
/// <response code="401">Wenn es kein zugelassenes Cookie gibt, wird „nicht zugelassen“ zurückgegeben.</response> /// <response code="401">Wenn es kein zugelassenes Cookie gibt, wird „nicht zugelassen“ zurückgegeben.</response>
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)] [ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)] [ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[Authorize(Policy = AuthPolicy.Sender)] [Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpPost("logout")] [HttpPost("logout")]
public IActionResult Logout() public IActionResult Logout()
{ {

View File

@@ -30,7 +30,7 @@ public class DocumentController(IMediator mediator, IAuthorizationService authSe
/// <param name="query">Encoded envelope key.</param> /// <param name="query">Encoded envelope key.</param>
/// <param name="cancel">Cancellation token.</param> /// <param name="cancel">Cancellation token.</param>
[HttpGet] [HttpGet]
[Authorize(Policy = AuthPolicy.Sender)] [Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery? query = null) public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery? query = null)
{ {
if (query is null) if (query is null)

View File

@@ -24,7 +24,7 @@ namespace EnvelopeGenerator.Server.Controllers;
/// </param> /// </param>
[Route("api/[controller]")] [Route("api/[controller]")]
[ApiController] [ApiController]
[Authorize(Policy = AuthPolicy.Sender)] [Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public class EmailTemplateController(IMediator mediator) : ControllerBase public class EmailTemplateController(IMediator mediator) : ControllerBase
{ {
/// <summary> /// <summary>

View File

@@ -150,7 +150,7 @@ public class EnvelopeReceiverController : ControllerBase
/// <response code="400">Wenn ein Fehler im HTTP-Body auftritt</response> /// <response code="400">Wenn ein Fehler im HTTP-Body auftritt</response>
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response> /// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
/// <response code="500">Es handelt sich um einen unerwarteten Fehler. Die Protokolle sollten überprüft werden.</response> /// <response code="500">Es handelt sich um einen unerwarteten Fehler. Die Protokolle sollten überprüft werden.</response>
[Authorize] [Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpPost] [HttpPost]
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeReceiverCommand request, CancellationToken cancel) public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeReceiverCommand request, CancellationToken cancel)
{ {
@@ -214,6 +214,10 @@ public class EnvelopeReceiverController : ControllerBase
if (reader.Read()) if (reader.Read())
{ {
bool outSuccess = reader.GetBoolean(0); bool outSuccess = reader.GetBoolean(0);
if (!outSuccess)
_logger.LogWarning(
"PRSIG_API_ADD_DOC_RECEIVER_ELEM returned OUT_SUCCESS=false. DOC_ID={DocId}, RECEIVER_ID={ReceiverId}, Page={Page}",
document.Id, rcv.Id, sign.Page);
} }
} }
#endregion #endregion
@@ -221,8 +225,6 @@ public class EnvelopeReceiverController : ControllerBase
#region Create history #region Create history
// ENV_UID, STATUS_ID, USER_ID, // ENV_UID, STATUS_ID, USER_ID,
string sql_hist = @" string sql_hist = @"
USE [DD_ECM]
DECLARE @OUT_SUCCESS bit; DECLARE @OUT_SUCCESS bit;
EXEC [dbo].[PRSIG_API_ADD_HISTORY_STATE] EXEC [dbo].[PRSIG_API_ADD_HISTORY_STATE]
@@ -244,6 +246,10 @@ public class EnvelopeReceiverController : ControllerBase
if (reader.Read()) if (reader.Read())
{ {
bool outSuccess = reader.GetBoolean(0); bool outSuccess = reader.GetBoolean(0);
if (!outSuccess)
_logger.LogWarning(
"PRSIG_API_ADD_HISTORY_STATE returned OUT_SUCCESS=false. EnvelopeUuid={EnvelopeUuid}",
envelope.Uuid);
} }
} }
#endregion #endregion

View File

@@ -1,9 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFrameworks>net8.0</TargetFrameworks>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageId>EnvelopeGenerator.Server</PackageId>
<Title></Title>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>EnvelopeGenerator.Server</Product>
<Version>1.0.1-beta</Version>
<FileVersion>1.0.1.0</FileVersion>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -0,0 +1,33 @@
namespace EnvelopeGenerator.Server.Handlers;
/// <summary>
/// A <see cref="DelegatingHandler"/> that forwards the incoming HTTP request's
/// <c>Cookie</c> header to all outgoing <see cref="System.Net.Http.HttpClient"/> calls
/// made by Blazor Server components.
///
/// Problem it solves:
/// Blazor Server runs on the server process. When a component calls an API endpoint
/// that requires cookie-based JWT authentication (AuthScheme.Sender), the HttpClient
/// does not automatically include the browser's cookies — those only travel with
/// browser-initiated requests. This handler copies the <c>Cookie</c> header from the
/// current <see cref="IHttpContextAccessor.HttpContext"/> into every outgoing request
/// so that the API's JwtBearer <c>OnMessageReceived</c> callback can extract the token.
///
/// Thread safety:
/// The handler is registered as Transient and is resolved per-request by the
/// IHttpClientFactory pipeline, so there is no shared state between requests.
/// </summary>
public class SenderAuthCookieHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var cookieHeader = httpContextAccessor.HttpContext?.Request.Headers["Cookie"].ToString();
if (!string.IsNullOrWhiteSpace(cookieHeader))
request.Headers.TryAddWithoutValidation("Cookie", cookieHeader);
return base.SendAsync(request, cancellationToken);
}
}

View File

@@ -69,6 +69,9 @@ try
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
// Named HttpClient for internal API calls // Named HttpClient for internal API calls
// SenderAuthCookieHandler forwards the browser's Cookie header so that
// Blazor Server components can call cookie-authenticated endpoints (AuthScheme.Sender).
builder.Services.AddTransient<EnvelopeGenerator.Server.Handlers.SenderAuthCookieHandler>();
builder.Services.AddHttpClient("EnvelopeGenerator.Server", (sp, client) => builder.Services.AddHttpClient("EnvelopeGenerator.Server", (sp, client) =>
{ {
var httpContextAccessor = sp.GetRequiredService<IHttpContextAccessor>(); var httpContextAccessor = sp.GetRequiredService<IHttpContextAccessor>();
@@ -79,7 +82,8 @@ try
// Set base address to current host for SSR scenarios // Set base address to current host for SSR scenarios
client.BaseAddress = new Uri($"{request.Scheme}://{request.Host}"); client.BaseAddress = new Uri($"{request.Scheme}://{request.Host}");
} }
}); })
.AddHttpMessageHandler<EnvelopeGenerator.Server.Handlers.SenderAuthCookieHandler>();
// CORS Policy // CORS Policy
var allowedOrigins = config.GetSection("AllowedOrigins").Get<string[]>() ?? var allowedOrigins = config.GetSection("AllowedOrigins").Get<string[]>() ??

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<WebPublishMethod>Package</WebPublishMethod>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<SiteUrlToLaunchAfterPublish />
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>5e0e17c0-ff5a-4246-bf87-1add85376a27</ProjectGuid>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\signFLOW\API\net8\$(Version)\EnvelopeGenerator.Server.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>EnvelopeGenerator</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId>
<TargetFramework>net8.0</TargetFramework>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<EnvironmentName>Production</EnvironmentName>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,225 @@
# EnvelopeGenerator.Server — Publish & Deployment Guide
## Inhaltsverzeichnis
1. [Unterschied zu einer normalen ASP.NET Core API](#unterschied-zu-einer-normalen-aspnet-core-api)
2. [Warum Self-Contained Publish?](#warum-self-contained-publish)
3. [Publish-Befehl (Terminal)](#publish-befehl-terminal)
4. [IIS-Konfiguration](#iis-konfiguration)
5. [Verzeichnisstruktur nach dem Publish](#verzeichnisstruktur-nach-dem-publish)
6. [Häufige Fehler](#häufige-fehler)
---
## Unterschied zu einer normalen ASP.NET Core API
`EnvelopeGenerator.Server` ist **keine** gewöhnliche ASP.NET Core Web API. Es handelt sich um eine **Blazor Auto (Server + WebAssembly Hybrid)**-Anwendung.
| Merkmal | Normale ASP.NET Core API | EnvelopeGenerator.Server (Blazor Auto) |
|---|---|---|
| Projekttyp | `Microsoft.NET.Sdk.Web` | `Microsoft.NET.Sdk.Web` + WASM Client |
| Frontend | Keins / Razor Pages | Blazor Server + Blazor WASM |
| WASM-Komponente | Nein | Ja (`EnvelopeGenerator.Server.Client`) |
| Framework-DLL-Bindung | Tolerant gegenüber Runtime-Versionen | **Strikt**: WASM erwartet exakte Assembly-Versionen |
| Publish ohne .NET auf Zielserver | Nicht nötig (FDD reicht meist) | **Self-Contained Pflicht** empfohlen |
| IIS Application Pool | `.NET CLR v4.0` oder `No Managed Code` | **Zwingend: `No Managed Code`** |
| Publish-Paketgröße | ~5–20 MB | **~500 MB** (enthält .NET Runtime) |
| `web.config processPath` | `dotnet` + `.dll` | **`.\EnvelopeGenerator.Server.exe`** |
### Warum die WASM-Komponente den Unterschied macht
Die WASM-Seite der Anwendung (`EnvelopeGenerator.Server.Client`) bindet Assemblies wie
`Microsoft.Extensions.DependencyInjection.Abstractions` in einer **fest definierten Version**.
Bei einem **Framework-Dependent Deployment** werden diese Assemblies nicht mitgeliefert und
müssen auf dem Zielserver vorhanden sein — in der exakt passenden Version.
Fehlt die passende .NET-Runtime auf dem Zielserver, erscheint folgender Fehler beim Start:
```
Unhandled exception. System.IO.FileNotFoundException:
Could not load file or assembly
'Microsoft.Extensions.DependencyInjection.Abstractions, Version=8.0.0.0'
```
---
## Warum Self-Contained Publish?
Beim **Self-Contained Deployment** werden **alle benötigten .NET Runtime-DLLs** in das
Ausgabeverzeichnis kopiert. Die Anwendung ist damit vollständig unabhängig von der auf dem
Zielserver installierten .NET-Version.
| | Framework-Dependent | Self-Contained |
|---|---|---|
| .NET auf Zielserver nötig | Ja | **Nein** |
| Paketgröße | ~20 MB | ~500 MB |
| `runtimeconfig.json` | `frameworkVersion` vorhanden | `includedFrameworks` (Runtime eingebettet) |
| Fehleranfälligkeit auf Fremd-PC | Hoch | Minimal |
---
## Publish-Befehl (Terminal)
### Empfohlener Befehl (Self-Contained, win-x64)
```bat
dotnet publish EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj ^
-c Release ^
-f net8.0 ^
--self-contained true ^
--runtime win-x64 ^
-o .\publish-output
```
> Dieser Befehl muss vom **Solution-Root-Verzeichnis** aus ausgefuehrt werden.
> Alternativ: `publish.bat` im selben Verzeichnis wie diese README ausfuehren.
### Parameter-Erklaerung
| Parameter | Bedeutung |
|---|---|
| `-c Release` | Release-Konfiguration (optimiert, kein Debug-Code) |
| `-f net8.0` | Ziel-Framework explizit angeben (Pflicht, da `<TargetFrameworks>` mehrere Werte haben kann) |
| `--self-contained true` | Alle .NET Runtime-DLLs ins Ausgabeverzeichnis kopieren |
| `--runtime win-x64` | Zielplattform: Windows 64-Bit |
| `-o .\publish-output` | Ausgabeverzeichnis |
### Doğrulama nach dem Publish
Nach erfolgreichem Publish folgende Dateien im Ausgabeverzeichnis prüfen:
```powershell
# Diese Dateien MÜSSEN vorhanden sein (Self-Contained-Nachweis):
Test-Path ".\publish-output\hostfxr.dll" # .NET Host
Test-Path ".\publish-output\coreclr.dll" # .NET Core Runtime
Test-Path ".\publish-output\Microsoft.Extensions.DependencyInjection.Abstractions.dll"
Test-Path ".\publish-output\EnvelopeGenerator.Server.exe"
Test-Path ".\publish-output\web.config"
```
Alle Ergebnisse müssen `True` sein.
---
## IIS-Konfiguration
> **WICHTIG:** Diese Einstellungen unterscheiden sich von einer normalen ASP.NET Core API
> und sind zwingend erforderlich.
### 1. Application Pool — `No Managed Code`
ASP.NET Core (und damit auch Blazor) verwaltet seinen eigenen Runtime-Lifecycle.
IIS darf **keinen** .NET CLR-Managed-Code-Kontext aktivieren.
**Einstellung:**
```
IIS Manager
→ Application Pools
→ [Pool-Name der Anwendung] → Basic Settings
→ .NET CLR Version: "No Managed Code" ← ZWINGEND
```
> **Fehler bei falscher Einstellung:** HTTP 500.30 — ASP.NET Core app failed to start
> (sc-win32-status: 574 in IIS-Logs)
### 2. ASP.NET Core Module V2
Das IIS-Modul `AspNetCoreModuleV2` muss installiert sein.
Es wird über das **.NET Hosting Bundle** mitgeliefert.
Prüfen:
```
IIS Manager → Modules → "AspNetCoreModuleV2" vorhanden?
```
Falls nicht installiert: [.NET 8 Hosting Bundle herunterladen](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
und nach der Installation IIS neu starten:
```cmd
net stop was /y && net start w3svc
```
### 3. web.config — Korrekte Konfiguration für Self-Contained
Nach dem Publish wird `web.config` automatisch generiert. Für Self-Contained muss sie so aussehen:
```xml
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<location path="." inheritInChildApplications="false">
<system.webServer>
<handlers>
<add name="aspNetCore" path="*" verb="*"
modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore processPath=".\EnvelopeGenerator.Server.exe"
stdoutLogEnabled="false"
stdoutLogFile=".\logs\stdout"
hostingModel="inprocess" />
</system.webServer>
</location>
</configuration>
```
**Kritische Unterschiede zu Framework-Dependent:**
| Eigenschaft | Framework-Dependent (FALSCH) | Self-Contained (RICHTIG) |
|---|---|---|
| `processPath` | `dotnet` | `.\EnvelopeGenerator.Server.exe` |
| `arguments` | `.\EnvelopeGenerator.Server.dll` | *(leer oder weggelassen)* |
### 4. Berechtigungen für das `logs`-Verzeichnis
Wenn `stdoutLogEnabled="true"` gesetzt wird (zur Fehlerdiagnose), muss das `logs`-Verzeichnis
existieren und der IIS-Prozess muss Schreibrechte haben:
```powershell
New-Item -ItemType Directory -Path "C:\inetpub\wwwroot\<App-Pfad>\logs" -Force
icacls "C:\inetpub\wwwroot\<App-Pfad>\logs" /grant "IIS_IUSRS:(OI)(CI)F"
```
> Ohne dieses Verzeichnis kann die Anwendung bei aktiviertem Logging **nicht starten**.
### 5. Application Pool Recycle nach Deployment
Nach jedem Deployment den Application Pool neu starten:
```cmd
# IIS Manager → Application Pools → [Pool] → Recycle
# oder per Kommandozeile (als Administrator):
%windir%\system32\inetsrv\appcmd recycle apppool /apppool.name:"<Pool-Name>"
```
---
## Verzeichnisstruktur nach dem Publish
```
publish-output\
├── EnvelopeGenerator.Server.exe ← Startpunkt (Self-Contained)
├── EnvelopeGenerator.Server.dll ← Managed Assembly
├── EnvelopeGenerator.Server.runtimeconfig.json
├── EnvelopeGenerator.Server.deps.json
├── web.config ← IIS-Konfiguration (auto-generiert)
├── hostfxr.dll ← .NET Host (Self-Contained-Nachweis)
├── coreclr.dll ← .NET Core Runtime
├── Microsoft.Extensions.*.dll ← Framework-DLLs (jetzt enthalten!)
├── DevExpress.*.dll ← UI-Komponenten
├── wwwroot\ ← Statische Web-Assets
│ ├── _framework\ ← WASM-Binaries
│ └── ...
└── logs\ ← Stdout-Logs (manuell anlegen!)
```
---
## Häufige Fehler
| Fehler | Ursache | Lösung |
|---|---|---|
| `FileNotFoundException: Microsoft.Extensions.DependencyInjection.Abstractions` | Framework-Dependent Publish auf Server ohne .NET 8 | Self-Contained Publish verwenden |
| `HTTP 500.30` in IIS | App startet nicht | Application Pool auf `No Managed Code` setzen |
| `HTTP 500.30` + `sc-win32-status: 574` | App Pool falsch oder `AspNetCoreModuleV2` fehlt | Pool prüfen + Hosting Bundle installieren |
| App startet per `.exe`, aber nicht in IIS | `web.config` hat noch `processPath="dotnet"` | `web.config` auf `.\EnvelopeGenerator.Server.exe` korrigieren |
| Logs-Verzeichnis fehlt → App startet nicht | `stdoutLogEnabled="true"` aber `logs\` existiert nicht | `logs\`-Ordner anlegen + IIS_IUSRS Schreibrecht geben |

View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.9",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@@ -308,8 +308,8 @@ article {
.home-btn-primary { .home-btn-primary {
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%); background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
border: none; border: none;
color: #fff; color: #fff !important;
font-weight: 500; font-weight: 700;
border-radius: 8px; border-radius: 8px;
transition: filter 0.15s, box-shadow 0.15s; transition: filter 0.15s, box-shadow 0.15s;
box-shadow: 0 2px 10px rgba(44,62,80,0.30); box-shadow: 0 2px 10px rgba(44,62,80,0.30);

View File

@@ -52,8 +52,8 @@
} }
.pdf-editor-wrapper { .pdf-editor-wrapper {
position: relative; width: 100%;
min-height: 100%; height: 100%;
} }
.sender-editor-pdf-viewer { .sender-editor-pdf-viewer {
@@ -61,6 +61,14 @@
height: 100%; height: 100%;
} }
.sender-editor-pdf-viewer .dxbl-toolbar {
justify-content: center;
}
.sender-editor-pdf-viewer .dxbl-toolbar-left {
margin-inline: auto;
}
.sender-editor-pdf-viewer .dxbrv-document-surface { .sender-editor-pdf-viewer .dxbrv-document-surface {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,92 +1,95 @@
window.envelopeEditor = { window.envelopeEditor = {
_overlaySyncState: {},
/** /**
* Returns click coordinates relative to the overlay element. * Returns the click position normalised to [0,1] relative to the rendered PDF page
* @param {string} overlayId - The id of the overlay div * element inside DxPdfViewer (or DxReportViewer as fallback).
* @param {number} clientX - MouseEventArgs.ClientX from Blazor *
* @param {number} clientY - MouseEventArgs.ClientY from Blazor * Normalising means the result is independent of zoom level: no matter how much the
* @returns {{ relX, relY, containerW, containerH }} * user has zoomed in/out, the same physical spot on the PDF will always yield the same
* normalised value. C# multiplies by the page's point dimensions to get PDF points.
*
* @param {string} viewerCssClass - CssClass set on DxPdfViewer (e.g. "sender-editor-pdf-viewer")
* @param {number} clientX - MouseEvent.clientX from Blazor
* @param {number} clientY - MouseEvent.clientY from Blazor
* @returns {{ normX, normY, pageIndex } | null}
* normX / normY : 0..1 fraction within the page element
* pageIndex : 0-based index of the page the click landed on (-1 if not found)
*/ */
getClickCoords: function (overlayId, clientX, clientY) { getClickCoordsOnPdfPage: function (viewerCssClass, clientX, clientY) {
const el = document.getElementById(overlayId);
if (!el) return null; // Find the viewer root element
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
console.warn('[envelopeEditor] viewer not found for class:', viewerCssClass);
return null;
}
// --- Candidate page elements (ordered by preference) ---
// DxPdfViewer renders individual pages as .dxbl-pdfv-page elements.
// DxReportViewer uses .dxbrv-report-preview-content-img as fallback.
const pageSelectors = [
'.dxbl-pdfv-page',
'.dxbrv-report-preview-page',
'.dxbrv-report-preview-content-img',
];
let allPages = [];
for (const sel of pageSelectors) {
const found = Array.from(viewer.querySelectorAll(sel));
if (found.length > 0) {
allPages = found;
break;
}
}
if (allPages.length === 0) {
console.warn('[envelopeEditor] no page elements found inside viewer');
return null;
}
// --- Find which page the click landed on ---
// Walk through all pages; pick the one whose bounding rect contains the click.
// If none contains it exactly, fall back to the page closest vertically.
let targetPage = null;
let targetIndex = -1;
let minDist = Infinity;
for (let i = 0; i < allPages.length; i++) {
const rect = allPages[i].getBoundingClientRect();
// Exact hit
if (clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom) {
targetPage = allPages[i];
targetIndex = i;
break;
}
// Track closest page (vertical centre distance) as fallback
const cy = rect.top + rect.height / 2;
const dist = Math.abs(clientY - cy);
if (dist < minDist) {
minDist = dist;
targetPage = allPages[i];
targetIndex = i;
}
}
if (!targetPage) return null;
const pageRect = targetPage.getBoundingClientRect();
// Clamp click inside page boundaries before normalising
const clampedX = Math.max(pageRect.left, Math.min(clientX, pageRect.right));
const clampedY = Math.max(pageRect.top, Math.min(clientY, pageRect.bottom));
const normX = (clampedX - pageRect.left) / pageRect.width;
const normY = (clampedY - pageRect.top) / pageRect.height;
const rect = el.getBoundingClientRect();
return { return {
relX: clientX - rect.left, normX: normX,
relY: clientY - rect.top, normY: normY,
containerW: rect.width, pageIndex: targetIndex
containerH: rect.height
}; };
},
syncOverlayToPage: function (wrapperId, overlayId) {
const wrapper = document.getElementById(wrapperId);
const overlay = document.getElementById(overlayId);
if (!wrapper || !overlay) {
return;
}
const existing = window.envelopeEditor._overlaySyncState[overlayId];
if (existing) {
return existing.sync();
}
const findTarget = (currentWrapper) => {
const page = currentWrapper.querySelector(".dxbrv-report-preview-content");
if (page) {
return page;
}
return currentWrapper.querySelector(".dxbrv-report-preview-content-img") ||
currentWrapper.querySelector("img.dxbrv-report-preview-content-img") ||
currentWrapper.querySelector(".dxbrv-document-surface img");
};
const sync = () => {
const currentWrapper = document.getElementById(wrapperId);
const currentOverlay = document.getElementById(overlayId);
if (!currentWrapper || !currentOverlay) {
return;
}
const target = findTarget(currentWrapper);
if (!target) {
currentOverlay.style.left = "0px";
currentOverlay.style.top = "0px";
currentOverlay.style.width = "0px";
currentOverlay.style.height = "0px";
return;
}
const wrapperRect = currentWrapper.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
currentOverlay.style.left = `${targetRect.left - wrapperRect.left + currentWrapper.scrollLeft}px`;
currentOverlay.style.top = `${targetRect.top - wrapperRect.top + currentWrapper.scrollTop}px`;
currentOverlay.style.width = `${targetRect.width}px`;
currentOverlay.style.height = `${targetRect.height}px`;
};
const scheduleSync = () => requestAnimationFrame(sync);
const observer = new MutationObserver(scheduleSync);
observer.observe(wrapper, { childList: true, subtree: true, attributes: true });
wrapper.addEventListener("scroll", scheduleSync, { passive: true });
window.addEventListener("resize", scheduleSync);
window.envelopeEditor._overlaySyncState[overlayId] = {
sync,
observer
};
sync();
setTimeout(sync, 50);
setTimeout(sync, 150);
setTimeout(sync, 400);
} }
}; };

View File

@@ -0,0 +1,96 @@
@echo off
setlocal
echo ============================================================
echo EnvelopeGenerator.Server - Self-Contained Publish
echo Target: win-x64 / .NET 8 / Release
echo ============================================================
echo.
REM Must be run from the solution root directory.
REM This file is located under: EnvelopeGenerator.Server\
set PROJECT=EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj
set OUTPUT=publish-output
set RID=win-x64
set FRAMEWORK=net8.0
echo [1/3] Cleaning previous publish output...
if exist "%OUTPUT%" (
rmdir /s /q "%OUTPUT%"
echo Removed: %OUTPUT%
) else (
echo Nothing to clean.
)
echo.
echo [2/3] Publishing...
echo Project : %PROJECT%
echo Output : %OUTPUT%
echo RID : %RID%
echo Framework: %FRAMEWORK%
echo.
dotnet publish "%PROJECT%" ^
-c Release ^
-f %FRAMEWORK% ^
--self-contained true ^
--runtime %RID% ^
-o "%OUTPUT%"
if %ERRORLEVEL% neq 0 (
echo.
echo [ERROR] Publish failed! ERRORLEVEL=%ERRORLEVEL%
pause
exit /b %ERRORLEVEL%
)
echo.
echo [3/3] Verifying output...
set PASS=1
if not exist "%OUTPUT%\EnvelopeGenerator.Server.exe" (
echo [FAIL] EnvelopeGenerator.Server.exe not found!
set PASS=0
)
if not exist "%OUTPUT%\hostfxr.dll" (
echo [FAIL] hostfxr.dll not found! (Not a self-contained publish?)
set PASS=0
)
if not exist "%OUTPUT%\coreclr.dll" (
echo [FAIL] coreclr.dll not found! (Not a self-contained publish?)
set PASS=0
)
if not exist "%OUTPUT%\Microsoft.Extensions.DependencyInjection.Abstractions.dll" (
echo [FAIL] Microsoft.Extensions.DependencyInjection.Abstractions.dll not found!
set PASS=0
)
if not exist "%OUTPUT%\web.config" (
echo [FAIL] web.config not found!
set PASS=0
)
if "%PASS%"=="1" (
echo.
echo ============================================================
echo PUBLISH SUCCEEDED
echo Output folder: %~dp0%OUTPUT%
echo ============================================================
echo.
echo Next steps:
echo 1. Copy the contents of '%OUTPUT%' to the IIS application directory
echo 2. Set the IIS Application Pool to 'No Managed Code'
echo 3. Recycle the Application Pool
echo.
) else (
echo.
echo ============================================================
echo PUBLISH COMPLETED BUT VERIFICATION FAILED
echo Review the FAIL messages above.
echo ============================================================
echo.
)
pause
endlocal

View File

@@ -45,6 +45,9 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.ReceiverUI", "EnvelopeGenerator.ReceiverUI\EnvelopeGenerator.ReceiverUI.csproj", "{FB2D306B-1042-4A70-31ED-F991A1599371}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.ReceiverUI", "EnvelopeGenerator.ReceiverUI\EnvelopeGenerator.ReceiverUI.csproj", "{FB2D306B-1042-4A70-31ED-F991A1599371}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}" 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
EndProjectSection
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj", "{4E6C54DA-576D-0955-2564-9EC890BB8279}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj", "{4E6C54DA-576D-0955-2564-9EC890BB8279}"
EndProject EndProject