Compare commits

...

80 Commits

Author SHA1 Message Date
b536b31a4b SF-74: CSS — app.css theme/dark-mode additions, envelope-viewer.css, sender-page.css new stylesheets 2026-09-03 15:40:20 +02:00
a80efee075 SF-74: ThemeService + signflow-theme.js — JS interop fixes and dark mode init improvements 2026-09-03 15:40:14 +02:00
86d6daafbd SF-74: EnvelopeSenderPage — ThemeService/UserPreferencesService inject, LoadFromServerAsync, grid layout persistence (LayoutAutoLoading/Saving) 2026-09-03 14:58:31 +02:00
aefa1a8883 SF-74: Client UI — MainLayout HeadContent+footer theme controls, ThemeControls component, app.css sf-footer styles 2026-09-03 14:58:19 +02:00
b3e7f77165 SF-74: Server — add signflow-theme.js (localStorage/system dark mode JS interop) 2026-09-03 14:58:07 +02:00
bac9bfd036 SF-74: Client Services — ThemeService (debounce, SaveError, DisplayName), UserPreferencesService (1h cache) 2026-09-03 14:57:55 +02:00
6effb8a0d6 SF-74: Server — UserPreferencesController, claim extensions, DI registration, App.razor 2026-09-03 14:57:44 +02:00
a9ff0d88f2 SF-74: Infrastructure — UserPreferencesService (IDistributedCache, no try-catch) 2026-09-03 14:57:32 +02:00
0fdfe991ea SF-74: Domain/Application — UserPreferences DTO, interface, Sender role constant 2026-09-03 14:57:21 +02:00
7fc2c14f9c Add dynamic theme and dark mode functionality
Introduced `ThemeService` to manage themes and dark mode, with a new footer UI for theme selection and toggling dark mode. Added dynamic CSS loading and Bootstrap theme support. Registered `UserPreferencesService` and `ThemeService` for SSR compatibility. Removed hardcoded DevExpress theme links.

Fixed German localization issues in `LoginReceiverPage` and `LoginSenderPage`. Updated `app.css` with styles for the new footer controls. Simplified font loading in `MainLayout.razor`.
2026-09-03 09:24:13 +02:00
5098deedd1 Add ThemeService and UserPreferencesService
Introduce ThemeService to manage themes and dark mode state,
including initialization, persistence, and server sync.

Add UserPreferencesService to handle API communication for
retrieving and saving user preferences with in-memory caching
to reduce network overhead.

Register both services in the DI container in Program.cs.

Enhance ThemeService with detailed documentation and introduce
ThemeOption record for selectable themes. Improve server-side
caching in UserPreferencesService for consistency and efficiency.
2026-09-03 01:18:01 +02:00
2e634361c5 Add user preferences management via distributed cache
Introduced `UserPreferencesDto` to represent UI preferences,
including theme, dark mode, and grid layouts. Added the
`IUserPreferencesService` interface and implemented it in
`UserPreferencesService` to manage preferences stored in
the distributed cache (`dbo.TBDD_CACHE`).

Registered `UserPreferencesService` in dependency injection
and added a NuGet reference for `Microsoft.Extensions.Caching.Abstractions`.

Created `UserPreferencesController` with `GET` and `PUT`
endpoints to retrieve and save user preferences. Validated
theme names in the `PUT` endpoint.

Updated `Program.cs` to ensure proper service registration.
2026-09-02 12:35:19 +02:00
60ebebc0d8 Add widget annotation and update stroke color
Updated `createAnnotations` to initialize a `PSPDFKit.Annotations.WidgetAnnotation` object, enabling the creation of widget annotations.

Modified `ctx.strokeStyle` in `createAnnotationFrameBlob` to use 'darkblue' instead of 'darkgray', improving the visual appearance of the annotation frame.
2026-08-27 14:46:32 +02:00
c2d8b6531c Target single framework and add publish profile
Updated `EnvelopeGenerator.Service.csproj` to target a single framework (`net4.6.2`) by replacing `<TargetFrameworks>` with `<TargetFramework>`.

Added `DefaultProfile.pubxml` to define a publish profile with settings for release configuration, platform, and file system-based publishing.
2026-08-27 12:44:11 +02:00
665266457f Migrate legacy VB.NET service to modern C# Worker
Migrated the legacy VB.NET Windows Service to a modern C# Worker Service targeting .NET Framework 4.6.2. Key changes include:

- Added `Microsoft.Extensions.Hosting.WindowsServices`, `NLog`, and `Quartz` for logging and scheduling.
- Introduced `LogConfigFactory` to bridge `ServiceConfig` and `LogConfig` for Quartz jobs.
- Rewrote `Worker` to handle the full service lifecycle, including database connection, temp file management, and scheduler initialization.
- Implemented `SchedulerFinishEnvelope` and `SchedulerEnvelopeTaskApi` for Quartz job scheduling.
- Created `TempFiles` for service-level temp file management.
- Configured `NLog` for file and console logging.
- Updated `appsettings.json` for connection strings and service-specific configuration.
- Replaced legacy `ProjectInstaller` with `AddWindowsService()` for SCM integration.
2026-08-27 11:39:49 +02:00
14d581acac Add EnvelopeGenerator.Service as a .NET Worker Service
Introduced a new project `EnvelopeGenerator.Service` to the solution as a .NET Worker Service targeting the `net4.6.2` framework.

- Configured the project with nullable reference types, implicit usings, and `LangVersion` set to `latest`.
- Added a `Worker` class that logs periodic messages while running.
- Updated `Program.cs` to configure and run the Worker Service.
- Added `launchSettings.json` for development environment configuration.
- Added `appsettings.json` and `appsettings.Development.json` for logging configuration.
- Included the project in `EnvelopeGenerator.sln` with appropriate build configurations and hierarchy updates.
2026-08-27 10:36:55 +02:00
eb533a1dc2 Replace EnvelopeGenerator.Service with Service_legacy
The project `EnvelopeGenerator.Service` has been removed from the solution and replaced with a new project, `EnvelopeGenerator.Service_legacy`.

Updated build configuration mappings to reflect the addition of `Service_legacy` and the removal of `Service`. Adjusted project nesting to place `Service_legacy` under the same parent as the removed `Service`.
2026-08-27 10:36:01 +02:00
36025fad7b Enhance handleReset functionality and fix async issues
Updated the `handleReset` method in `app.js` to await the
`Swal.fire` call, ensuring proper asynchronous handling.
Added logic to reset editable text form fields and restore
widget backgrounds after signing.

Refactored `app.min.js` to align with changes in `app.js`,
ensuring consistency between the minified and non-minified
versions. Improved code readability and maintainability
across both files.
2026-08-27 09:32:51 +02:00
0606efabf9 Update truncation limits for "position" and "city"
Increased the maximum width values for the "position" and "city" keys in the `TextMaxWidths` property of `Jobs.FinalizeDocument` (PDFBurnerParams.vb) from `1.25F` to `1.41F`. This change allows more text to fit before truncation occurs, improving text rendering flexibility.
2026-08-25 16:38:51 +02:00
2cb1e266f7 Improve annotation handling and UI stability
Updated `appsettings.UI.json` to adjust annotation properties for better alignment and positioning. Refactored `createAnnotations` and `createAnnotationFrameBlob` in `annotation.js` to improve readability, reusability, and asynchronous handling.

Enhanced the `App` class in `app.js` by introducing `signatureWidgets` for stable frame placement and added logic to find and update the nearest signature widget. Improved bounding box calculations and removed widget backgrounds after signing.

Refactored `handleAnnotationsCreate` to align with new logic and updated `app.min.js` to reflect these changes. General improvements include better code maintainability, reduced redundancy, and enhanced user experience.
2026-08-25 16:38:22 +02:00
35c1374b9c Update text truncation logic to use rendered width
Replaced character-based truncation with pixel-accurate truncation based on rendered width in inches. Updated `TruncateText` in `PDFBurner.vb` to use `Graphics.MeasureString` for width calculation and introduced a binary search for efficient truncation. Renamed `egName` to `fieldEgName` for clarity.

Replaced `TextMaxLengths` with `TextMaxWidths` in `PDFBurnerParams.vb`, specifying maximum render widths in inches. Updated default values for `position`, `city`, and `date`. Revised XML documentation to reflect the new truncation logic.
2026-08-25 09:47:22 +02:00
feaea1626b Fix signature field formatting and add text truncation
Address customer feedback on signature field layout issues:
- Added truncation logic in `PDFBurner.vb` to limit text length
  based on per-field maximums, appending a suffix (e.g., `..`).
- Enhanced `PDFBurnerParams.vb` with `TextMaxLengths` and
  `TruncationSuffix` for configurable truncation behavior.
- Improved spacing and layout handling for signature fields to
  prevent text collisions within fixed dimensions.
- Updated `EnvelopeGenerator.sln` with documentation for the
  signature formatting fix (`fix-signature-field-formatting.md`).
- Applied truncation consistently across `AddFormFieldValue`
  overloads and ensured deterministic rendering of annotations.
2026-08-25 09:07:02 +02:00
babd96200a Refactor and enhance PDF finalization process
- Added new imports to integrate additional services.
- Renamed `envelope` to `oEnvelopeEntity` for clarity.
- Improved envelope retrieval logic and error handling.
- Refactored `ReadOnly` envelope handling for readability.
- Reintroduced and enhanced annotation processing logic.
- Updated `BurnAnnotsToPDF` call with better logging.
- Added envelope domain model loading for report creation.
- Implemented signing certificate report generation.
- Merged burned document with signing certificate report.
- Updated output file naming and adjusted save logic.
- Improved method structure with clear steps and logging.
2026-08-24 14:33:03 +02:00
f9cfcf1f19 Fix: Add "Read and confirmed" label for RaC envelopes
Updated the Signature Certificate report to display the correct
label "Read and confirmed" for "Read and Sign" envelopes.

- Added `DocumentSignedRaC` resource key to `Model.resx` and
  `Model.en.resx` with localized values ("Gelesen und bestätigt"
  and "Read and confirmed").
- Updated `ReportItem.vb` to include conditional logic in
  `ItemStatusTranslated` for "Read and Sign" envelopes.
- Added strongly-typed property for `DocumentSignedRaC` in
  `Model.Designer.cs`.
- Updated `fix-report-label-read-and-confirmed.md` documentation
  to reflect changes, including testing steps and task completion.

These changes ensure accurate status labels in the report for
different envelope types.
2026-08-24 14:31:40 +02:00
ffb9e07466 Fix label for "Read and Sign" envelopes in reports
Addressed an issue where the Signature Certificate report incorrectly displayed "Document signed" for "Read and Sign" envelopes. Added a new resource key, `DocumentSignedRaC`, with translations ("Read and confirmed" / "Gelesen und bestätigt") to resolve this.

Updated `ItemStatusTranslated` in `ReportItem.vb` to use the new key for "Read and Sign" envelopes (`EnvelopeTypeId == 2`) while retaining existing behavior for other envelope types. Leveraged `Envelope.IsReadAndConfirm()` to determine envelope type.

Pending tasks include adding the new resource key to resource files and updating `Model.Designer.cs` with a strongly-typed property. Testing instructions provided to verify correct label behavior.
2026-08-24 13:47:28 +02:00
5384a08905 Update .gitignore to exclude specific files and directories
The .gitignore file was updated to ignore the following files and
directories:
- `FodyWeavers.xsd`
- `/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+/ignore/EnvelopeGenerator.Form`

These changes ensure that unnecessary or sensitive files are not
tracked in version control.
2026-08-20 14:58:51 +02:00
dda5ff28e1 Update build package paths to include PreRelease folder
Updated the `DesktopBuildPackageLocation` property in
`IISProfileNet8.pubxml` and `IISProfile.pubxml` to store
build packages in the `PreRelease` directory.

- Updated `EnvelopeGenerator.API.zip` and `EnvelopeGenerator.Server.zip` paths in `IISProfileNet8.pubxml`.
- Updated `EnvelopeGenerator.ReceiverUI.zip` path in `IISProfile.pubxml`.

No other properties or configurations were modified.
2026-07-06 14:32:18 +02:00
043349a47f Update EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md 2026-07-06 10:17:10 +02:00
c36f7ccf98 Merge branch 'feat/migr-DxReportViewer' into master 2026-07-02 15:59:31 +02:00
cf3e85bcd7 Update .gitignore and add new solution items
Updated the `.gitignore` file to exclude the `MigrationBackup/`
directory. Added `publish-output` and `tekh_softHSM_test.md`
to the `EnvelopeGenerator.Server` directory.

Modified the `EnvelopeGenerator.sln` file to include a new
solution item `RECEIVER_PDF_VIEWER_CONTEXT.md` under the
`src` project section. No structural changes were made to
existing projects in the solution.
2026-07-02 15:56:46 +02:00
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
4d069cdaa0 Merge branch 'feat/migr-DxReportViewer' of http://git.dd:3000/AppStd/EnvelopeGenerator into feat/migr-DxReportViewer 2026-07-01 17:16:19 +02:00
bbbfa4de01 Add phone number support for receivers in UI
Added functionality to display a receiver's phone number in the
sender-receiver chip if provided. Introduced a new input field
in the receiver popup for entering an optional phone number.

Updated the `ReceiverDraft` model and `_receivers` list to
include and store phone numbers. Modified methods to handle
phone number input and saving. Added CSS styles for displaying
the phone number in the sender-receiver chip.
2026-07-01 17:16:15 +02:00
3ff3373b27 Improve email suggestion handling and keyboard navigation
Enhanced the email suggestion feature by adding keyboard navigation support (`ArrowUp`, `ArrowDown`, `Enter`, `Escape`) and introducing `_selectedReceiverEmailSuggestion` to track the current selection. Updated methods to synchronize input and suggestions, pre-select matching suggestions, and reset the state when the popup is opened or closed. Improved error handling and ensured clean state management for better user experience.
2026-07-01 17:10:54 +02:00
2af8815cf6 Replace DxButton with HTML buttons and update styles
Replaced DevExpress DxButton components with standard HTML
<button> elements in EnvelopeSenderEditorPage.razor for
"Add Receiver" and "Add Signature" actions. The new buttons
include SVG icons and text for improved customization and
styling.

Updated envelope-viewer.css to add new styles for the
buttons, including `.sender-toolbar-action-btn` and
`.sender-toolbar-action-btn--compact` classes. Removed
unused styles related to DxButton components and adjusted
`.sender-receiver-chip__action` for proper width handling.

These changes improve design flexibility and maintainability.
2026-07-01 17:02:19 +02:00
ca24a96084 Add receiver management to EnvelopeSenderEditorPage
Introduced a new "Receivers" panel in `EnvelopeSenderEditorPage` to manage receivers. Added a popup for adding receivers with validation, email suggestions, and caching for performance. Updated the layout to display receiver details and signature fields.

Injected `EnvelopeReceiverPageDataService` for receiver-related operations. Added a `ReceiverDraft` model and implemented methods for managing receivers. Enhanced CSS for the new UI elements and ensured responsiveness. Minor refactoring and cleanup included.
2026-07-01 16:32:28 +02:00
db368b889a Merge branch 'feat/migr-DxReportViewer' of http://git.dd:3000/AppStd/EnvelopeGenerator into feat/migr-DxReportViewer 2026-07-01 15:43:33 +02:00
8f451b9c2c Implement navigation for CreateEnvelope method
Replaced the placeholder console log in the `CreateEnvelope` method with actual navigation functionality. The method now uses `Navigation.NavigateTo` to redirect users to the `/sender/editor` page, enabling the intended behavior for creating envelopes.
2026-07-01 15:43:26 +02:00
6120e6062e Update ReceiverController namespace and enhance Get method
The namespace of the `ReceiverController` class was updated from `EnvelopeGenerator.GeneratorAPI.Controllers` to `EnvelopeGenerator.Server.Controllers`.

The `Get` method was enhanced with the following changes:
- Added `[Authorize]` attribute with `AuthScheme.Sender`.
- Updated the method signature to include an optional `ReadReceiverQuery? receiver` parameter (defaulting to `null`) and a new `bool onlyEmailAddress` parameter (defaulting to `false`).
- Modified logic to handle `receiver` being `null` by creating a new `ReadReceiverQuery`.
- Added handling for `onlyEmailAddress` to return a list of email addresses if true.
- Simplified the result handling and removed the previous `receiver.HasAnyCriteria` logic.
2026-07-01 15:16:17 +02:00
cc2aea90ed Enhance query criteria and add partial email matching
- Added `[NotMapped]` attribute to `HasAnyCriteria` in `ReceiverQueryBase` to exclude it from database mapping.
- Made `HasAnyCriteria` in `ReceiverQueryBase` virtual for overriding.
- Introduced `EmailAddressSearch` in `ReadReceiverQuery` for partial email matching.
- Overrode `HasAnyCriteria` in `ReadReceiverQuery` to include `EmailAddressSearch`.
- Updated `ReadReceiverQueryHandler` to support partial email matching using `EF.Functions.Like`.
2026-07-01 15:15:50 +02:00
762a9e8bca Improve PDF viewer overlay synchronization
Refactor `EnvelopeSenderEditorPage.razor` to enhance the structure and behavior of the PDF editor wrapper:
- Add `class="pdf-editor-wrapper"` and update `overflow` to `auto`.
- Update `DxPdfViewer`'s `CssClass` to `sender-editor-pdf-viewer`.
- Introduce `OnAfterRenderAsync` to synchronize the overlay with the viewer.

Add new styles in `envelope-viewer.css` for better layout:
- Ensure `.pdf-editor-wrapper` and `.sender-editor-pdf-viewer` occupy full dimensions.
- Center and align content within the PDF viewer.

Enhance `envelope-editor.js` with `syncOverlayToPage`:
- Dynamically adjust overlay position and size relative to the viewer.
- Use `MutationObserver` and event listeners for real-time synchronization.
- Handle delayed rendering with scheduled sync attempts.

These changes improve overlay alignment, user experience, and code maintainability.
2026-07-01 14:26:11 +02:00
6ed4caea4f Merge branch 'feat/migr-DxReportViewer' of http://git.dd:3000/AppStd/EnvelopeGenerator into feat/migr-DxReportViewer 2026-07-01 13:59:29 +02:00
d94821433a Enable WebAssembly mode and add blazing-berry theme
Added the `@rendermode InteractiveWebAssembly` directive to
`IndexPage.razor` to enable interactive WebAssembly rendering.
Included a `<link>` element to reference the `blazing-berry.bs5.min.css`
stylesheet from the `DevExpress.Blazor.Themes` content folder to
apply the "blazing-berry" theme for enhanced styling.
2026-07-01 12:58:25 +02:00
278b9964f1 Update signing confirmation text and logout navigation
The user-facing text in `EnvelopeReceiverReportSignedPage.razor` has been updated to provide a more detailed and formal confirmation message for signing a document.

- Replaced "Dokument erfolgreich unterschrieben" with "Möchten Sie das Dokument verbindlich unterschreiben?".
- Updated the follow-up message to clarify the irreversibility of the action and the electronic signing process.

Additionally, the logout navigation behavior has been modified:
- Changed the post-logout redirect from `/envelope/login/{EnvelopeKey}` to the root page (`/`), while retaining the `forceLoad` parameter.
2026-07-01 12:56:51 +02:00
e6722803bb Add submit confirmation popup and logout functionality
Added dependency injection for `AuthService`, `ReceiverAuthorizationService`, `PageDataService`, and `Logger` to enable their usage in the component. Introduced a "Submit" button in the UI to confirm the signing process and complete the workflow.

Implemented a `DxPopup` component to display a confirmation dialog when the "Submit" button is clicked. The popup includes a message about the successful signing of the document and asks the user to confirm whether to complete the process and log out.

Added state variables `_isLoggingOut` and `_submitConfirmVisible` to manage the popup visibility and logout process. Created `OpenSubmitConfirmPopup` to toggle the popup and `SubmitAndLogoutAsync` to handle the submission process, including logging out via `AuthService` and navigating to the login page.

Updated the `@code` block with the new state variables and methods for managing the submit and logout functionality.
2026-07-01 12:36:50 +02:00
47bc7675c9 Handle cache miss and redirect in SignedPage
Added a check for `_sig` being `null` to handle cache misses or missing `sid`. Logged a warning with `Sid` and `EnvelopeKey` details when this occurs. Implemented a redirection to the report page (`/envelope/{EnvelopeKey}/report`) using `Navigation.NavigateTo` with `forceLoad: true`. Added an early return to prevent further execution after redirection.
2026-07-01 11:47:09 +02:00
789e312316 Refactor signature box layout and improve readability
Refactored the signature box layout to dynamically calculate
positions based on content, improving flexibility and precision.
Introduced new constants (`lineH`, `bgPad`) to standardize
spacing and replaced hardcoded values for better maintainability.
Adjusted background rectangle sizing to fit content dynamically
and improved text layout logic to handle optional fields more
gracefully. Simplified image area logic and reduced redundant
calculations. Overall, improved code readability and alignment
for a cleaner, more compact layout.
2026-07-01 11:38:07 +02:00
2a9bbb3fe5 Add envelope editor with PDF upload and signature tools
Introduced a new Blazor page `EnvelopeSenderEditorPage.razor` for editing envelopes with an interactive interface. Integrated `DxPdfViewer` for rendering PDFs and added functionality for uploading, viewing, and interacting with PDF files.

Key features:
- Action bar with buttons for uploading PDFs, toggling signature placement mode, clearing fields, and saving.
- Placement mode for adding signature fields via an overlay, with visual placeholders.
- JavaScript interop (`envelope-editor.js`) for precise click coordinate mapping.
- Error handling for unsupported file types and size limits (50 MB).
- Logging for debugging key actions like PDF uploads and field placements.

Defined constants for accurate signature field dimensions and scaling. Added models (`SignatureFieldDraft`, `OverlayCoords`) to manage state and interactions.
2026-07-01 11:25:04 +02:00
bc34317720 Adjust signature box layout and add background color
Updated the signature box proportions by adjusting `imgRatio` to 52% and `textRatio` to 43%. Added a cream-tone background with extra padding (`bgPad`) and rendered it behind the signature content. Tightened gaps between the image, separator line, and text area. Increased text row height slightly for better spacing.
2026-07-01 11:12:16 +02:00
76ff3e47e1 Render signatures on PDF using PdfSharp
Added functionality to render captured signatures onto a PDF document using the PdfSharp library. Introduced a new `_signatures` field to store signature data and updated `OnInitializedAsync` to fetch and process signatures. Implemented the `DrawSignaturesOnPdf` method to overlay signature images, separator lines, and signer details (name, position, place, date) onto the PDF. Added a helper method `DataUrlToBytes` for decoding Base64-encoded signature images. Defined constants for layout and styling to ensure consistent rendering. Updated `Dispose` to clean up resources.
2026-07-01 10:30:35 +02:00
2d22bfcd06 Simplify signed document viewer logic
Removed all signature capture and validation functionality, including the signature popup, JavaScript interop, and related backend logic. Simplified the `DxReportViewer` initialization to directly display the signed document. Added support for retrieving cached signatures via the `sid` query parameter. Streamlined error handling, logging, and page metadata. Cleaned up unused imports, constants, and methods to reduce complexity.
2026-07-01 09:57:19 +02:00
185c783824 Improve signature handling and navigation stability
Updated button logic to display "Unterschreiben" only when signature fields exist. Changed `_signaturePopupVisible` to always be `false` for consistent popup behavior. Improved navigation after caching a signature by nullifying `_report`, adding a delay for UI updates, and using `forceLoad: true` for clean circuit teardown. These changes enhance user experience and prevent potential crashes.
2026-07-01 09:55:55 +02:00
b957b4b4bb Update navigation path for successful login
Changed the navigation path in `LoginReceiverPage.razor` to redirect users to `/envelope/{EnvelopeKey}/report` instead of `/envelope/{EnvelopeKey}` upon a successful login. The `forceLoad: true` parameter remains unchanged to ensure a full page reload.
2026-07-01 09:54:59 +02:00
df154d83cc Refactor signature caching and navigation logic
Replaced the previous signature persistence mechanism with
`IMemoryCache` for temporary storage of captured signatures
using a unique `Guid` key and a 1-minute TTL. Added logging
to track cached signatures and their associated envelope keys.

Removed the logic for rebuilding and displaying reports with
overlaid signatures. Instead, implemented navigation to a
new signed page (`/envelope/{EnvelopeKey}/signed`) with the
signature ID passed as a query parameter.
2026-07-01 01:41:19 +02:00
49ec9fbead Add PdfSharp font resolver for .NET 8 compatibility
Added a `PdfSharpFontResolver` class to enable font resolution
for PdfSharp in .NET 8, addressing the lack of system font
access. The resolver reads fonts from the Windows Fonts folder
and supports the Arial font family. Registered the resolver
globally in `Program.cs` using `GlobalFontSettings.FontResolver`.

Updated `Program.cs` with comments explaining the necessity of
the resolver for .NET 8. The resolver includes methods to map
font family names to specific font files and load font data.
Throws a `FileNotFoundException` if required fonts are missing.

Made minor formatting changes in `Program.cs` without altering
the `SwaggerDoc` description functionality.
2026-07-01 01:25:24 +02:00
01fc29f59e Refactor signature handling and add signed page UI
Refactored `EnvelopeReceiverReportPage.razor` to replace the logic for burning captured signatures with a new approach that draws placeholder boxes for signature fields using the `DrawSignaturePlaceholders` method. Removed the `BurnSignaturesIntoPdf` method and introduced `PDFsharp` for rendering placeholders.

Added `EnvelopeReceiverReportSignedPage.razor` to handle signed envelopes, including a detailed UI for document display, metadata, and a signature popup with "Draw," "Text," and "Image" modes. Integrated JavaScript interop for signature creation and validation.

Updated `EnvelopeGenerator.Server.csproj` to include the `PDFsharp` library. Enhanced error handling, logging, and UI feedback. Improved code readability and maintainability through cleanup and refactoring.
2026-07-01 01:25:09 +02:00
733b70cca2 Refactor PDF handling; remove iText dependency
Replaced iText-based PDF processing with DevExpress PdfGraphics API.
Removed `itext` and `itext.bouncy-castle-adapter` dependencies.
Simplified `BuildReport` to burn signatures directly into PDFs
and render all pages using `XRPdfContent` with `GenerateOwnPages = true`.
Consolidated subreport logic into `BuildReport` and removed
`BuildPageSubreport`. Eliminated unused constants and methods,
including `GetPdfPageCount`. Updated XML documentation to reflect
the new implementation. Placeholder implementation for
`BurnSignaturesIntoPdf` added, pending further development.
2026-06-30 23:55:39 +02:00
8f4b751303 Add envelope report page with signature capture
Added a new Razor page `EnvelopeReceiverReportPage.razor` to display and manage envelope reports at the route `/envelope/{EnvelopeKey}/report`. Integrated DevExpress Blazor Reporting components (`DxReportViewer`, `DxPopup`) for rendering PDF documents and capturing user signatures.

Implemented a multi-tab signature capture interface supporting drawing, text input with font selection, and image uploads. Added support for dynamically overlaying captured signatures on PDF documents using `XRPictureBox`.

Introduced dependency injection for services like `AuthService`, `ReceiverAuthorizationService`, and `PageDataService` to handle authentication, data retrieval, and logging. Included lifecycle methods for user authorization, PDF loading, and restoring cached signatures.

Added validation for signature input, error handling for missing data, and utility methods for building reports, extracting PDF page counts, and converting base64 data URLs. Integrated JavaScript interop for canvas-based signature handling.

Included custom styles and assets, and implemented disposal logic for cleaning up resources.
2026-06-30 23:27:20 +02:00
104 changed files with 5942 additions and 155 deletions

5
.gitignore vendored
View File

@@ -365,3 +365,8 @@ 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
/ignore/EnvelopeGenerator.Form

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

@@ -11,7 +11,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish> <LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data> <ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>5e0e17c0-ff5a-4246-bf87-1add85376a27</ProjectGuid> <ProjectGuid>5e0e17c0-ff5a-4246-bf87-1add85376a27</ProjectGuid>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\signFLOW\API\net8\$(Version)\EnvelopeGenerator.API.zip</DesktopBuildPackageLocation> <DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\signFLOW\PreRelease\API\net8\$(Version)\EnvelopeGenerator.API.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile> <PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>EnvelopeGenerator</DeployIisAppPath> <DeployIisAppPath>EnvelopeGenerator</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId> <_TargetId>IISWebDeployPackage</_TargetId>

View File

@@ -0,0 +1,39 @@
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
/// Represents a user's UI preferences stored in the distributed cache (dbo.TBDD_CACHE).
/// </summary>
public sealed record UserPreferencesDto
{
/// <summary>
/// The active DevExpress theme name.
/// Allowed values: "blazing-berry", "fluent", "purple"
/// </summary>
public string ThemeName { get; set; } = "blazing-berry";
/// <summary>
/// Whether dark mode is active.
/// Default: false (light mode).
/// </summary>
public bool IsDarkMode { get; set; } = false;
/// <summary>
/// Persisted DxGrid layout states, keyed by a stable grid identifier.
/// Example keys: "sender.active-envelopes", "sender.completed-envelopes"
/// Values are opaque JSON strings produced by DxGrid.SaveLayoutAsync().
/// </summary>
public Dictionary<string, string> GridLayouts { get; set; } = new();
/// <summary>
/// The authenticated user's display name ("{GivenName} {Surname}").
/// Populated by the server on GET responses from JWT claims.
/// NOT persisted in cache — injected at read time by UserPreferencesController.
/// Ignored on PUT requests.
/// </summary>
public string? DisplayName { get; set; }
/// <summary>
///
/// </summary>
public static UserPreferencesDto Default { get; } = new UserPreferencesDto();
}

View File

@@ -0,0 +1,29 @@
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services;
/// <summary>
/// Manages per-user UI preferences (theme, dark mode, grid layouts).
/// Preferences are stored in the distributed cache (dbo.TBDD_CACHE) as JSON.
/// Cache key schema: "signflow.user-preferences:{username}"
/// </summary>
public interface IUserPreferencesService
{
/// <summary>
/// Retrieves preferences for the given user.
/// Always returns a valid object — never throws, never returns null.
/// Returns defaults if no entry exists in cache.
/// </summary>
/// <param name="id"></param>
/// <param name="ct">Cancellation token.</param>
Task<UserPreferencesDto> GetOrCreateAsync(string id, CancellationToken ct = default);
/// <summary>
/// Persists the given preferences for the user in the distributed cache.
/// Failures are non-fatal and silently ignored.
/// </summary>
/// <param name="id"></param>
/// <param name="preferences">The preferences to persist.</param>
/// <param name="ct">Cancellation token.</param>
Task SaveAsync(string id, UserPreferencesDto preferences, CancellationToken ct = default);
}

View File

@@ -1,4 +1,6 @@
namespace EnvelopeGenerator.Application.Common.Query; using System.ComponentModel.DataAnnotations.Schema;
namespace EnvelopeGenerator.Application.Common.Query;
/// <summary> /// <summary>
/// Stellt eine Abfrage dar, um die Details eines Empfängers zu lesen. /// Stellt eine Abfrage dar, um die Details eines Empfängers zu lesen.
@@ -29,5 +31,6 @@ public record ReceiverQueryBase
/// <see cref="Id"/>, <see cref="EmailAddress"/>, or <see cref="Signature"/> is not null. /// <see cref="Id"/>, <see cref="EmailAddress"/>, or <see cref="Signature"/> is not null.
/// <para>Usage example: The query can be executed only if at least one criterion is specified.</para> /// <para>Usage example: The query can be executed only if at least one criterion is specified.</para>
/// </remarks> /// </remarks>
public bool HasAnyCriteria => Id is not null || EmailAddress is not null || Signature is not null; [NotMapped]
public virtual bool HasAnyCriteria => Id is not null || EmailAddress is not null || Signature is not null;
} }

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

@@ -5,6 +5,7 @@ using EnvelopeGenerator.Application.Common.Query;
using MediatR; using MediatR;
using EnvelopeGenerator.Domain.Entities; using EnvelopeGenerator.Domain.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations.Schema;
namespace EnvelopeGenerator.Application.Receivers.Queries; namespace EnvelopeGenerator.Application.Receivers.Queries;
@@ -12,7 +13,24 @@ namespace EnvelopeGenerator.Application.Receivers.Queries;
/// Stellt eine Abfrage dar, um die Details eines Empfängers zu lesen. /// Stellt eine Abfrage dar, um die Details eines Empfängers zu lesen.
/// um spezifische Informationen über einen Empfänger abzurufen. /// um spezifische Informationen über einen Empfänger abzurufen.
/// </summary> /// </summary>
public record ReadReceiverQuery : ReceiverQueryBase, IRequest<IEnumerable<ReceiverDto>>; public record ReadReceiverQuery : ReceiverQueryBase, IRequest<IEnumerable<ReceiverDto>>
{
/// <summary>
/// Suchbegriff für eine teilweise Übereinstimmung in der E-Mail Adresse des Empfängers
/// </summary>
public virtual string? EmailAddressSearch { get; set; }
/// <summary>
/// Checks whether any of the specified query criteria have a value.
/// </summary>
/// <remarks>
/// This property returns <c>true</c> if at least one of the fields
/// <see cref="ReceiverQueryBase.Id"/>, <see cref="ReceiverQueryBase.EmailAddress"/>, <see cref="EmailAddressSearch"/>, or <see cref="ReceiverQueryBase.Signature"/> is not null.
/// <para>Usage example: The query can be executed only if at least one criterion is specified.</para>
/// </remarks>
[NotMapped]
public override bool HasAnyCriteria => EmailAddressSearch is not null || base.HasAnyCriteria;
}
/// <summary> /// <summary>
/// ///
@@ -53,6 +71,11 @@ public class ReadReceiverQueryHandler : IRequestHandler<ReadReceiverQuery, IEnum
query = query.Where(r => r.EmailAddress == email); query = query.Where(r => r.EmailAddress == email);
} }
if (!string.IsNullOrWhiteSpace(request.EmailAddressSearch))
{
query = query.Where(r => EF.Functions.Like(r.EmailAddress, $"%{request.EmailAddressSearch}%"));
}
if (request.Signature is string signature) if (request.Signature is string signature)
{ {
query = query.Where(r => r.Signature == signature); query = query.Where(r => r.Signature == signature);

View File

@@ -1,6 +1,7 @@
Imports System.IO Imports System.IO
Imports DigitalData.Modules.Database Imports DigitalData.Modules.Database
Imports DigitalData.Modules.Logging Imports DigitalData.Modules.Logging
Imports EnvelopeGenerator.CommonServices
Imports EnvelopeGenerator.CommonServices.Jobs Imports EnvelopeGenerator.CommonServices.Jobs
Imports EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument Imports EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument
Imports GdPicture14 Imports GdPicture14
@@ -145,64 +146,91 @@ Public Class frmFinalizePDF
log.AppendLine($"Document bytes: {oBuffer.Length}") log.AppendLine($"Document bytes: {oBuffer.Length}")
' 3. Check what BurnAnnotsToPDF will do internally ' 3. Check what BurnAnnotsToPDF will do internally
Dim oEnvelopeEntity As Envelope = Nothing
Using scope = Factory.Shared.ScopeFactory.CreateScope() Using scope = Factory.Shared.ScopeFactory.CreateScope()
Dim envRepo = scope.ServiceProvider.Repository(Of Envelope)() Dim envRepo = scope.ServiceProvider.Repository(Of Envelope)()
Dim envelope = envRepo.Where(Function(env) env.Id = envelopeId).FirstOrDefault() oEnvelopeEntity = envRepo.Where(Function(env) env.Id = envelopeId).FirstOrDefault()
If envelope Is Nothing Then If oEnvelopeEntity Is Nothing Then
log.AppendLine("ERROR: Envelope not found in EF Core!") log.AppendLine("ERROR: Envelope not found in EF Core!")
txtResult.Text = log.ToString() txtResult.Text = log.ToString()
Return Return
End If End If
log.AppendLine($"Envelope found: Id={envelope.Id}, EnvelopeTypeId={envelope.EnvelopeTypeId}") log.AppendLine($"Envelope found: Id={oEnvelopeEntity.Id}, EnvelopeTypeId={oEnvelopeEntity.EnvelopeTypeId}")
log.AppendLine($"ReadOnly (IsReadAndConfirm): {envelope.ReadOnly}") log.AppendLine($"ReadOnly (IsReadAndConfirm): {oEnvelopeEntity.ReadOnly}")
If envelope.ReadOnly Then If oEnvelopeEntity.ReadOnly Then
log.AppendLine(">>> EARLY RETURN: ReadOnly=True, original PDF returned without burning") log.AppendLine(">>> ReadOnly=True: original PDF will be used without burning (annotations skipped)")
txtResult.Text = log.ToString()
Return
End If
Dim sigRepo = scope.ServiceProvider.Repository(Of DocReceiverElement)()
Dim elements = sigRepo _
.Where(Function(sig) sig.Document.EnvelopeId = envelopeId) _
.Include(Function(sig) sig.Annotations) _
.ToList()
log.AppendLine($"Elements (Signature) count: {elements.Count}")
If elements.Any() Then
log.AppendLine(">>> PATH: BurnElementAnnotsToPDF (new element-based path)")
For Each elem In elements
Dim annotCount = If(elem.Annotations IsNot Nothing, elem.Annotations.Count(), 0)
log.AppendLine($" Element Id={elem.Id}, Page={elem.Page}, X={elem.X}, Y={elem.Y}, W={elem.Width}, H={elem.Height}, Annotations={annotCount}")
If elem.Annotations IsNot Nothing Then
For Each annot In elem.Annotations
log.AppendLine($" Annot: Name={annot.Name}, Type={annot.Type}, X={annot.X}, Y={annot.Y}, W={annot.Width}, H={annot.Height}")
Next
End If
Next
Else Else
log.AppendLine(">>> PATH: BurnInstantJSONAnnotsToPDF (old JSON-based path)") Dim sigRepo = scope.ServiceProvider.Repository(Of DocReceiverElement)()
Dim elements = sigRepo _
.Where(Function(sig) sig.Document.EnvelopeId = envelopeId) _
.Include(Function(sig) sig.Annotations) _
.ToList()
log.AppendLine($"Elements (Signature) count: {elements.Count}")
If elements.Any() Then
log.AppendLine(">>> PATH: BurnElementAnnotsToPDF (new element-based path)")
For Each elem In elements
Dim annotCount = If(elem.Annotations IsNot Nothing, elem.Annotations.Count(), 0)
log.AppendLine($" Element Id={elem.Id}, Page={elem.Page}, X={elem.X}, Y={elem.Y}, W={elem.Width}, H={elem.Height}, Annotations={annotCount}")
If elem.Annotations IsNot Nothing Then
For Each annot In elem.Annotations
log.AppendLine($" Annot: Name={annot.Name}, Type={annot.Type}, X={annot.X}, Y={annot.Y}, W={annot.Width}, H={annot.Height}")
Next
End If
Next
Else
log.AppendLine(">>> PATH: BurnInstantJSONAnnotsToPDF (old JSON-based path)")
End If
End If End If
End Using End Using
' 4. Actually call BurnAnnotsToPDF (same as Service) ' 4. Burn annotations (ReadOnly envelopes return original PDF unchanged)
log.AppendLine("") log.AppendLine("")
log.AppendLine("Calling BurnAnnotsToPDF...") log.AppendLine("Calling BurnAnnotsToPDF...")
Dim oNewBuffer = PDFBurner.BurnAnnotsToPDF(oBuffer, oJsonList, envelopeId) Dim oBurnedBuffer As Byte() = PDFBurner.BurnAnnotsToPDF(oBuffer, oJsonList, envelopeId)
log.AppendLine($"Result bytes: {oNewBuffer.Length}") log.AppendLine($"Burned bytes: {oBurnedBuffer.Length}")
log.AppendLine($"Same as input: {oBuffer.Length = oNewBuffer.Length AndAlso oBuffer.SequenceEqual(oNewBuffer)}") log.AppendLine($"Unchanged (ReadOnly): {oBuffer.Length = oBurnedBuffer.Length AndAlso oBuffer.SequenceEqual(oBurnedBuffer)}")
' 5. Write output ' 5. Build State and load Envelope domain model for ReportCreator
log.AppendLine("")
log.AppendLine("Loading envelope domain model for report...")
Dim oState As New State() With {
.Database = Database,
.LogConfig = LogConfig
}
Dim oEnvelopeModel As New EnvelopeModel(oState)
Dim oEnvelope = oEnvelopeModel.GetById(envelopeId)
If oEnvelope Is Nothing Then
log.AppendLine("ERROR: Envelope not found via EnvelopeModel!")
txtResult.Text = log.ToString()
Return
End If
log.AppendLine($"Envelope loaded: {oEnvelope.Id}")
' 6. Create signing certificate report
log.AppendLine("Creating signing certificate report...")
Dim oReportCreator As New ReportCreator(LogConfig, oState)
Dim oReportBuffer As Byte() = oReportCreator.CreateReport(oEnvelope)
log.AppendLine($"Report bytes: {oReportBuffer.Length}")
' 7. Merge burned document + report (last page)
log.AppendLine("Merging documents...")
Dim oMerger As New PDFMerger(LogConfig, pGDPictureLicenseKey)
Dim oMergedBuffer As Byte() = oMerger.MergeDocuments(oBurnedBuffer, oReportBuffer)
log.AppendLine($"Merged bytes: {oMergedBuffer.Length}")
' 8. Save and open
Dim desktopPath As String = Environment.GetFolderPath(Environment.SpecialFolder.Desktop) Dim desktopPath As String = Environment.GetFolderPath(Environment.SpecialFolder.Desktop)
Dim oNewPath = Path.Combine(desktopPath, $"E{txtEnvelope.Text}_FullTest.burned.pdf") Dim oMergedPath = Path.Combine(desktopPath, $"E{txtEnvelope.Text}_FullTest.merged.pdf")
File.WriteAllBytes(oNewPath, oNewBuffer) File.WriteAllBytes(oMergedPath, oMergedBuffer)
log.AppendLine($"Output: {oNewPath}") log.AppendLine($"Output: {oMergedPath}")
txtResult.Text = log.ToString() txtResult.Text = log.ToString()
Process.Start(oNewPath) Process.Start(oMergedPath)
Catch ex As Exception Catch ex As Exception
txtResult.Text = $"ERROR: {ex.Message}{vbCrLf}{vbCrLf}{ex.ToString()}" txtResult.Text = $"ERROR: {ex.Message}{vbCrLf}{vbCrLf}{ex.ToString()}"

View File

@@ -119,7 +119,7 @@ Namespace Jobs.FinalizeDocument
Dim y = frameY + yOffsetofFF Dim y = frameY + yOffsetofFF
If annot.Type = AnnotationType.FormField Then If annot.Type = AnnotationType.FormField Then
AddFormFieldValue(annot.X / inchFactor, y, annot.Width / inchFactor, annot.Height / inchFactor, element.Page, annot.Value) AddFormFieldValue(annot.X / inchFactor, y, annot.Width / inchFactor, annot.Height / inchFactor, element.Page, annot.Value, annot.Name)
ElseIf annot.Type = AnnotationType.Image Then ElseIf annot.Type = AnnotationType.Image Then
AddImageAnnotation( AddImageAnnotation(
annot.X / inchFactor, annot.X / inchFactor,
@@ -269,11 +269,14 @@ Namespace Jobs.FinalizeDocument
Next Next
End Sub End Sub
Private Sub AddFormFieldValue(x As Double, y As Double, width As Double, height As Double, page As Integer, value As String) Private Sub AddFormFieldValue(x As Double, y As Double, width As Double, height As Double, page As Integer, value As String, Optional egName As String = "")
Manager.SelectPage(page) Manager.SelectPage(page)
' Truncate if needed
Dim displayValue As String = TruncateText(value, egName)
' Add the text annotation ' Add the text annotation
Dim ant = Manager.AddTextAnnot(x, y, width, height, value) Dim ant = Manager.AddTextAnnot(x, y, width, height, displayValue)
' Set the font properties ' Set the font properties
ant.FontName = _pdfBurnerParams.FontName ant.FontName = _pdfBurnerParams.FontName
@@ -294,8 +297,9 @@ Namespace Jobs.FinalizeDocument
Dim oHeight = oBounds.Item(3) Dim oHeight = oBounds.Item(3)
Manager.SelectPage(pAnnotation.pageIndex + 1) Manager.SelectPage(pAnnotation.pageIndex + 1)
' Add the text annotation ' Add the text annotation (truncate if needed)
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, formFieldValue.value) Dim displayValue As String = TruncateText(formFieldValue.value, pAnnotation.egName)
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, displayValue)
' Set the font properties ' Set the font properties
ant.FontName = _pdfBurnerParams.FontName ant.FontName = _pdfBurnerParams.FontName
@@ -306,6 +310,48 @@ Namespace Jobs.FinalizeDocument
#End Region #End Region
#Region "Helpers" #Region "Helpers"
''' <summary>
''' Truncates a field value so its rendered width fits within the configured max width (inches).
''' Uses Graphics.MeasureString with the configured font for pixel-accurate measurement.
''' Appends TruncationSuffix when the value is cut.
''' </summary>
Private Function TruncateText(value As String, fieldEgName As String) As String
If String.IsNullOrEmpty(value) Then Return value
If _pdfBurnerParams.TextMaxWidths Is Nothing Then Return value
Dim maxWidthInches As Single = 0
If Not _pdfBurnerParams.TextMaxWidths.TryGetValue(fieldEgName, maxWidthInches) Then Return value
If maxWidthInches <= 0 Then Return value
Dim suffix As String = If(_pdfBurnerParams.TruncationSuffix, "")
Using bmp As New Bitmap(1, 1)
Using g As Graphics = Graphics.FromImage(bmp)
Dim font As New Font(_pdfBurnerParams.FontName, _pdfBurnerParams.FontSize, _pdfBurnerParams.FontStyle, GraphicsUnit.Point)
Dim maxWidthPx As Single = maxWidthInches * g.DpiX
' Already fits — no truncation needed
If g.MeasureString(value, font).Width <= maxWidthPx Then Return value
' Binary search for the longest prefix that fits (with suffix)
Dim lo As Integer = 0
Dim hi As Integer = value.Length
While lo < hi
Dim mid As Integer = (lo + hi + 1) \ 2
Dim candidate As String = value.Substring(0, mid) & suffix
If g.MeasureString(candidate, font).Width <= maxWidthPx Then
lo = mid
Else
hi = mid - 1
End If
End While
Return value.Substring(0, lo) & suffix
End Using
End Using
End Function
Private Function ToPointF(pPoints As List(Of Single)) As PointF Private Function ToPointF(pPoints As List(Of Single)) As PointF
Dim oPoints = pPoints.Select(AddressOf ToInches).ToList() Dim oPoints = pPoints.Select(AddressOf ToInches).ToList()
Return New PointF(oPoints.Item(0), oPoints.Item(1)) Return New PointF(oPoints.Item(0), oPoints.Item(1))

View File

@@ -12,5 +12,24 @@ Namespace Jobs.FinalizeDocument
Public Property FontSize As Integer = 8 Public Property FontSize As Integer = 8
Public Property FontStyle As FontStyle = FontStyle.Italic Public Property FontStyle As FontStyle = FontStyle.Italic
''' <summary>
''' The suffix appended to a truncated field value. Defaults to "..".
''' Can be changed to any string (e.g. "…", "...", "").
''' </summary>
Public Property TruncationSuffix As String = ".."
''' <summary>
''' Per-field maximum render width in inches.
''' Text is measured using the configured Font/FontSize via Graphics.MeasureString (pixel-accurate).
''' If the rendered width exceeds the limit, the text is cut and TruncationSuffix is appended.
''' Keys must match egName values used in PDFBurner (e.g. "name", "position", "city", "date").
''' Set a value to 0 to disable truncation for that field.
''' </summary>
Public Property TextMaxWidths As New Dictionary(Of String, Single) From {
{"position", 1.41F},
{"city", 1.41F},
{"date", 2.0F}
}
End Class End Class
End Namespace End Namespace

View File

@@ -1,5 +1,6 @@
Imports EnvelopeGenerator.Domain.Constants Imports EnvelopeGenerator.Domain.Constants
Imports EnvelopeGenerator.Domain.Entities Imports EnvelopeGenerator.Domain.Entities
Imports EnvelopeGenerator.Domain.Interfaces
Public Class ReportItem Public Class ReportItem
@@ -11,8 +12,15 @@ Public Class ReportItem
Public Property ItemStatus As EnvelopeStatus Public Property ItemStatus As EnvelopeStatus
Public ReadOnly Property ItemStatusTranslated As String Public ReadOnly Property ItemStatusTranslated As String
Get Get
Dim oStatus = ItemStatus.ToString() Dim oStatusKey = ItemStatus.ToString()
Return My.Resources.Model.ResourceManager.GetString(oStatus)
If ItemStatus = EnvelopeStatus.DocumentSigned AndAlso
Envelope IsNot Nothing AndAlso
Envelope.IsReadAndConfirm() Then
oStatusKey = "DocumentSignedRaC"
End If
Return My.Resources.Model.ResourceManager.GetString(oStatusKey)
End Get End Get
End Property End Property

View File

@@ -168,6 +168,9 @@
<data name="DocumentSigned" xml:space="preserve"> <data name="DocumentSigned" xml:space="preserve">
<value>Document signed</value> <value>Document signed</value>
</data> </data>
<data name="DocumentSignedRaC" xml:space="preserve">
<value>Read and confirmed</value>
</data>
<data name="Draft" xml:space="preserve"> <data name="Draft" xml:space="preserve">
<value>Draft</value> <value>Draft</value>
</data> </data>

View File

@@ -168,6 +168,9 @@
<data name="DocumentSigned" xml:space="preserve"> <data name="DocumentSigned" xml:space="preserve">
<value>Dokument unterzeichnet</value> <value>Dokument unterzeichnet</value>
</data> </data>
<data name="DocumentSignedRaC" xml:space="preserve">
<value>Gelesen und bestätigt</value>
</data>
<data name="Draft" xml:space="preserve"> <data name="Draft" xml:space="preserve">
<value>Entwurf</value> <value>Entwurf</value>
</data> </data>

View File

@@ -1,5 +1,6 @@
#if NETFRAMEWORK #if NETFRAMEWORK
using System; using System;
using System.Collections.Generic;
#endif #endif
namespace EnvelopeGenerator.Domain.Constants namespace EnvelopeGenerator.Domain.Constants
@@ -15,7 +16,11 @@ namespace EnvelopeGenerator.Domain.Constants
public static class Receiver public static class Receiver
{ {
public const string TFA = "EGReceiverTFA"; public const string TFA = "EGReceiverTFA";
[Obsolete("Use Receiver.Authorized")]
public const string Full = "EGReceiver"; public const string Full = "EGReceiver";
public static readonly IReadOnlyList<string> Authorized = new[] { "receiver", "EGReceiver" };
} }
public const string Sender = "EGSender"; public const string Sender = "EGSender";

View File

@@ -213,6 +213,15 @@ namespace My.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Gelesen und bestätigt.
/// </summary>
public static string DocumentSignedRaC {
get {
return ResourceManager.GetString("DocumentSignedRaC", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to Entwurf. /// Looks up a localized string similar to Entwurf.
/// </summary> /// </summary>

View File

@@ -168,6 +168,9 @@
<data name="DocumentSigned" xml:space="preserve"> <data name="DocumentSigned" xml:space="preserve">
<value>Dokument unterzeichnet</value> <value>Dokument unterzeichnet</value>
</data> </data>
<data name="DocumentSignedRaC" xml:space="preserve">
<value>Gelesen und bestätigt</value>
</data>
<data name="Draft" xml:space="preserve"> <data name="Draft" xml:space="preserve">
<value>Entwurf</value> <value>Entwurf</value>
</data> </data>

View File

@@ -14,6 +14,7 @@ using EnvelopeGenerator.Infrastructure.Repositories;
using EnvelopeGenerator.Infrastructure.Executor; using EnvelopeGenerator.Infrastructure.Executor;
using EnvelopeGenerator.Application.Common.Interfaces.Repositories; using EnvelopeGenerator.Application.Common.Interfaces.Repositories;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor; using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities; using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
#elif NETFRAMEWORK #elif NETFRAMEWORK
using System; using System;
@@ -55,6 +56,9 @@ namespace EnvelopeGenerator.Infrastructure
services.TryAddScoped<IEnvelopeTypeRepository, EnvelopeTypeRepository>(); services.TryAddScoped<IEnvelopeTypeRepository, EnvelopeTypeRepository>();
services.TryAddScoped<IReceiverRepository, ReceiverRepository>(); services.TryAddScoped<IReceiverRepository, ReceiverRepository>();
services.TryAddScoped<IEnvelopeReceiverReadOnlyRepository, EnvelopeReceiverReadOnlyRepository>(); services.TryAddScoped<IEnvelopeReceiverReadOnlyRepository, EnvelopeReceiverReadOnlyRepository>();
// User Preferences — distributed cache (dbo.TBDD_CACHE)
services.TryAddScoped<IUserPreferencesService, UserPreferencesService>();
#endif #endif
services.AddDbRepository(opt => services.AddDbRepository(opt =>

View File

@@ -26,6 +26,7 @@
<PackageReference Include="DigitalData.Core.Infrastructure" Version="2.6.1" /> <PackageReference Include="DigitalData.Core.Infrastructure" Version="2.6.1" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" /> <PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" /> <PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
<PackageReference Include="QuestPDF" Version="2025.7.1" /> <PackageReference Include="QuestPDF" Version="2025.7.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />

View File

@@ -0,0 +1,46 @@
#if NET
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
namespace EnvelopeGenerator.Infrastructure;
/// <summary>
/// Implements user preferences storage using the distributed cache (dbo.TBDD_CACHE).
/// No DB schema changes required — uses the existing SQL Server distributed cache table.
/// Cache key schema: "signflow.user-preferences:{username}"
/// Expiration: indefinite (no TTL) — preferences persist until explicitly overwritten.
/// </summary>
public sealed class UserPreferencesService(IDistributedCache cache) : IUserPreferencesService
{
private const string KeyPrefix = "signflow.user-preferences:";
private static string BuildKey(string id) => $"{KeyPrefix}{id}";
/// <inheritdoc />
public async Task<UserPreferencesDto> GetOrCreateAsync(string id, CancellationToken ct = default)
{
var json = await cache.GetStringAsync(BuildKey(id), ct);
if(json is not null && JsonSerializer.Deserialize<UserPreferencesDto>(json) is UserPreferencesDto pref)
{
return pref;
}
else
{
// init new
await SaveAsync(id, UserPreferencesDto.Default, ct);
return UserPreferencesDto.Default;
}
}
/// <inheritdoc />
public async Task SaveAsync(string id, UserPreferencesDto preferences, CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(preferences);
// No expiration — preferences are stored indefinitely.
await cache.SetStringAsync(BuildKey(id), json, new DistributedCacheEntryOptions(), ct);
}
}
#endif

View File

@@ -9,7 +9,7 @@
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish> <LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<ExcludeApp_Data>false</ExcludeApp_Data> <ExcludeApp_Data>false</ExcludeApp_Data>
<ProjectGuid>fb2d306b-1042-4a70-31ed-f991a1599371</ProjectGuid> <ProjectGuid>fb2d306b-1042-4a70-31ed-f991a1599371</ProjectGuid>
<DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\signFLOW\ReceiverUI\net8\$(Version)\EnvelopeGenerator.ReceiverUI.zip</DesktopBuildPackageLocation> <DesktopBuildPackageLocation>M:\App&amp;Service\0 DD - Smart UP\signFLOW\PreRelease\ReceiverUI\net8\$(Version)\EnvelopeGenerator.ReceiverUI.zip</DesktopBuildPackageLocation>
<PackageAsSingleFile>true</PackageAsSingleFile> <PackageAsSingleFile>true</PackageAsSingleFile>
<DeployIisAppPath>EnvelopeGenerator.ReceiverUI</DeployIisAppPath> <DeployIisAppPath>EnvelopeGenerator.ReceiverUI</DeployIisAppPath>
<_TargetId>IISWebDeployPackage</_TargetId> <_TargetId>IISWebDeployPackage</_TargetId>

View File

@@ -1,28 +1,43 @@
@using EnvelopeGenerator.Server.Client.Services; @using EnvelopeGenerator.Server.Client.Services
@inherits LayoutComponentBase @inherits LayoutComponentBase
@implements IDisposable
<div class="page"> @inject ThemeService ThemeService
<main>
<HeadContent>
<link href="@ThemeService.ThemeCssHref" rel="stylesheet" />
</HeadContent>
<div class="page" data-bs-theme="@ThemeService.BootstrapTheme">
<main>
<article class="content"> <article class="content">
@Body @Body
</article> </article>
</main> </main>
<footer class="receiver-footer"> <footer class="receiver-footer">
<span>&copy; SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span> <div class="receiver-footer__left">
<span class="receiver-footer__sep">&#124;</span> <span>&copy; SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a> <span class="receiver-footer__sep">&#124;</span>
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
</div>
<ThemeControls />
</footer> </footer>
</div> </div>
@code { @code {
[Inject] IHttpClientFactory HttpClientFactory { get; set; } = default!; [Inject] IHttpClientFactory HttpClientFactory { get; set; } = default!;
List<string> RequiredFonts = new() {
"opensans.ttf"
};
protected async override Task OnInitializedAsync() { private Action? _themeChangeHandler;
await FontLoader.LoadFonts(HttpClientFactory, RequiredFonts);
protected override async Task OnInitializedAsync()
{
await FontLoader.LoadFonts(HttpClientFactory, ["opensans.ttf"]);
_themeChangeHandler = () => InvokeAsync(StateHasChanged);
ThemeService.OnChange += _themeChangeHandler;
await base.OnInitializedAsync(); await base.OnInitializedAsync();
} }
}
public void Dispose()
=> ThemeService.OnChange -= _themeChangeHandler;
}

View File

@@ -0,0 +1,95 @@
@using EnvelopeGenerator.Server.Client.Services
@rendermode InteractiveWebAssembly
@implements IDisposable
@inject ThemeService ThemeService
<div class="sf-theme-slider">
@* Moon — left end (blazing-dark) *@
<svg class="sf-theme-icon @(ThemeService.ThemeName == "blazing-dark" ? "sf-theme-icon--active" : "")"
xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 24 24"
title="Blazing Dark">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
@* Track wrapper: range input + left/mid/right ticks *@
<div class="sf-theme-track-wrapper">
@* Left tick *@
<div class="sf-theme-tick sf-theme-tick--left"></div>
@* Mid tick (marks the middle position) *@
<div class="sf-theme-tick sf-theme-tick--mid"></div>
@* Right tick *@
<div class="sf-theme-tick sf-theme-tick--right"></div>
<input type="range"
class="sf-theme-range"
min="0" max="2" step="1"
value="@SliderValue"
@oninput="OnSliderChanged"
title="@ThemeService.AvailableThemes[SliderValue].Label" />
</div>
@* Sun — right end (purple) *@
<svg class="sf-theme-icon @(ThemeService.ThemeName == "purple" ? "sf-theme-icon--active" : (ThemeService.ThemeName == "blazing-berry" ? "sf-theme-icon--mid" : ""))"
xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="1.5"
title="Blazing Berry / Purple">
<circle cx="12" cy="12" r="4" fill="currentColor" stroke="none"/>
<line x1="12" y1="2" x2="12" y2="5"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/>
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/>
<line x1="2" y1="12" x2="5" y2="12"/>
<line x1="19" y1="12" x2="22" y2="12"/>
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/>
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
</svg>
</div>
@code {
private Action? _themeChangeHandler;
/// <summary>
/// Maps the active theme to a 0-based slider step.
/// 0 = blazing-dark (🌙 left)
/// 1 = blazing-berry (middle)
/// 2 = purple (☀️ right)
/// </summary>
private int SliderValue => ThemeService.ThemeName switch
{
"blazing-dark" => 0,
"purple" => 2,
_ => 1
};
protected override void OnInitialized()
{
_themeChangeHandler = () => InvokeAsync(StateHasChanged);
ThemeService.OnChange += _themeChangeHandler;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await ThemeService.InitializeAsync();
}
private async Task OnSliderChanged(ChangeEventArgs e)
{
if (!int.TryParse(e.Value?.ToString(), out var step)) return;
var themeName = step switch
{
0 => "blazing-dark",
2 => "purple",
_ => "blazing-berry"
};
await ThemeService.SetThemeAsync(themeName);
}
public void Dispose()
=> ThemeService.OnChange -= _themeChangeHandler;
}

View File

@@ -5,19 +5,15 @@
@using EnvelopeGenerator.Server.Client.Models @using EnvelopeGenerator.Server.Client.Models
@using DevExpress.Blazor @using DevExpress.Blazor
@using EnvelopeGenerator.Server.Client.Services @using EnvelopeGenerator.Server.Client.Services
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
@using EnvelopeGenerator.Application.Common.Dto @using EnvelopeGenerator.Application.Common.Dto
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService @inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService @inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion @inject AppVersionService AppVersion
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" /> <link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/sender-page.css")" rel="stylesheet" /> <link href="@AppVersion.GetVersionedUrl("css/sender-page.css")" rel="stylesheet" />
@@ -136,9 +132,13 @@
SelectionMode="GridSelectionMode.Single" SelectionMode="GridSelectionMode.Single"
SelectedDataItem="@_selectedEnvelope" SelectedDataItem="@_selectedEnvelope"
SelectedDataItemChanged="@OnSelectedEnvelopeChanged" SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
CustomizeElement="OnCustomizeElement"> CustomizeElement="OnCustomizeElement"
LayoutAutoLoading="OnActiveGridLayoutAutoLoading"
LayoutAutoSaving="OnActiveGridLayoutAutoSaving">
<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>
@@ -232,9 +232,13 @@
SelectionMode="GridSelectionMode.Single" SelectionMode="GridSelectionMode.Single"
SelectedDataItem="@_selectedEnvelope" SelectedDataItem="@_selectedEnvelope"
SelectedDataItemChanged="@OnSelectedEnvelopeChanged" SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
CustomizeElement="OnCustomizeElement"> CustomizeElement="OnCustomizeElement"
LayoutAutoLoading="OnCompletedGridLayoutAutoLoading"
LayoutAutoSaving="OnCompletedGridLayoutAutoSaving">
<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>
@@ -337,6 +341,10 @@
private DxGrid? _gridActive; private DxGrid? _gridActive;
private DxGrid? _gridCompleted; private DxGrid? _gridCompleted;
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
private const string GridKeyActive = "sender.active-envelopes";
private const string GridKeyCompleted = "sender.completed-envelopes";
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var hasAccess = await AuthService.CheckSenderAccessAsync(); var hasAccess = await AuthService.CheckSenderAccessAsync();
@@ -346,6 +354,12 @@
return; return;
} }
// Load theme + DisplayName from server after authentication.
// Non-fatal: if this fails the page still loads with default theme.
// ThemeService.ScheduleSaveAsync() already logs errors internally.
try { await ThemeService.LoadFromServerAsync(); }
catch { /* swallow — default theme remains active */ }
await LoadEnvelopesAsync(); await LoadEnvelopesAsync();
} }
@@ -363,13 +377,10 @@
var envelopes = _allEnvelopes.ToList(); var envelopes = _allEnvelopes.ToList();
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList(); _activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList(); _completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
await JSRuntime.InvokeVoidAsync("console.log", $"Loaded {_activeEnvelopes.Count()} active and {_completedEnvelopes.Count()} completed envelopes");
} }
catch (Exception ex) catch (Exception ex)
{ {
_errorMessage = ex.Message; _errorMessage = ex.Message;
await JSRuntime.InvokeVoidAsync("console.error", "Fehler beim Laden der Umschläge:", ex.ToString());
} }
finally finally
{ {
@@ -385,28 +396,26 @@
void CreateEnvelope() void CreateEnvelope()
{ {
// TODO: Navigate to envelope creation page Navigation.NavigateTo("/sender/editor");
JSRuntime.InvokeVoidAsync("console.log", "Create envelope clicked - not yet implemented");
} }
void EditEnvelope() void EditEnvelope()
{ {
if (_selectedEnvelope == null) return; if (_selectedEnvelope == null) return;
// TODO: Navigate to envelope editor // TODO: Navigate to envelope editor
JSRuntime.InvokeVoidAsync("console.log", $"Edit envelope {_selectedEnvelope.Id} clicked - not yet implemented");
} }
void DeleteEnvelope() void DeleteEnvelope()
{ {
if (_selectedEnvelope == null) return; if (_selectedEnvelope == null) return;
// TODO: Show delete confirmation dialog // TODO: Show delete confirmation dialog
JSRuntime.InvokeVoidAsync("console.log", $"Delete envelope {_selectedEnvelope.Id} clicked - not yet implemented");
} }
async Task LogoutAsync() async Task LogoutAsync()
{ {
_isLoggingOut = true; _isLoggingOut = true;
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
UserPreferencesService.InvalidateCache();
await AuthService.LogoutSenderAsync(); await AuthService.LogoutSenderAsync();
Navigation.NavigateTo("/sender/login", forceLoad: true); Navigation.NavigateTo("/sender/login", forceLoad: true);
} }
@@ -443,4 +452,45 @@
{ {
_selectedEnvelope = envelope as EnvelopeDto; _selectedEnvelope = envelope as EnvelopeDto;
} }
// ── Grid Layout Persistence ───────────────────────────────────────────────
// LayoutAutoLoading fires when the grid initializes — restore saved layout.
// LayoutAutoSaving fires when the user changes column order/width/sort — persist it.
// UserPreferencesService.GetAsync() uses the 1h in-memory cache → 0 HTTP on restore.
// UserPreferencesService.SaveGridLayoutAsync() merges the single key → 1 PUT.
async Task OnActiveGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
{
// Use cached preferences only — avoid HTTP during grid initialization.
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(GridKeyActive, out var json))
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
async Task OnActiveGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
try
{
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(GridKeyActive, json);
}
catch (Exception ex)
{
await JSRuntime.InvokeVoidAsync("console.warn", $"[Sender] Grid layout save failed: {ex.Message}");
}
}
async Task OnCompletedGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
{
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(GridKeyCompleted, out var json))
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
async Task OnCompletedGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(GridKeyCompleted, json);
}
} }

View File

@@ -1,7 +1,7 @@
@page "/" @page "/"
@inject IJSRuntime JS @inject IJSRuntime JS
@inject NavigationManager Navigation
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" /> @rendermode InteractiveWebAssembly
<div class="home-page-wrapper"> <div class="home-page-wrapper">
@@ -25,7 +25,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">
@@ -47,6 +58,7 @@
PDF-Export PDF-Export
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -4,8 +4,6 @@
@inject AuthService AuthService @inject AuthService AuthService
@inject NavigationManager Navigation @inject NavigationManager Navigation
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100"> <div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;"> <div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
@@ -15,14 +13,14 @@
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" /> <path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
</svg> </svg>
</div> </div>
<h5 class="mb-0 fw-semibold">Dokument öffnen</h5> <h5 class="mb-0 fw-semibold">Dokument öffnen</h5>
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">Sicherer Zugang mit Zugangscode</p> <p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">Sicherer Zugang mit Zugangscode</p>
</div> </div>
<div class="card-body p-4"> <div class="card-body p-4">
<p class="text-muted mb-4" style="font-size: 0.875rem; line-height: 1.5;"> <p class="text-muted mb-4" style="font-size: 0.875rem; line-height: 1.5;">
Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen. Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.
</p> </p>
@if (LoginResult == EnvelopeLoginResult.NotFound) @if (LoginResult == EnvelopeLoginResult.NotFound)
@@ -33,7 +31,7 @@
</svg> </svg>
<div> <div>
<strong>Dokument nicht gefunden.</strong><br /> <strong>Dokument nicht gefunden.</strong><br />
<span style="font-size:0.85rem;">Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</span> <span style="font-size:0.85rem;">Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</span>
</div> </div>
</div> </div>
} }
@@ -44,7 +42,7 @@
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" /> <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg> </svg>
<div> <div>
<strong>Ungültiger Zugangscode.</strong><br /> <strong>Ungültiger Zugangscode.</strong><br />
<span style="font-size:0.85rem;">Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.</span> <span style="font-size:0.85rem;">Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.</span>
</div> </div>
</div> </div>
@@ -58,7 +56,7 @@
</svg> </svg>
<div> <div>
<strong>Serverfehler.</strong><br /> <strong>Serverfehler.</strong><br />
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span> <span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
</div> </div>
</div> </div>
} }
@@ -115,14 +113,14 @@
@if (IsLoading) @if (IsLoading)
{ {
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span> <span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span>Überprüfen …</span> <span>Überprüfen …</span>
} }
else else
{ {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" /> <path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
</svg> </svg>
<span>Dokument öffnen</span> <span>Dokument öffnen</span>
} }
</button> </button>

View File

@@ -4,8 +4,6 @@
@inject AuthService AuthService @inject AuthService AuthService
@inject NavigationManager Navigation @inject NavigationManager Navigation
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100"> <div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;"> <div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
@@ -32,7 +30,7 @@
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/> <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg> </svg>
<div> <div>
<strong>Ungültige Anmeldedaten.</strong><br /> <strong>Ungültige Anmeldedaten.</strong><br />
<span style="font-size:0.85rem;">Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.</span> <span style="font-size:0.85rem;">Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.</span>
</div> </div>
</div> </div>
@@ -44,7 +42,7 @@
</svg> </svg>
<div> <div>
<strong>Serverfehler.</strong><br /> <strong>Serverfehler.</strong><br />
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span> <span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
</div> </div>
</div> </div>
} }
@@ -120,7 +118,7 @@
disabled="@(IsLoading || string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))"> disabled="@(IsLoading || string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))">
@if (IsLoading) { @if (IsLoading) {
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span> <span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span>Anmelden …</span> <span>Anmelden …</span>
} else { } else {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 3.5a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 1 1 0v2A1.5 1.5 0 0 1 9.5 14h-8A1.5 1.5 0 0 1 0 12.5v-9A1.5 1.5 0 0 1 1.5 2h8A1.5 1.5 0 0 1 11 3.5v2a.5.5 0 0 1-1 0v-2z"/> <path fill-rule="evenodd" d="M10 3.5a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 1 1 0v2A1.5 1.5 0 0 1 9.5 14h-8A1.5 1.5 0 0 1 0 12.5v-9A1.5 1.5 0 0 1 1.5 2h8A1.5 1.5 0 0 1 11 3.5v2a.5.5 0 0 1-1 0v-2z"/>

View File

@@ -34,6 +34,10 @@ builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddSingleton<AppVersionService>(); builder.Services.AddSingleton<AppVersionService>();
builder.Services.AddScoped<EnvelopeService>(); builder.Services.AddScoped<EnvelopeService>();
// UI Preferences & Theme
builder.Services.AddScoped<UserPreferencesService>();
builder.Services.AddScoped<ThemeService>();
// DevExpress WASM // DevExpress WASM
builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer(); builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer();
builder.Services.AddDevExpressWebAssemblyBlazorReportViewer(); builder.Services.AddDevExpressWebAssemblyBlazorReportViewer();

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

@@ -0,0 +1,232 @@
using Microsoft.JSInterop;
using Microsoft.Extensions.Logging;
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Manages the active DevExpress theme for the WASM client.
///
/// Theme selection is the sole UI control — no separate dark/light toggle.
/// Themes ordered dark → light: blazing-dark | blazing-berry | purple
///
/// Initialization:
/// 1. InitializeAsync() — reads localStorage["signflow.theme"], falls back to
/// system prefers-color-scheme (dark → blazing-dark, light → blazing-berry).
/// 2. LoadFromServerAsync() — called after login, syncs theme from server cache.
///
/// Persistence:
/// - SetThemeAsync() → debounced 500ms PUT /api/UserPreferences.
/// - ScheduleSaveAsync() uses GetCached() — no extra GET before each save.
///
/// Consumers subscribe to OnChange and call StateHasChanged (e.g. MainLayout).
/// </summary>
public class ThemeService : IAsyncDisposable
{
// ── Allowed themes (ordered dark → light for range slider) ───────────────
// DevExpress.Blazor.Themes 25.2.3: blazing-berry, blazing-dark, purple
public static readonly IReadOnlyList<ThemeOption> AvailableThemes =
[
new("Blazing Dark", "blazing-dark"), // index 0 — 🌙
new("Blazing Berry", "blazing-berry"), // index 1 — middle
new("Purple", "purple"), // index 2 — ☀️
];
private const string LocalStorageThemeKey = "signflow.theme";
// ── State ─────────────────────────────────────────────────────────────────
private string _themeName = "blazing-berry";
private bool _initialized = false;
// ── Dependencies ──────────────────────────────────────────────────────────
private readonly IJSRuntime _js;
private readonly UserPreferencesService _preferencesService;
private readonly ILogger<ThemeService> _logger;
// ── Debounce ──────────────────────────────────────────────────────────────
private CancellationTokenSource? _saveCts;
// ── Change & error notification ───────────────────────────────────────────
/// <summary>Fired whenever theme changes. Subscribers call StateHasChanged.</summary>
public event Action? OnChange;
/// <summary>
/// Set when a background save fails. UI layer can surface this as a warning.
/// Cleared on the next successful save or theme change.
/// </summary>
public Exception? SaveError { get; private set; }
public ThemeService(IJSRuntime js, UserPreferencesService preferencesService, ILogger<ThemeService> logger)
{
_js = js;
_preferencesService = preferencesService;
_logger = logger;
}
// ── Public state ──────────────────────────────────────────────────────────
public string ThemeName => _themeName;
/// <summary>True when blazing-dark is active — used for Bootstrap data-bs-theme.</summary>
public bool IsDarkMode => _themeName == "blazing-dark";
public string? DisplayName { get; private set; }
/// <summary>
/// CSS href for the active DevExpress theme.
/// Example: "_content/DevExpress.Blazor.Themes/blazing-dark.bs5.min.css"
/// </summary>
public string ThemeCssHref =>
$"_content/DevExpress.Blazor.Themes/{_themeName}.bs5.min.css";
/// <summary>Bootstrap 5 data-bs-theme value. Only "dark" for blazing-dark.</summary>
public string BootstrapTheme => _themeName == "blazing-dark" ? "dark" : "light";
// ── Initialization ────────────────────────────────────────────────────────
/// <summary>
/// Called once in OnAfterRenderAsync(firstRender: true).
/// Reads theme from localStorage; falls back to system dark-mode preference.
/// Safe to call multiple times — subsequent calls are no-ops.
/// </summary>
public async Task InitializeAsync()
{
if (_initialized) return;
_initialized = true;
var stored = await _js.InvokeAsync<string?>("localStorage.getItem", LocalStorageThemeKey);
if (stored is not null && AvailableThemes.Any(t => t.Value == stored))
{
_themeName = stored;
}
else
{
// No stored theme — use system preference to pick dark or light
var prefersDark = await _js.InvokeAsync<bool>(
"eval",
"(function(){ return window.matchMedia('(prefers-color-scheme: dark)').matches; })()");
_themeName = prefersDark ? "blazing-dark" : "blazing-berry";
}
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
}
/// <summary>
/// Called after successful sender login.
/// Syncs theme from the server cache; on first login propagates local theme to server.
/// </summary>
public async Task LoadFromServerAsync(CancellationToken ct = default)
{
var prefs = await _preferencesService.GetAsync(ct);
var isFirstLogin = prefs.GridLayouts.Count == 0
&& prefs.ThemeName == "blazing-berry"
&& !prefs.IsDarkMode;
if (isFirstLogin && _themeName != "blazing-berry")
{
// Propagate locally-detected theme to server on first login
prefs.ThemeName = _themeName;
prefs.IsDarkMode = IsDarkMode;
prefs = await _preferencesService.SaveAsync(prefs, ct);
}
ApplyPreferences(prefs);
// Persist resolved theme to localStorage
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
DisplayName = prefs.DisplayName;
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
}
// ── Mutation ──────────────────────────────────────────────────────────────
/// <summary>
/// Sets the active theme, applies it immediately, and schedules a server save.
/// State change is applied regardless of whether the save succeeds.
/// </summary>
public async Task SetThemeAsync(string themeName)
{
if (themeName == _themeName) return;
if (!AvailableThemes.Any(t => t.Value == themeName))
throw new ArgumentException($"Theme '{themeName}' is not allowed.", nameof(themeName));
_themeName = themeName;
SaveError = null;
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
await ScheduleSaveAsync();
}
// ── Private helpers ───────────────────────────────────────────────────────
private void ApplyPreferences(UserPreferencesDto prefs)
{
_themeName = AvailableThemes.Any(t => t.Value == prefs.ThemeName)
? prefs.ThemeName
: "blazing-berry";
// Legacy: if IsDarkMode flag was saved but ThemeName wasn't updated
if (prefs.IsDarkMode && _themeName == "blazing-berry")
_themeName = "blazing-dark";
}
private async Task ApplyThemeToDocumentAsync()
{
try
{
await _js.InvokeVoidAsync("signflowTheme.apply", ThemeCssHref, BootstrapTheme, _themeName);
}
catch (Exception ex)
{
// JS unavailable during server prerender — <HeadContent> link is the SSR fallback.
_logger.LogDebug(ex, "[ThemeService] ApplyThemeToDocumentAsync skipped (JS unavailable).");
}
}
private async Task ScheduleSaveAsync()
{
_saveCts?.Cancel();
_saveCts = new CancellationTokenSource();
try
{
await Task.Delay(500, _saveCts.Token);
var prefs = _preferencesService.GetCached() ?? new UserPreferencesDto();
prefs.ThemeName = _themeName;
prefs.IsDarkMode = IsDarkMode;
await _preferencesService.SaveAsync(prefs, _saveCts.Token);
}
catch (TaskCanceledException)
{
// Newer save scheduled — intentionally cancelled.
}
catch (Exception ex)
{
_logger.LogError(ex, "Background save of user preferences failed.");
SaveError = ex;
OnChange?.Invoke();
}
}
public ValueTask DisposeAsync()
{
_saveCts?.Cancel();
_saveCts?.Dispose();
return ValueTask.CompletedTask;
}
}
/// <summary>Describes a selectable DevExpress theme.</summary>
/// <param name="Label">User-facing display name.</param>
/// <param name="Value">CSS theme identifier.</param>
public sealed record ThemeOption(string Label, string Value);

View File

@@ -0,0 +1,136 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
using Microsoft.Extensions.Logging;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Retrieves and persists user UI preferences via the server API.
/// The server stores preferences in the distributed cache (dbo.TBDD_CACHE).
///
/// Client-side memory cache:
/// Preferences are cached in-memory for <see cref="CacheDuration"/> after the first fetch.
/// All reads (GetAsync, GetCachedAsync) are served from cache after the first load.
/// Writes (SaveAsync) update both the cache and the server atomically.
/// Cache is invalidated explicitly via InvalidateCache().
/// </summary>
public class UserPreferencesService(
IHttpClientFactory clientFactory,
ILogger<UserPreferencesService> logger)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private const string Endpoint = "/api/UserPreferences";
// ── Client-side memory cache ──────────────────────────────────────────────
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1);
private UserPreferencesDto? _cached;
private DateTimeOffset _cachedAt = DateTimeOffset.MinValue;
private bool IsCacheValid => _cached is not null
&& DateTimeOffset.UtcNow - _cachedAt < CacheDuration;
/// <summary>
/// Returns preferences from in-memory cache if valid; otherwise fetches from the server.
/// On first call after login this issues one HTTP GET — all subsequent calls within
/// <see cref="CacheDuration"/> are served from memory with zero network overhead.
/// </summary>
public async Task<UserPreferencesDto> GetAsync(CancellationToken ct = default)
{
if (IsCacheValid)
return _cached!;
try
{
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.GetAsync(Endpoint, ct);
// Not authenticated yet (login page) or no preferences saved — return defaults.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound ||
response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
SetCache(new UserPreferencesDto());
return _cached!;
}
response.EnsureSuccessStatusCode();
var dto = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct)
?? new UserPreferencesDto();
SetCache(dto);
return _cached!;
}
catch (Exception ex) when (ex is not TaskCanceledException)
{
logger.LogError(ex, "Failed to load user preferences from server.");
throw;
}
}
/// <summary>
/// Returns the cached preferences without hitting the network.
/// Returns null if the cache has not been populated yet (before first GetAsync call).
/// </summary>
public UserPreferencesDto? GetCached() => IsCacheValid ? _cached : null;
/// <summary>
/// Persists the given preferences on the server and updates the in-memory cache.
/// </summary>
public async Task<UserPreferencesDto> SaveAsync(UserPreferencesDto preferences, CancellationToken ct = default)
{
try
{
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.PutAsJsonAsync(Endpoint, preferences, _jsonOptions, ct);
// Not authenticated (login page) — skip save, keep local state only.
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
return preferences;
response.EnsureSuccessStatusCode();
var saved = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct)
?? preferences;
SetCache(saved);
return saved;
}
catch (Exception ex) when (ex is not TaskCanceledException)
{
logger.LogError(ex, "Failed to save user preferences to server.");
throw;
}
}
/// <summary>
/// Updates a single grid layout key without a network read.
/// Uses the in-memory cache for the current state, merges the new layout, then saves.
/// If the cache is empty (cold start), fetches from server first.
/// </summary>
/// <param name="gridKey">Stable grid identifier, e.g. "sender.active-envelopes".</param>
/// <param name="layoutJson">Opaque layout JSON produced by DxGrid.SaveLayoutAsync().</param>
public async Task SaveGridLayoutAsync(string gridKey, string layoutJson, CancellationToken ct = default)
{
// Use cached copy — avoids a GET before every grid layout save.
var prefs = GetCached() ?? await GetAsync(ct);
prefs.GridLayouts[gridKey] = layoutJson;
await SaveAsync(prefs, ct);
}
/// <summary>
/// Invalidates the in-memory cache, forcing the next GetAsync to fetch from the server.
/// Call this after logout or when a forced refresh is needed.
/// </summary>
public void InvalidateCache()
{
_cached = null;
_cachedAt = DateTimeOffset.MinValue;
}
private void SetCache(UserPreferencesDto dto)
{
_cached = dto;
_cachedAt = DateTimeOffset.UtcNow;
}
}

View File

@@ -17,6 +17,7 @@
<script src="_content/DevExpress.Blazor.Resources/js/preload-script.js"></script> <script src="_content/DevExpress.Blazor.Resources/js/preload-script.js"></script>
<script src="js/typed.umd.js"></script> <script src="js/typed.umd.js"></script>
<script src="js/receiver-signature.js?v=9"></script> <script src="js/receiver-signature.js?v=9"></script>
<script src="js/signflow-theme.js"></script>
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
</body> </body>

View File

@@ -0,0 +1,723 @@
@page "/envelope/{EnvelopeKey}"
@rendermode InteractiveServer
@using DevExpress.Blazor.Reporting
@using DevExpress.XtraReports.UI
@using EnvelopeGenerator.Server.Client.Models
@using EnvelopeGenerator.Server.Client.Models.Constants
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
@using Microsoft.JSInterop
@using DevExpress.Blazor
@using System.Drawing
@using System.Security.Claims
@using Microsoft.Extensions.Caching.Memory
@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 AppVersionService AppVersion
@inject IMemoryCache MemoryCache
@inject ILogger<ReceiverPage> Logger
@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" />
<script src="@AppVersion.GetVersionedUrl("js/receiver-signature.js")"></script>
<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;">
@* Row 1: Title + Sender + Badges *@
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem;">
@* Left: Title + Sender *@
<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 ?? "Dokument")
</div>
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName) || !string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.Email))
{
<span style="font-size: 0.7rem; color: #6b7280; white-space: nowrap;">
Von
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName))
{
<span style="font-weight: 500; color: #374151;">@_envelopeReceiver.Envelope.User.FullName</span>
}
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.Email))
{
<span>&lt;@_envelopeReceiver.Envelope.User.Email&gt;</span>
}
@if (_envelopeReceiver.Envelope?.AddedWhen != null)
{
<span>&nbsp;·&nbsp;@_envelopeReceiver.Envelope.AddedWhen.ToString("dd.MM.yyyy")</span>
}
</span>
}
}
else
{
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937;">Dokumentenansicht</div>
}
</div>
@* Right: Badges + Signature status *@
<div class="d-flex align-items-center" style="gap: 0.75rem; flex: 0 0 auto;">
@if (_envelopeReceiver is not null)
{
<div class="d-flex flex-wrap align-items-center" style="gap: 0.3rem; font-size: 0.7rem;">
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Name))
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem; background: #f3f4f6; border-radius: 0.25rem; color: #374151; white-space: nowrap;">
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4Z" />
</svg>
@_envelopeReceiver.Name
</span>
}
@if (_signatures.Count > 0)
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem; background: @(_capturedSignature is not null ? "#d1fae5" : "#ede9fe"); border-radius: 0.25rem; color: @(_capturedSignature is not null ? "#065f46" : "#6d28d9"); font-weight: 500; white-space: nowrap;">
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="currentColor" class="me-1" 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-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
</svg>
@_signatures.Count Unterschrift@(_signatures.Count != 1 ? "en" : "")
@if (_capturedSignature is not null)
{
<span class="ms-1">✓</span>
}
</span>
}
@if (_envelopeReceiver.Envelope?.UseAccessCode ?? false)
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem; background: #fef3c7; border-radius: 0.25rem; color: #92400e; font-weight: 500; white-space: nowrap;">
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
</svg>
Code
</span>
}
@if (_envelopeReceiver.Envelope?.TFAEnabled ?? false)
{
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem; background: #dbeafe; border-radius: 0.25rem; color: #1e40af; font-weight: 500; white-space: nowrap;">
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z" />
<path d="M10.854 5.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647a.5.5 0 0 1 .708 0z" />
</svg>
2FA
</span>
}
</div>
}
@* Unterschreiben button — visible only when signature fields exist *@
@if (_signatures.Count > 0)
{
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change pdf-toolbar__btn--signature-change-active"
@onclick="OpenSignaturePopup"
title="Unterschreiben"
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="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-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
</svg>
<span class="pdf-toolbar__btn-text">Unterschreiben</span>
</button>
}
</div>
</div>
@* Row 2: Messages *@
@if (_envelopeReceiver is not null && (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.Message) || !string.IsNullOrWhiteSpace(_envelopeReceiver.PrivateMessage)))
{
<div style="display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.7rem; padding-top: 0.15rem; border-top: 1px solid #e5e7eb;">
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.Message))
{
<div style="flex: 1; min-width: 0; padding: 0.2rem 0.4rem; background: #f9fafb; border-radius: 0.25rem; border-left: 2px solid #9ca3af; display: flex; align-items: flex-start; gap: 0.25rem;">
<span style="font-weight: 500; color: #374151; flex-shrink: 0;">📧</span>
<span style="color: #6b7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@_envelopeReceiver.Envelope.Message</span>
</div>
}
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.PrivateMessage))
{
<div style="flex: 1; min-width: 0; padding: 0.2rem 0.4rem; background: #fef3c7; border-radius: 0.25rem; border-left: 2px solid #f59e0b; display: flex; align-items: flex-start; gap: 0.25rem;">
<span style="font-weight: 500; color: #92400e; flex-shrink: 0;">🔒</span>
<span style="color: #92400e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@_envelopeReceiver.PrivateMessage</span>
</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ädt...</span>
</div>
<p class="text-white fw-semibold">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">Fehler beim Laden des Dokuments</h5>
<p class="mb-0">@_errorMessage</p>
</div>
</div>
</div>
</div>
}
else if (_report is not null)
{
<DxReportViewer @ref="_reportViewer"
Report="_report"
RootCssClasses="w-100 h-100" />
}
</div>
</div>
@* Signature Popup *@
<DxPopup @bind-Visible="_signaturePopupVisible"
HeaderText="Unterschrift erstellen"
Width="620px"
MaxWidth="95vw"
ShowFooter="true"
CloseOnOutsideClick="false"
ShowCloseButton="false"
CloseOnEscape="false"
Shown="OnPopupShownAsync">
<BodyContentTemplate>
<ul class="nav nav-tabs mb-3" style="border-bottom: 2px solid #e9ecef;">
<li class="nav-item">
<button type="button"
class="nav-link @(_activeSignatureTab == SignatureTabDraw ? "active" : "")"
style="@(_activeSignatureTab == SignatureTabDraw ? "border-bottom: 3px solid #4F46E5; color: #4F46E5; font-weight: 600;" : "color: #6c757d;")"
@onclick="() => SetSignatureTabAsync(SignatureTabDraw)">
Zeichnen
</button>
</li>
<li class="nav-item">
<button type="button"
class="nav-link @(_activeSignatureTab == SignatureTabText ? "active" : "")"
style="@(_activeSignatureTab == SignatureTabText ? "border-bottom: 3px solid #4F46E5; color: #4F46E5; font-weight: 600;" : "color: #6c757d;")"
@onclick="() => SetSignatureTabAsync(SignatureTabText)">
Text
</button>
</li>
<li class="nav-item">
<button type="button"
class="nav-link @(_activeSignatureTab == SignatureTabImage ? "active" : "")"
style="@(_activeSignatureTab == SignatureTabImage ? "border-bottom: 3px solid #4F46E5; color: #4F46E5; font-weight: 600;" : "color: #6c757d;")"
@onclick="() => SetSignatureTabAsync(SignatureTabImage)">
Bild
</button>
</li>
</ul>
@if (_activeSignatureTab == SignatureTabDraw)
{
<p style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.75rem;">Bitte unterschreiben Sie im folgenden Feld.</p>
<canvas id="rp-signature-pad"
width="560"
height="180"
style="border: 2px solid #e9ecef; border-radius: 8px; background: white; width: 100%; max-width: 560px; touch-action: none; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"></canvas>
}
else if (_activeSignatureTab == SignatureTabText)
{
<p style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.75rem;">Geben Sie Ihre Unterschrift als Text ein und wählen Sie eine Schriftart.</p>
<div class="row g-3 mb-3">
<div class="col-12 col-md-7">
<input class="form-control"
placeholder="Ihre Unterschrift"
value="@_typedSignatureText"
@oninput="OnTypedSignatureChanged"
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
</div>
<div class="col-12 col-md-5">
<select class="form-select"
value="@_typedSignatureFont"
@onchange="OnTypedSignatureFontChanged"
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;">
@foreach (var font in TypedSignatureFonts)
{
<option value="@font.Value" style="font-family: @font.Value">@font.Text</option>
}
</select>
</div>
</div>
<canvas id="rp-typed-signature-pad"
width="560"
height="180"
style="border: 2px solid #e9ecef; border-radius: 8px; background: white; width: 100%; max-width: 560px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"></canvas>
}
else
{
<p style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.75rem;">Laden Sie ein Bild Ihrer Unterschrift hoch.</p>
<input id="rp-signature-image-input"
class="form-control mb-3"
type="file"
accept="image/png,image/jpeg,image/webp"
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
<canvas id="rp-image-signature-pad"
width="560"
height="180"
style="border: 2px solid #e9ecef; border-radius: 8px; background: white; width: 100%; max-width: 560px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"></canvas>
}
<div style="border-top: 2px solid #e9ecef; margin-top: 1.5rem; padding-top: 1.5rem;">
<div class="row g-3">
<div class="col-12 col-md-6">
<label class="form-label" for="rp-signer-name" style="font-size: 0.875rem; font-weight: 500; color: #495057;">
Vor- und Nachname <span style="color: #dc3545;">*</span>
</label>
<input id="rp-signer-name"
class="form-control"
value="@_signerFullName"
@oninput="args => _signerFullName = args.Value?.ToString() ?? string.Empty"
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="rp-signer-position" style="font-size: 0.875rem; font-weight: 500; color: #495057;">
Position <span style="color: #6c757d; font-weight: 400;">(optional)</span>
</label>
<input id="rp-signer-position"
class="form-control"
value="@_signerPosition"
@oninput="args => _signerPosition = args.Value?.ToString() ?? string.Empty"
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="rp-signature-place" style="font-size: 0.875rem; font-weight: 500; color: #495057;">
Ort <span style="color: #dc3545;">*</span>
</label>
<input id="rp-signature-place"
class="form-control"
value="@_signaturePlace"
@oninput="args => _signaturePlace = args.Value?.ToString() ?? string.Empty"
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
</div>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(_popupValidationMessage))
{
<div style="background: #fee; border-left: 4px solid #dc3545; padding: 0.75rem 1rem; margin-top: 1rem; border-radius: 4px;">
<span style="color: #dc3545; font-size: 0.875rem; font-weight: 500;">@_popupValidationMessage</span>
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<div class="d-flex gap-2 justify-content-between w-100" style="padding: 0.5rem 0;">
<button class="btn btn-outline-secondary"
@onclick="RenewSignatureAsync"
style="border-radius: 6px; padding: 0.625rem 1.25rem; font-weight: 500;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z" />
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z" />
</svg>
Erneuern
</button>
<button class="btn btn-primary"
@onclick="SaveSignatureAsync"
style="background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%); border: none; border-radius: 6px; padding: 0.625rem 2rem; font-weight: 600; box-shadow: 0 2px 4px rgba(79, 70, 229, 0.3);">
<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" />
</svg>
Speichern
</button>
</div>
</FooterContentTemplate>
</DxPopup>
@code {
// ----- Constants -----
const string SignatureTabDraw = "draw";
const string SignatureTabText = "text";
const string SignatureTabImage = "image";
const string DrawCanvasId = "rp-signature-pad";
const string TypedCanvasId = "rp-typed-signature-pad";
const string ImageInputId = "rp-signature-image-input";
const string ImageCanvasId = "rp-image-signature-pad";
readonly (string Text, string Value)[] TypedSignatureFonts =
[
("Brush Script", "'Brush Script MT', cursive"),
("Segoe Script", "'Segoe Script', cursive"),
("Lucida Handwriting", "'Lucida Handwriting', cursive"),
("Comic Sans", "'Comic Sans MS', cursive"),
("Cursive", "cursive"),
];
// ----- Parameters -----
[Parameter] public string? EnvelopeKey { get; set; }
// ----- Page state -----
bool _isLoading = true;
string? _errorMessage;
byte[]? _pdfBytes;
IReadOnlyList<SignatureDto> _signatures = [];
EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto? _envelopeReceiver;
ClaimsPrincipal? _receiverUser;
// ----- Report viewer -----
DxReportViewer? _reportViewer;
XtraReport? _report;
// ----- Signature popup state -----
SignatureCaptureDto? _capturedSignature;
bool _signaturePopupVisible = false;
string? _popupValidationMessage;
string _activeSignatureTab = SignatureTabDraw;
string _typedSignatureText = string.Empty;
string _typedSignatureFont = "'Brush Script MT', cursive";
string _signerFullName = string.Empty;
string _signerPosition = string.Empty;
string _signaturePlace = string.Empty;
// ----- Lifecycle -----
protected override async Task OnInitializedAsync()
{
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
_errorMessage = "Envelope-Schlüssel fehlt.";
_isLoading = false;
return;
}
// Authorization — same pattern as EnvelopeReceiverPage
_receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
if (_receiverUser is null)
{
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
return;
}
try
{
// Load PDF bytes via MediatR (uses authenticated user's claims)
_pdfBytes = await PageDataService.GetDocumentAsync(_receiverUser);
if (_pdfBytes is not { Length: > 0 })
{
_errorMessage = "Dokument konnte nicht geladen werden: Keine Daten empfangen.";
_isLoading = false;
return;
}
// Load signature fields for this receiver
_signatures = await PageDataService.GetSignaturesAsync(_receiverUser);
// Load envelope receiver metadata
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
if (_envelopeReceiver is null)
Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey);
// Build initial report (no signature image yet)
_report = BuildReport(_pdfBytes, _signatures, capturedSignature: null);
// Try to restore cached signature
try
{
var cachedSignature = await PageDataService.GetCachedSignatureAsync(_receiverUser);
if (cachedSignature is not null)
{
_capturedSignature = cachedSignature;
_signerFullName = cachedSignature.FullName;
_signerPosition = cachedSignature.Position;
_signaturePlace = cachedSignature.Place;
_signaturePopupVisible = false;
// Rebuild with cached signature overlaid
_report = BuildReport(_pdfBytes, _signatures, _capturedSignature);
}
else
{
_activeSignatureTab = SignatureTabDraw;
_signaturePopupVisible = false;
_popupValidationMessage = null;
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to load cached signature for {EnvelopeKey}", EnvelopeKey);
_activeSignatureTab = SignatureTabDraw;
_signaturePopupVisible = false;
_popupValidationMessage = null;
}
}
catch (Exception ex)
{
_errorMessage = $"Fehler beim Laden des Dokuments: {ex.Message}";
Logger.LogError(ex, "Unexpected error for {EnvelopeKey}", EnvelopeKey);
}
_isLoading = false;
await InvokeAsync(StateHasChanged);
}
// ----- Report builder -----
/// <summary>
/// Builds an XtraReport wrapping the PDF bytes.
/// If a signature is captured and there are signature fields, the signature image is
/// first burned into the PDF via DevExpress PdfDocumentProcessor, then the modified
/// PDF is handed to XRPdfContent with GenerateOwnPages = true so that all pages appear.
/// </summary>
static XtraReport BuildReport(
byte[] pdfBytes,
IReadOnlyList<SignatureDto> signatures,
SignatureCaptureDto? capturedSignature)
{
// Always draw placeholder boxes on signature fields so the user knows where to sign.
// When a captured signature exists, it will be applied in the Signed page instead.
byte[] sourcePdf = pdfBytes;
if (signatures.Count > 0)
{
sourcePdf = DrawSignaturePlaceholders(pdfBytes, signatures);
}
var report = new XtraReport
{
PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4,
Landscape = false,
Margins = new System.Drawing.Printing.Margins(0, 0, 0, 0),
};
var detail = new DetailBand { HeightF = 0f };
report.Bands.Add(detail);
detail.Controls.Add(new XRPdfContent
{
Source = sourcePdf,
GenerateOwnPages = true,
});
return report;
}
/// <summary>
/// Uses PdfSharp to draw a visible signature placeholder box on every signature field.
/// sig.X / sig.Y come from GetSignaturesAsync(UnitOfLength.Point) → already in PDF points.
/// PdfSharp coordinate origin: bottom-left, Y up. Conversion: pdfY = pageH - sigY - sigH
/// Signature field size (fixed): 1.77" × 1.96" = 127.44pt × 141.12pt
/// </summary>
static byte[] DrawSignaturePlaceholders(
byte[] pdfBytes,
IReadOnlyList<SignatureDto> signatures)
{
if (signatures.Count == 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
foreach (var sig in signatures)
{
int pageIndex = sig.Page - 1;
if (pageIndex < 0 || pageIndex >= document.PageCount) continue;
var page = document.Pages[pageIndex];
// PdfSharp XGraphics uses top-left origin, Y down — same as sig.X/sig.Y
// No coordinate conversion needed.
using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page);
var rect = new PdfSharp.Drawing.XRect(sig.X, sig.Y, sigW, sigH);
// Filled semi-transparent rectangle
var fillBrush = new PdfSharp.Drawing.XSolidBrush(
PdfSharp.Drawing.XColor.FromArgb(40, 60, 80, 160));
var borderPen = new PdfSharp.Drawing.XPen(
PdfSharp.Drawing.XColor.FromArgb(200, 60, 80, 200), 1.5);
gfx.DrawRectangle(fillBrush, rect);
gfx.DrawRectangle(borderPen, rect);
// "UNTERSCHRIFT" label centred in the box
var font = new PdfSharp.Drawing.XFont("Arial", 9,
PdfSharp.Drawing.XFontStyleEx.Bold);
var textBrush = new PdfSharp.Drawing.XSolidBrush(
PdfSharp.Drawing.XColor.FromArgb(200, 40, 60, 140));
var textFmt = new PdfSharp.Drawing.XStringFormat
{
Alignment = PdfSharp.Drawing.XStringAlignment.Center,
LineAlignment = PdfSharp.Drawing.XLineAlignment.Center,
};
gfx.DrawString("UNTERSCHRIFT", font, textBrush, rect, textFmt);
}
document.Save(outputMs);
return outputMs.ToArray();
}
/// <summary>Converts a base64 data URL (data:image/...;base64,...) to raw bytes.</summary>
static byte[]? DataUrlToBytes(string dataUrl)
{
try
{
var commaIndex = dataUrl.IndexOf(',');
if (commaIndex < 0) return null;
return Convert.FromBase64String(dataUrl[(commaIndex + 1)..]);
}
catch
{
return null;
}
}
// ----- Signature popup handlers -----
void OpenSignaturePopup()
{
_activeSignatureTab = SignatureTabDraw;
_signaturePopupVisible = true;
_popupValidationMessage = null;
}
async Task OnPopupShownAsync()
{
await InitializeActiveSignatureTabAsync();
}
async Task SetSignatureTabAsync(string tab)
{
_activeSignatureTab = tab;
_popupValidationMessage = null;
await InvokeAsync(StateHasChanged);
await Task.Delay(50);
await InitializeActiveSignatureTabAsync();
}
async Task InitializeActiveSignatureTabAsync()
{
if (_activeSignatureTab == SignatureTabDraw)
await JSRuntime.InvokeVoidAsync("receiverSignature.initialize", DrawCanvasId);
else if (_activeSignatureTab == SignatureTabText)
{
await JSRuntime.InvokeVoidAsync("receiverSignature.initializeTyped", TypedCanvasId);
await RenderTypedSignatureAsync();
}
else
await JSRuntime.InvokeVoidAsync("receiverSignature.initializeImage", ImageInputId, ImageCanvasId);
}
async Task RenewSignatureAsync()
{
_popupValidationMessage = null;
if (_activeSignatureTab == SignatureTabDraw)
await JSRuntime.InvokeVoidAsync("receiverSignature.clear", DrawCanvasId);
else if (_activeSignatureTab == SignatureTabText)
{
_typedSignatureText = string.Empty;
await JSRuntime.InvokeVoidAsync("receiverSignature.clearTyped", TypedCanvasId);
}
else
await JSRuntime.InvokeVoidAsync("receiverSignature.clearImage", ImageInputId, ImageCanvasId);
}
async Task OnTypedSignatureChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args)
{
_typedSignatureText = args.Value?.ToString() ?? string.Empty;
await RenderTypedSignatureAsync();
}
async Task OnTypedSignatureFontChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args)
{
_typedSignatureFont = args.Value?.ToString() ?? _typedSignatureFont;
await RenderTypedSignatureAsync();
}
async Task RenderTypedSignatureAsync()
{
await JSRuntime.InvokeVoidAsync("receiverSignature.renderTypedSignature",
TypedCanvasId, _typedSignatureText, _typedSignatureFont);
}
async Task SaveSignatureAsync()
{
if (string.IsNullOrWhiteSpace(_signerFullName))
{
_popupValidationMessage = "Bitte geben Sie Vor- und Nachname ein.";
return;
}
if (string.IsNullOrWhiteSpace(_signaturePlace))
{
_popupValidationMessage = "Bitte geben Sie den Ort ein.";
return;
}
var signatureDataUrl = await GetActiveSignatureDataUrlAsync();
if (string.IsNullOrWhiteSpace(signatureDataUrl))
{
_popupValidationMessage = "Die Unterschrift ist erforderlich.";
return;
}
_popupValidationMessage = null;
_capturedSignature = new SignatureCaptureDto
{
DataUrl = signatureDataUrl,
FullName = _signerFullName.Trim(),
Position = _signerPosition.Trim(),
Place = _signaturePlace.Trim(),
};
_signaturePopupVisible = false;
// Store signature in IMemoryCache with a Guid key (1 minute TTL)
var sid = Guid.NewGuid().ToString("N");
MemoryCache.Set(
sid,
_capturedSignature,
TimeSpan.FromMinutes(1));
Logger.LogInformation(
"Signature cached with sid={Sid} for envelope {EnvelopeKey}", sid, EnvelopeKey);
// Null the report → DxReportViewer removed from DOM → no crash on dispose
_report = null;
await InvokeAsync(StateHasChanged);
await Task.Delay(50);
// Navigate — forceLoad:true for clean circuit teardown
Navigation.NavigateTo(
$"/envelope/{Uri.EscapeDataString(EnvelopeKey!)}/signed?sid={sid}",
forceLoad: true);
}
async Task<string?> GetActiveSignatureDataUrlAsync()
{
if (_activeSignatureTab == SignatureTabDraw)
return await JSRuntime.InvokeAsync<string?>("receiverSignature.getDataUrl", DrawCanvasId);
if (_activeSignatureTab == SignatureTabText)
{
await RenderTypedSignatureAsync();
return await JSRuntime.InvokeAsync<string?>("receiverSignature.getTypedDataUrl", TypedCanvasId);
}
return await JSRuntime.InvokeAsync<string?>("receiverSignature.getImageDataUrl", ImageCanvasId);
}
// ----- Disposal -----
public void Dispose()
{
_report?.Dispose();
}
}

View File

@@ -0,0 +1,396 @@
@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 Microsoft.Extensions.Caching.Memory
@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 AppVersionService AppVersion
@inject IMemoryCache MemoryCache
@inject ILogger<ReceiverSignedPage> Logger
@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 ?? "Dokument")
</div>
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName))
{
<span style="font-size: 0.7rem; color: #6b7280; white-space: nowrap;">
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;">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="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">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ädt...</span>
</div>
<p class="text-white fw-semibold">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">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="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;">
Möchten Sie das Dokument verbindlich unterschreiben?
</p>
<p style="margin: 0; color: #6b7280; font-size: 0.85rem; line-height: 1.5;">
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>
</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;">
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>
}
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;
void OpenSubmitConfirmPopup() => _submitConfirmVisible = true;
async Task SubmitAndLogoutAsync()
{
if (_isLoggingOut) return;
_isLoggingOut = true;
_submitConfirmVisible = false;
await InvokeAsync(StateHasChanged);
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey!);
Navigation.NavigateTo("/", forceLoad: true);
}
protected override async Task OnInitializedAsync()
{
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
_errorMessage = "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.GetDocumentAsync(_receiverUser);
if (pdfBytes is not { Length: > 0 })
{
_errorMessage = "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 = $"Fehler: {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)..]);
}
}

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

@@ -3,7 +3,7 @@ using EnvelopeGenerator.Application.Receivers.Queries;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.GeneratorAPI.Controllers; namespace EnvelopeGenerator.Server.Controllers;
/// <summary> /// <summary>
/// Controller für die Verwaltung von Empfängern. /// Controller für die Verwaltung von Empfängern.
@@ -33,15 +33,16 @@ public class ReceiverController : ControllerBase
/// <param name="receiver">Die Abfrageparameter, einschließlich E-Mail-Adresse und Signatur.</param> /// <param name="receiver">Die Abfrageparameter, einschließlich E-Mail-Adresse und Signatur.</param>
/// <returns>Eine Liste von Empfängern oder ein Fehlerstatus.</returns> /// <returns>Eine Liste von Empfängern oder ein Fehlerstatus.</returns>
[HttpGet] [HttpGet]
public async Task<IActionResult> Get([FromQuery] ReadReceiverQuery receiver) [Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public async Task<IActionResult> Get([FromQuery] ReadReceiverQuery? receiver = null, [FromQuery] bool onlyEmailAddress = false)
{ {
if (!receiver.HasAnyCriteria) var result = await _mediator.Send(receiver ?? new ReadReceiverQuery());
{
var all = await _mediator.Send(new ReadReceiverQuery());
return Ok(all);
}
var result = await _mediator.Send(receiver); if (result is null)
return result is null ? NotFound() : Ok(result); return NotFound();
else if (onlyEmailAddress)
return Ok(result.Select(r => r.EmailAddress).ToList());
else
return Ok(result);
} }
} }

View File

@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Server.Extensions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
/// Manages per-user UI preferences (theme, dark mode, grid layouts).
/// Preferences are stored in the distributed cache (dbo.TBDD_CACHE).
/// Accessible by both Sender and Receiver roles.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
public class UserPreferencesController(IUserPreferencesService preferencesService) : ControllerBase
{
private static readonly HashSet<string> AllowedThemes =
["blazing-berry", "blazing-dark", "purple"];
/// <summary>
/// Returns the current user's UI preferences.
/// Always returns 200 with defaults if no preferences have been saved yet.
/// DisplayName is populated from JWT claims for senders only.
/// </summary>
[HttpGet]
public async Task<ActionResult<UserPreferencesDto>> Get(CancellationToken ct)
{
bool isReceiver = User.IsReceiver();
var userId = isReceiver ? User.ReceiverMail() : User.GetUsername();
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var prefs = await preferencesService.GetOrCreateAsync(userId, ct);
// Populate DisplayName for sender only (receiver has no given/surname claims)
if (!isReceiver)
prefs.DisplayName = $"{User.GetPrename()} {User.GetName()}".Trim();
return Ok(prefs);
}
/// <summary>
/// Saves (full replace) the current user's UI preferences.
/// </summary>
[HttpPut]
public async Task<ActionResult<UserPreferencesDto>> Put(
[FromBody] UserPreferencesDto dto,
CancellationToken ct)
{
if (!AllowedThemes.Contains(dto.ThemeName))
return BadRequest($"Invalid theme '{dto.ThemeName}'. Allowed: {string.Join(", ", AllowedThemes)}.");
var userId = User.IsReceiver() ? User.ReceiverMail() : User.GetUsername();
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
await preferencesService.SaveAsync(userId, dto, ct);
return Ok(dto);
}
}

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>
@@ -28,13 +39,12 @@
<PackageReference Include="DigitalData.Core.API" Version="2.2.1" /> <PackageReference Include="DigitalData.Core.API" Version="2.2.1" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" /> <PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.11" /> <PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.11" />
<PackageReference Include="itext" Version="8.0.5" />
<PackageReference Include="itext.bouncy-castle-adapter" Version="8.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.17" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.17" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" /> <PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
<PackageReference Include="NLog" Version="5.2.5" /> <PackageReference Include="NLog" Version="5.2.5" />
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.0" /> <PackageReference Include="NLog.Web.AspNetCore" Version="5.3.0" />
<PackageReference Include="PDFsharp" Version="6.2.4" />
<PackageReference Include="Scalar.AspNetCore" Version="2.2.1" /> <PackageReference Include="Scalar.AspNetCore" Version="2.2.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />

View File

@@ -1,4 +1,6 @@
using DigitalData.Auth.Claims; using DigitalData.Auth.Claims;
using DigitalData.UserManager.Domain.Entities;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.JsonWebTokens;
using System.Security.Claims; using System.Security.Claims;
@@ -93,4 +95,14 @@ public static class ReceiverClaimExtensions
else else
throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.ReceiverId}' is not a valid integer."); throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.ReceiverId}' is not a valid integer.");
} }
/// <summary>
///
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
public static bool IsReceiver(this ClaimsPrincipal user)
{
return Role.Receiver.Authorized.Any(user.IsInRole);
}
} }

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[]>() ??
@@ -104,7 +108,7 @@ try
{ {
Version = "v1", Version = "v1",
Title = "signFLOW Absender-API", Title = "signFLOW Absender-API",
Description = "Eine API zur Verwaltung der Erstellung, des Versands und der Nachverfolgung von Umschlägen in der signFLOW-Anwendung.", Description = "Eine API zur Verwaltung der Erstellung, des Versands und der Nachverfolgung von Umschl<EFBFBD>gen in der signFLOW-Anwendung.",
Contact = new OpenApiContact Contact = new OpenApiContact
{ {
Name = "Digital Data GmbH", Name = "Digital Data GmbH",
@@ -267,7 +271,12 @@ try
.AddAuthenticationSchemes(AuthScheme.Receiver) .AddAuthenticationSchemes(AuthScheme.Receiver)
.RequireAuthenticatedUser() .RequireAuthenticatedUser()
.RequireRole(Role.Receiver.Full, "receiver")) .RequireRole(Role.Receiver.Full, "receiver"))
.AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA)); .AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA))
// Combined policy: accepts both Sender and Receiver tokens.
// Used by endpoints accessible to both roles (e.g. UserPreferences).
.AddPolicy(AuthPolicy.SenderOrReceiver, policy => policy
.AddAuthenticationSchemes(AuthScheme.Sender, AuthScheme.Receiver)
.RequireAuthenticatedUser());
// User Manager // User Manager
#pragma warning disable CS0618 #pragma warning disable CS0618
@@ -314,6 +323,16 @@ try
.AddEnvelopeGeneratorServices(config); .AddEnvelopeGeneratorServices(config);
#pragma warning restore CS0618 #pragma warning restore CS0618
// User Preferences (theme, dark mode, grid layouts — stored in TBDD_CACHE)
// Registration handled by Infrastructure DependencyInjection (AddEnvelopeGeneratorInfrastructureServices)
// Client-side services that MainLayout depends on must also be registered here
// so that Blazor Auto prerender (SSR pass) can resolve them on the server.
// ThemeService.InitializeAsync() uses IJSRuntime but is only called in OnAfterRenderAsync
// (never during prerender), so registering it server-side is safe.
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
// Business Services (Server specific) // Business Services (Server specific)
builder.Services.AddScoped<DocumentService>(); builder.Services.AddScoped<DocumentService>();
builder.Services.AddScoped<AuthService>(); builder.Services.AddScoped<AuthService>();
@@ -338,6 +357,10 @@ try
builder.Services.AddDevExpressBlazor(); builder.Services.AddDevExpressBlazor();
builder.Services.AddDevExpressServerSideBlazorPdfViewer(); builder.Services.AddDevExpressServerSideBlazorPdfViewer();
// PdfSharp font resolver — required for .NET 8 (no system font access without it)
PdfSharp.Fonts.GlobalFontSettings.FontResolver =
EnvelopeGenerator.Server.Services.PdfSharpFontResolver.Instance;
// Configuration Options // Configuration Options
builder.Services.Configure<EnvelopeGenerator.Server.Client.Options.ApiOptions>( builder.Services.Configure<EnvelopeGenerator.Server.Client.Options.ApiOptions>(
builder.Configuration.GetSection("ApiOptions")); builder.Configuration.GetSection("ApiOptions"));

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\PreRelease\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 |
### Überprüfung 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

@@ -4,12 +4,14 @@ using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver; using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
using EnvelopeGenerator.Application.Documents.Queries; using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries; using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Receivers.Queries;
using EnvelopeGenerator.Server.Client.Models; using EnvelopeGenerator.Server.Client.Models;
using EnvelopeGenerator.Server.Client.Models.Constants; using EnvelopeGenerator.Server.Client.Models.Constants;
using EnvelopeGenerator.Server.Extensions; using EnvelopeGenerator.Server.Extensions;
using EnvelopeGenerator.Server.Options; using EnvelopeGenerator.Server.Options;
using MediatR; using MediatR;
using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ApplicationEnvelopeReceiverDto = EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto; using ApplicationEnvelopeReceiverDto = EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto;
@@ -21,7 +23,8 @@ namespace EnvelopeGenerator.Server.Services;
public class EnvelopeReceiverPageDataService( public class EnvelopeReceiverPageDataService(
IMediator mediator, IMediator mediator,
IDistributedCache cache, IDistributedCache cache,
IOptions<CacheOptions> cacheOptions) IOptions<CacheOptions> cacheOptions,
IMemoryCache memoryCache)
{ {
private const string SignatureCacheKeyPrefix = "envelope-generator.receiver-ui.signature:"; private const string SignatureCacheKeyPrefix = "envelope-generator.receiver-ui.signature:";
@@ -101,4 +104,23 @@ public class EnvelopeReceiverPageDataService(
Page = element.Page, Page = element.Page,
SenderAppType = (EnvelopeGenerator.Server.Client.Models.Constants.SenderAppType)element.SenderAppType SenderAppType = (EnvelopeGenerator.Server.Client.Models.Constants.SenderAppType)element.SenderAppType
}; };
private static readonly string ReceiverEmailSearchCacheKey = Guid.NewGuid().ToString();
public async Task<IEnumerable<string>> SearchReceiverEMailsAsync(string emailSearchTerm, CancellationToken cancellationToken = default)
{
return await memoryCache.GetOrCreateAsync(ReceiverEmailSearchCacheKey + emailSearchTerm, async entry =>
{
var query = new ReadReceiverQuery { EmailAddressSearch = emailSearchTerm };
var receivers = await mediator.Send(query, cancellationToken);
if(receivers.Any())
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
else
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);
return receivers.Select(r => r.EmailAddress);
}) ?? [];
}
} }

View File

@@ -0,0 +1,46 @@
using PdfSharp.Fonts;
namespace EnvelopeGenerator.Server.Services;
/// <summary>
/// PdfSharp 6.x IFontResolver for .NET 8.
/// PdfSharp cannot access system fonts on .NET Core/8 without an explicit resolver.
/// This implementation reads fonts directly from the Windows Fonts folder.
/// Register once at startup: GlobalFontSettings.FontResolver = PdfSharpFontResolver.Instance;
/// </summary>
public class PdfSharpFontResolver : IFontResolver
{
public static readonly PdfSharpFontResolver Instance = new();
private static readonly string FontsFolder =
Environment.GetFolderPath(Environment.SpecialFolder.Fonts);
public FontResolverInfo? ResolveTypeface(string familyName, bool isBold, bool isItalic)
{
var key = familyName.ToLowerInvariant() switch
{
"arial" => isBold ? "arialbd" : "arial",
_ => null
};
return key is null ? null : new FontResolverInfo(key);
}
public byte[] GetFont(string faceName)
{
var fileName = faceName switch
{
"arialbd" => "arialbd.ttf",
_ => "arial.ttf",
};
var path = Path.Combine(FontsFolder, fileName);
if (!File.Exists(path))
throw new FileNotFoundException(
$"Font file not found: {path}. " +
"Ensure Arial is installed on the server.");
return File.ReadAllBytes(path);
}
}

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);
@@ -342,7 +342,7 @@ article {
height: 36px; height: 36px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: space-between; /* ← left group | right group */
gap: 10px; gap: 10px;
padding: 0 20px; padding: 0 20px;
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%); background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
@@ -363,6 +363,224 @@ article {
text-decoration: underline; text-decoration: underline;
} }
.receiver-footer__left {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.receiver-footer__sep { .receiver-footer__sep {
opacity: 0.4; opacity: 0.4;
}
/* ── Footer: Theme Slider ────────────────────────────────────────────────── */
/* 🌙 blazing-dark ──◇──·──◇── purple ☀️ */
/* Left group: copyright | datenschutz → Right group: slider */
.sf-theme-slider {
display: flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
}
/* Moon / Sun SVG icons */
.sf-theme-icon {
display: block;
opacity: 0.55; /* ↑ was 0.42 — more visible as base */
flex-shrink: 0;
transition: opacity 0.18s;
align-self: center;
}
.sf-theme-icon--active {
opacity: 0.92;
}
.sf-theme-icon--mid {
opacity: 0.62;
}
/* ── Wrapper holds the range input + tick marks ───────────────────────────── */
.sf-theme-track-wrapper {
position: relative;
display: flex;
align-items: center;
width: 80px;
height: 16px; /* enough height so ticks + thumb don't clip */
}
/* Actual range input — fills the wrapper */
.sf-theme-range {
-webkit-appearance: none;
appearance: none;
position: relative;
z-index: 1;
width: 100%;
height: 2px;
background: rgba(255, 255, 255, 0.28);
border-radius: 1px;
outline: none;
cursor: pointer;
margin: 0;
/* no extra padding so ticks align correctly */
}
.sf-theme-range:hover {
background: rgba(255, 255, 255, 0.42);
}
/* Thumb — white circle */
.sf-theme-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 11px;
height: 11px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.90);
cursor: pointer;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
transition: transform 0.12s, box-shadow 0.12s;
}
.sf-theme-range::-webkit-slider-thumb:hover {
transform: scale(1.25);
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.38);
}
/* Firefox */
.sf-theme-range::-moz-range-thumb {
width: 11px;
height: 11px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.90);
cursor: pointer;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
}
.sf-theme-range::-moz-range-track {
height: 2px;
background: rgba(255, 255, 255, 0.28);
border-radius: 1px;
}
/* ── End ticks (left & right): thin vertical bars ────────────────────────── */
.sf-theme-tick {
position: absolute;
z-index: 0;
width: 2px;
height: 7px;
border-radius: 1px;
background: rgba(255, 255, 255, 0.45);
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
.sf-theme-tick--left { left: 0; }
.sf-theme-tick--right { right: 0; }
/* Mid tick — thinner, subtler */
.sf-theme-tick--mid {
left: 50%;
transform: translate(-50%, -50%);
width: 1px;
height: 5px;
background: rgba(255, 255, 255, 0.50); /* ↑ was 0.22 */
}
/* Sun icon: always visible regardless of active state */
.sf-theme-icon--sun {
opacity: 0.72; /* minimum visibility */
}
/* ── Theme overrides: Home page & Login ──────────────────────────────────── */
/* selector: [data-sf-theme="<name>"] on <html>, set by signflow-theme.js */
/* ── Purple ── */
[data-sf-theme="purple"] .home-page-wrapper {
background: linear-gradient(160deg, #1a0533 0%, #3b0764 45%, #6b21a8 100%);
}
[data-sf-theme="purple"] .home-btn-primary {
background: linear-gradient(135deg, #6b21a8 0%, #9333ea 100%);
}
[data-sf-theme="purple"] .home-feature-badge {
background: rgba(147, 51, 234, 0.10);
color: #c084fc;
border-color: rgba(147, 51, 234, 0.25);
}
/* Login card header: set via a CSS rule so inline gradient can be overridden */
.login-card .card-header {
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%) !important;
}
[data-sf-theme="purple"] .login-card .card-header {
background: linear-gradient(135deg, #1a0533 0%, #6b21a8 100%) !important;
}
[data-sf-theme="blazing-dark"] .home-page-wrapper {
background: linear-gradient(160deg, #0a0f1e 0%, #111827 50%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .home-btn-primary {
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%);
}
[data-sf-theme="blazing-dark"] .home-feature-badge {
background: rgba(99, 102, 241, 0.12);
color: #a5b4fc;
border-color: rgba(99, 102, 241, 0.28);
}
[data-sf-theme="blazing-dark"] .home-card {
background: rgba(15, 20, 40, 0.92) !important;
color: rgba(255, 255, 255, 0.85) !important;
}
[data-sf-theme="blazing-dark"] .home-card .text-muted {
color: rgba(255, 255, 255, 0.55) !important;
}
[data-sf-theme="blazing-dark"] .login-card {
background: rgba(15, 20, 40, 0.95) !important;
color: rgba(255, 255, 255, 0.85) !important;
}
[data-sf-theme="blazing-dark"] .login-card .card-header {
background: linear-gradient(135deg, #0a0f1e 0%, #1e1b4b 100%) !important;
}
[data-sf-theme="blazing-dark"] .login-card .card-body {
background: rgba(15, 20, 40, 0.95);
}
[data-sf-theme="blazing-dark"] .login-card .card-footer {
background: rgba(15, 20, 40, 0.95) !important;
color: rgba(255, 255, 255, 0.45) !important;
}
[data-sf-theme="blazing-dark"] .login-card .form-label {
color: rgba(255, 255, 255, 0.75);
}
[data-sf-theme="blazing-dark"] .login-card .form-control,
[data-sf-theme="blazing-dark"] .login-card .input-group-text {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.85);
}
[data-sf-theme="blazing-dark"] .login-card .btn-primary {
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%) !important;
border-color: transparent !important;
}
[data-sf-theme="blazing-dark"] .login-card .text-muted {
color: rgba(255, 255, 255, 0.50) !important;
} }

View File

@@ -51,6 +51,41 @@
overflow: auto; overflow: auto;
} }
.pdf-editor-wrapper {
width: 100%;
height: 100%;
}
.sender-editor-pdf-viewer {
width: 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 {
display: flex;
flex-direction: column;
align-items: center;
}
.sender-editor-pdf-viewer .dxbrv-report-preview-content-flex-item {
width: 100%;
display: flex;
justify-content: center;
}
.sender-editor-pdf-viewer .dxbrv-report-preview-content {
margin-left: auto;
margin-right: auto;
}
.pdf-viewer-container { .pdf-viewer-container {
height: 100%; height: 100%;
display: flex; display: flex;
@@ -546,6 +581,285 @@ body.resizing {
white-space: nowrap; white-space: nowrap;
} }
.sender-toolbar-action-btn {
min-width: auto;
padding: 0.5rem 0.75rem;
}
.sender-toolbar-action-btn--compact {
padding: 0.45rem 0.7rem;
}
.sender-receivers-panel {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem 0.9rem;
border-radius: 12px;
background: linear-gradient(135deg, rgba(126, 34, 206, 0.05) 0%, rgba(42, 82, 152, 0.05) 100%);
border: 1px solid rgba(126, 34, 206, 0.12);
}
.sender-receivers-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.sender-receivers-panel__title-wrap {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.sender-receivers-panel__title {
font-size: 0.8rem;
font-weight: 700;
color: #4c1d95;
letter-spacing: 0.02em;
}
.sender-receivers-panel__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
min-height: 1.5rem;
padding: 0 0.45rem;
border-radius: 999px;
background: rgba(79, 70, 229, 0.12);
color: #5b21b6;
font-size: 0.72rem;
font-weight: 700;
}
.sender-receivers-panel__add-btn .dxbl-btn {
border-radius: 8px;
font-weight: 600;
}
.pdf-toolbar-like-btn .dxbl-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 34px;
padding: 0.45rem 0.85rem;
border-radius: 8px;
border: 1px solid rgba(126, 34, 206, 0.2);
background: linear-gradient(135deg, rgba(126, 34, 206, 0.05) 0%, rgba(42, 82, 152, 0.05) 100%);
color: #1e293b;
font-size: 0.75rem;
font-weight: 600;
box-shadow: none;
transition: all 0.2s ease;
}
.pdf-toolbar-like-btn .dxbl-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(126, 34, 206, 0.1) 0%, rgba(42, 82, 152, 0.1) 100%);
border-color: rgba(126, 34, 206, 0.4);
color: #1e293b;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(126, 34, 206, 0.2);
}
.pdf-toolbar-like-btn .dxbl-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(126, 34, 206, 0.15);
}
.pdf-toolbar-like-btn--add .dxbl-btn::before,
.pdf-toolbar-like-btn--signature .dxbl-btn::before {
display: inline-block;
font-size: 0.9rem;
line-height: 1;
font-weight: 700;
}
.pdf-toolbar-like-btn--add .dxbl-btn::before {
content: '+';
color: #7e22ce;
}
.pdf-toolbar-like-btn--signature .dxbl-btn {
color: #7e22ce;
}
.pdf-toolbar-like-btn--signature .dxbl-btn::before {
content: '?';
color: #7e22ce;
}
.pdf-toolbar-like-btn--signature .dxbl-btn:hover:not(:disabled) {
color: #7e22ce;
}
.sender-receivers-panel__empty {
font-size: 0.78rem;
color: #64748b;
}
.sender-receivers-list {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
}
.sender-receiver-chip {
display: inline-flex;
align-items: center;
gap: 0.75rem;
min-width: 220px;
max-width: 100%;
padding: 0.625rem 0.75rem;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(126, 34, 206, 0.12);
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06);
}
.sender-receiver-chip__body {
min-width: 0;
flex: 1;
}
.sender-receiver-chip__name {
font-size: 0.78rem;
font-weight: 700;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sender-receiver-chip__email {
margin-top: 0.15rem;
font-size: 0.72rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sender-receiver-chip__phone {
margin-top: 0.15rem;
font-size: 0.72rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sender-receiver-popup .dxbl-modal {
border-radius: 18px;
}
.sender-receiver-popup .dxbl-popup {
max-width: min(720px, calc(100vw - 2rem));
}
.sender-receiver-popup .dxbl-popup-content {
padding: 1rem 1.25rem 1.1rem;
}
.sender-receiver-popup .dxbl-popup-header {
padding: 0.95rem 1.25rem;
}
.sender-receiver-popup .dxbl-popup-footer {
padding: 0.75rem 1.25rem 1rem;
}
.sender-receiver-popup__body {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.sender-receiver-popup__form-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 1rem 1.25rem;
align-items: start;
min-height: 250px;
}
.sender-receiver-popup__field {
min-width: 0;
}
.sender-receiver-popup__label {
margin-bottom: 0.45rem;
font-size: 0.82rem;
font-weight: 600;
color: #475569;
}
.sender-receiver-popup__field .dxbl-text-edit,
.sender-receiver-popup__field .dxbl-dropdown-edit {
width: 100%;
}
.sender-receiver-popup__field .dxbl-input-editor,
.sender-receiver-popup__field .dxbl-text-edit-input {
min-height: 38px;
}
.sender-receiver-popup__suggestions-shell {
min-height: 188px;
margin-top: 0.5rem;
}
.sender-receiver-popup__suggestions {
border: 1px solid rgba(126, 34, 206, 0.12);
border-radius: 10px;
background: rgba(255, 255, 255, 0.98);
overflow: hidden;
}
.sender-receiver-popup__suggestions .dxbl-listbox {
border: none;
}
.sender-receiver-popup__suggestions .dxbl-listbox-scroll-viewer {
max-height: 180px;
}
.sender-receiver-popup__hint {
font-size: 0.78rem;
color: #64748b;
}
.sender-receiver-popup__loading {
font-size: 0.78rem;
color: #4f46e5;
font-weight: 600;
}
.sender-receiver-popup__validation {
padding: 0.625rem 0.75rem;
border-radius: 10px;
background: rgba(239, 68, 68, 0.08);
color: #b91c1c;
font-size: 0.8rem;
font-weight: 600;
}
.sender-receiver-popup__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
width: 100%;
}
.sender-receiver-popup__footer .dxbl-btn {
min-width: 148px;
border-radius: 8px;
font-weight: 600;
}
.pdf-frame { .pdf-frame {
background: white; background: white;
border-radius: 16px; border-radius: 16px;
@@ -748,6 +1062,30 @@ body.resizing {
flex-wrap: wrap; flex-wrap: wrap;
} }
.sender-receivers-panel {
padding: 0.625rem 0.75rem;
}
.sender-receiver-chip {
width: 100%;
min-width: 0;
flex-wrap: wrap;
}
.sender-receiver-chip__action {
width: 100%;
}
.sender-receiver-chip__action {
width: 100%;
}
.sender-receiver-popup__form-grid {
grid-template-columns: 1fr;
gap: 0.85rem;
min-height: 0;
}
.envelope-title { .envelope-title {
font-size: 1rem; font-size: 1rem;
} }
@@ -765,3 +1103,111 @@ body.resizing {
padding: 1.5rem; padding: 1.5rem;
} }
} }
/* ── Theme overrides: Envelope Viewer / Editor / Receiver ───────────────── */
/* selector: [data-sf-theme="<name>"] on <html>, set by signflow-theme.js */
/* ── Purple ─────────────────────────────────────────────────────────────── */
[data-sf-theme="purple"] .envelope-viewer-layout {
background: linear-gradient(135deg, #1a0533 0%, #3b0764 50%, #6b21a8 100%);
}
[data-sf-theme="purple"] .envelope-action-bar {
border-bottom-color: rgba(147, 51, 234, 0.4);
}
[data-sf-theme="purple"] .envelope-logo svg {
color: #9333ea;
filter: drop-shadow(0 2px 4px rgba(147, 51, 234, 0.35));
}
[data-sf-theme="purple"] .pdf-toolbar__btn {
border-color: rgba(147, 51, 234, 0.25);
}
[data-sf-theme="purple"] .pdf-toolbar__btn:hover {
border-color: rgba(147, 51, 234, 0.5);
box-shadow: 0 2px 8px rgba(147, 51, 234, 0.2);
}
[data-sf-theme="purple"] .pdf-text-layer ::selection,
[data-sf-theme="purple"] .pdf-text-layer ::-moz-selection {
background: rgba(147, 51, 234, 0.3);
}
/* ── Blazing Dark ───────────────────────────────────────────────────────── */
[data-sf-theme="blazing-dark"] .envelope-viewer-layout {
background: linear-gradient(135deg, #0a0f1e 0%, #111827 55%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .envelope-action-bar {
background: rgba(20, 20, 35, 0.92);
border-bottom-color: rgba(99, 102, 241, 0.35);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
[data-sf-theme="blazing-dark"] .envelope-title {
color: rgba(255, 255, 255, 0.92);
}
[data-sf-theme="blazing-dark"] .envelope-key {
color: rgba(255, 255, 255, 0.45);
}
[data-sf-theme="blazing-dark"] .envelope-logo svg {
color: #818cf8;
filter: drop-shadow(0 2px 4px rgba(129, 140, 248, 0.3));
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.82);
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn:hover {
background: rgba(129, 140, 248, 0.15);
border-color: rgba(129, 140, 248, 0.4);
color: #fff;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--signature-change-active {
background: rgba(99, 102, 241, 0.25);
border-color: rgba(99, 102, 241, 0.5);
color: #a5b4fc;
}
/* Error container */
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
/* Receiver info header */
[data-sf-theme="blazing-dark"] .receiver-info-header__gradient {
background: linear-gradient(135deg, #0a0f1e 0%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .receiver-info-message {
color: rgba(255, 255, 255, 0.70);
border-bottom-color: rgba(255, 255, 255, 0.06);
}
[data-sf-theme="blazing-dark"] .receiver-info-private-message {
background: rgba(245, 158, 11, 0.08);
border-top-color: rgba(245, 158, 11, 0.18);
color: #fde68a;
}
/* Signature action bar */
[data-sf-theme="blazing-dark"] .receiver-action-bar {
background: rgba(20, 20, 35, 0.92);
border-bottom-color: rgba(255, 255, 255, 0.06);
}
/* PDF text selection */
[data-sf-theme="blazing-dark"] .pdf-text-layer ::selection,
[data-sf-theme="blazing-dark"] .pdf-text-layer ::-moz-selection {
background: rgba(99, 102, 241, 0.35);
}

View File

@@ -295,3 +295,146 @@
font-size: 0.813rem; font-size: 0.813rem;
} }
} }
/* ── Theme overrides ─────────────────────────────────────────────────────── */
/* Selector: [data-sf-theme="<name>"] set on <html> by signflow-theme.js */
/* ── Purple theme ──────────────────────────────────────────────────────────
Deeper violet, less blue — visually distinct from Blazing Berry */
[data-sf-theme="purple"] .sender-dashboard-layout {
background: linear-gradient(135deg, #1a0533 0%, #3b0764 50%, #6b21a8 100%);
}
[data-sf-theme="purple"] .sender-grid-container::before {
background: linear-gradient(90deg, #9333ea 0%, #6b21a8 100%);
}
[data-sf-theme="purple"] .sender-action-bar {
border-bottom-color: rgba(147, 51, 234, 0.4);
}
[data-sf-theme="purple"] .sender-logo svg {
color: #9333ea;
filter: drop-shadow(0 2px 4px rgba(147, 51, 234, 0.35));
}
[data-sf-theme="purple"] .sender-btn--primary {
background: linear-gradient(135deg, #9333ea 0%, #6b21a8 100%);
}
[data-sf-theme="purple"] .sender-btn--primary:hover:not(:disabled) {
background: linear-gradient(135deg, #7e22ce 0%, #581c87 100%);
box-shadow: 0 4px 16px rgba(147, 51, 234, 0.35);
}
[data-sf-theme="purple"] .sender-btn:hover:not(:disabled) {
border-color: rgba(147, 51, 234, 0.45);
box-shadow: 0 4px 12px rgba(147, 51, 234, 0.22);
}
[data-sf-theme="purple"] .sender-tabs {
border-bottom-color: rgba(147, 51, 234, 0.12);
}
[data-sf-theme="purple"] .sender-tab--active {
color: #9333ea;
border-bottom-color: #9333ea;
}
/* ── Blazing Dark theme ────────────────────────────────────────────────────
Dark navy background, frosted glass surfaces turn semi-dark */
[data-sf-theme="blazing-dark"] .sender-dashboard-layout {
background: linear-gradient(135deg, #0a0f1e 0%, #111827 55%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .sender-action-bar {
background: rgba(20, 20, 35, 0.92);
border-bottom-color: rgba(99, 102, 241, 0.35);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
[data-sf-theme="blazing-dark"] .sender-title {
color: rgba(255, 255, 255, 0.92);
}
[data-sf-theme="blazing-dark"] .sender-logo svg {
color: #818cf8;
filter: drop-shadow(0 2px 4px rgba(129, 140, 248, 0.3));
}
[data-sf-theme="blazing-dark"] .sender-btn {
color: rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
}
[data-sf-theme="blazing-dark"] .sender-btn:hover:not(:disabled) {
background: rgba(129, 140, 248, 0.15);
border-color: rgba(129, 140, 248, 0.4);
color: #fff;
box-shadow: 0 4px 12px rgba(129, 140, 248, 0.2);
}
[data-sf-theme="blazing-dark"] .sender-btn:disabled {
color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.06);
}
[data-sf-theme="blazing-dark"] .sender-btn--primary {
background: linear-gradient(135deg, #6366f1 0%, #4338ca 100%);
color: white;
border-color: transparent;
}
[data-sf-theme="blazing-dark"] .sender-btn--primary:hover:not(:disabled) {
background: linear-gradient(135deg, #818cf8 0%, #6366f1 100%);
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4);
}
[data-sf-theme="blazing-dark"] .sender-btn--danger {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.28);
color: #fca5a5;
}
[data-sf-theme="blazing-dark"] .sender-btn--danger:hover:not(:disabled) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
border-color: transparent;
}
[data-sf-theme="blazing-dark"] .sender-grid-container {
background: rgba(15, 20, 40, 0.95);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06);
}
[data-sf-theme="blazing-dark"] .sender-grid-container::before {
background: linear-gradient(90deg, #6366f1 0%, #4338ca 100%);
}
[data-sf-theme="blazing-dark"] .sender-tabs {
background: rgba(255, 255, 255, 0.03);
border-bottom-color: rgba(99, 102, 241, 0.12);
}
[data-sf-theme="blazing-dark"] .sender-tab {
color: rgba(255, 255, 255, 0.45);
}
[data-sf-theme="blazing-dark"] .sender-tab--active {
color: #818cf8;
border-bottom-color: #818cf8;
}
[data-sf-theme="blazing-dark"] .sender-tab:hover:not(.sender-tab--active) {
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.04);
}
/* Error container in dark mode */
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}

View File

@@ -0,0 +1,95 @@
window.envelopeEditor = {
/**
* Returns the click position normalised to [0,1] relative to the rendered PDF page
* element inside DxPdfViewer (or DxReportViewer as fallback).
*
* Normalising means the result is independent of zoom level: no matter how much the
* 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)
*/
getClickCoordsOnPdfPage: function (viewerCssClass, clientX, clientY) {
// 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;
return {
normX: normX,
normY: normY,
pageIndex: targetIndex
};
}
};

View File

@@ -0,0 +1,37 @@
/**
* signFLOW Theme Helper
* Called from ThemeService (Blazor WASM) to apply theme changes.
*/
window.signflowTheme = {
apply: function (href, bsTheme, themeName) {
console.debug('[signflowTheme] apply', themeName, bsTheme);
// ── Bootstrap dark mode ─────────────────────────────────────────
document.documentElement.setAttribute('data-bs-theme', bsTheme);
// ── Theme name attribute for CSS overrides ──────────────────────
// Applied to <html> so any nested selector can use it:
// [data-sf-theme="blazing-dark"] .sender-dashboard-layout { ... }
document.documentElement.setAttribute('data-sf-theme', themeName);
// ── DevExpress theme CSS link ────────────────────────────────────
document.querySelectorAll('link[href*="DevExpress.Blazor.Themes"]').forEach(function(el) {
if (el.id !== 'dx-theme-link') el.parentNode.removeChild(el);
});
let link = document.getElementById('dx-theme-link');
if (!link) {
link = document.createElement('link');
link.id = 'dx-theme-link';
link.rel = 'stylesheet';
document.head.appendChild(link);
}
const currentRelative = link.getAttribute('href') || '';
if (currentRelative !== href) {
link.setAttribute('href', href);
}
}
};

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

@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net4.6.2</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-EnvelopeGenerator.Service-816256a4-c636-4313-92c0-b62b04040c2b</UserSecretsId>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="7.0.0" />
<PackageReference Include="NLog" Version="5.3.1" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.9" />
</ItemGroup>
<!-- DigitalData & Quartz: local packages, must match CommonServices versions exactly -->
<ItemGroup>
<Reference Include="DigitalData.Modules.Database">
<HintPath>..\packages\DigitalData.Modules.Database.2.3.5.4\lib\net462\DigitalData.Modules.Database.dll</HintPath>
</Reference>
<Reference Include="DigitalData.Modules.Encryption">
<HintPath>..\packages\DigitalData.Modules.Encryption.1.3.1\lib\net462\DigitalData.Modules.Encryption.dll</HintPath>
</Reference>
<Reference Include="DigitalData.Modules.Logging">
<HintPath>..\packages\DigitalData.Modules.Logging.2.6.5\lib\net462\DigitalData.Modules.Logging.dll</HintPath>
</Reference>
<!-- Quartz 3.8.0 <20> must match CommonServices (same AppDomain, one version) -->
<Reference Include="Quartz">
<HintPath>..\packages\Quartz.3.8.0\lib\net462\Quartz.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.CommonServices\EnvelopeGenerator.CommonServices.vbproj" />
<ProjectReference Include="..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,28 @@
using DigitalData.Modules.Logging;
namespace EnvelopeGenerator.Service;
/// <summary>
/// Builds a <see cref="LogConfig"/> from <see cref="ServiceConfig"/>.
/// Required because CommonServices jobs have a hard dependency on <see cref="LogConfig"/>
/// in their Quartz <c>JobDataMap</c>.
/// Log path mirrors the legacy behavior: <c>&lt;BaseDirectory&gt;\Log</c>.
/// </summary>
internal static class LogConfigFactory
{
public static LogConfig Create(ServiceConfig config)
{
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log");
var logConfig = new LogConfig(
LogConfig.PathType.CustomPath,
logPath,
null,
"Digital Data",
"EnvelopeGenerator.Service");
logConfig.Debug = config.Debug;
return logConfig;
}
}

View File

@@ -0,0 +1,315 @@
# Migration Plan: EnvelopeGenerator.Service_legacy ? EnvelopeGenerator.Service
**Revision v3** — Full deep analysis complete
## Overview
Migrate the legacy VB.NET Windows Service (`EnvelopeGenerator.Service_legacy`) to a modern
C# Worker Service (`EnvelopeGenerator.Service`) targeting **.NET Framework 4.6.2**, using
`Microsoft.Extensions.Hosting`, `IConfiguration`, `ILogger<T>` and **NLog**.
---
## Deep Analysis Findings (vs. v1/v2)
### Finding 1 — Connection String: Plain Text, No Encryption Required
`FinalizeDocumentJob` and `APIEnvelopeJob` call `MSSQLServer.DecryptConnectionString()` on the
`JobDataMap[Value.DATABASE]` value. However, `MSSQLServer.DecryptConnectionString()` is a pass-through
for plain-text strings — no wrapping/encryption step is needed.
**Resolution**: The connection string is read from `IConfiguration` via the standard
`ConnectionStrings:Default` key (same key as `appsettings.Database.json` in `EnvelopeGenerator.Web`).
It is passed **as-is** to both the Worker-level `MSSQLServer` and the `JobDataMap`. No
`EncryptConnectionString()` call is made anywhere in the new service.
### Finding 2 — Dual TempFiles Architecture
There are **two independent** `TempFiles` classes:
- `EnvelopeGenerator.CommonServices.TempFiles` — used **inside each job** via `New TempFiles(LogConfig)`.
This is called on every job execution and manages the job's own temp lifecycle. It expects `LogConfig`.
**We do not touch this.**
- `EnvelopeGenerator.Service_legacy.TempFiles` — used at the **service/host level** (startup cleanup,
shutdown cleanup). This is the one we rewrite in C# in the Service project using `ILogger<TempFiles>`.
### Finding 3 — LogConfig is a Hard Dependency of All Jobs
`FinalizeDocumentJob.Execute()` and `APIEnvelopeJob.Execute()` both do:
```vb
LogConfig = pContext.MergedJobDataMap.Item(Value.LOGCONFIG)
Logger = LogConfig.GetLogger()
myTempFiles = New TempFiles(LogConfig) ' ? CommonServices.TempFiles
```
`LogConfig` is **not optional**. The schedulers must construct a valid `LogConfig` and place it in the
`JobDataMap`. It must carry the correct log path, debug flag, and application identity.
**Resolution**: A `LogConfigFactory` static helper class will be created in the Service project to
build a `LogConfig` from `ServiceConfig` values:
- `LogPath` = `Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log")` (mirrors legacy `My.Application.Info.DirectoryPath + "\Log"`)
- `Debug` = `ServiceConfig.Debug`
- Application name = `"EnvelopeGenerator.Service"`
### Finding 4 — ProjectInstaller is Obsolete
`ProjectInstaller.vb` was the old `installutil.exe` mechanism for Windows Service registration.
With `AddWindowsService()` + `sc create` / PowerShell `New-Service`, this is **completely replaced**.
No equivalent is needed in the new project.
### Finding 5 — GDPicture License SQL
The legacy service queries: `SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME = 'GDPICTURE' and ACTIVE = 1`
This must be replicated exactly in the Worker startup sequence. If the result is null/empty, startup fails
with a descriptive exception (matching legacy behavior).
### Finding 6 — Quartz Version Mismatch
`CommonServices` uses `Quartz 3.8.0`. `Service_legacy` uses `Quartz 3.15.0`.
The new `EnvelopeGenerator.Service` must use **Quartz 3.8.0** to match `CommonServices` (same AppDomain,
same version must be loaded). The legacy version bump in `Service_legacy` was inconsistent.
---
## 1. Project File (`EnvelopeGenerator.Service.csproj`)
- Add **NuGet packages**:
- `NLog` (5.x, matching CommonServices' `NLog.5.0.5`)
- `NLog.Extensions.Logging` (latest net462-compatible)
- `Quartz` (**3.8.0** — must match CommonServices)
- `Microsoft.Extensions.Hosting.WindowsServices` (for `AddWindowsService()`)
- Add **project reference** to `EnvelopeGenerator.CommonServices` (vbproj)
- Add **project reference** to `EnvelopeGenerator.Domain` (csproj)
---
## 2. Configuration (`ServiceConfig.cs`)
```csharp
public class ServiceConfig
{
public string ConnectionString { get; set; } = string.Empty;
public bool Debug { get; set; } = false;
public int IntervalInMin { get; set; } = 1;
public PDFBurnerParams PDFBurnerParams { get; set; } = new PDFBurnerParams();
}
```
Connection string is read via the standard `ConnectionStrings:Default` key — consistent with
`appsettings.Database.json` used by `EnvelopeGenerator.Web`:
`appsettings.json`:
```json
{
"ConnectionStrings": {
"Default": ""
},
"ServiceConfig": {
"Debug": false,
"IntervalInMin": 1,
"PDFBurnerParams": {
"IgnoredLabels": [ "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" ],
"TopMargin": 0.1,
"YOffset": -0.3,
"FontName": "Arial",
"FontSize": 8,
"FontStyle": 2,
"TruncationSuffix": "..",
"TextMaxWidths": {
"position": 1.41,
"city": 1.41,
"date": 2.0
}
}
}
}
```
> `FontStyle` integer: Regular=0, Bold=1, **Italic=2**, Underline=4, Strikeout=8.
> All values mirror `PDFBurnerParams.vb` defaults exactly.
---
## 3. NLog Setup
**`nlog.config`**: File target ? `${basedir}/Log/service-${shortdate}.log`, async wrapper,
layout with timestamp + level + logger + message + exception.
**`Program.cs`**: Wire via `NLog.Extensions.Logging`:
```csharp
builder.Logging.ClearProviders();
builder.Logging.AddNLog("nlog.config");
```
---
## 4. LogConfigFactory (`LogConfigFactory.cs`)
New static helper — bridges `ServiceConfig` ? `LogConfig` (required by CommonServices jobs):
```csharp
internal static class LogConfigFactory
{
public static LogConfig Create(ServiceConfig config)
{
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log");
var logConfig = new LogConfig(
LogConfig.PathType.CustomPath, logPath, null,
"Digital Data", "EnvelopeGenerator.Service");
logConfig.Debug = config.Debug;
return logConfig;
}
}
```
This `LogConfig` instance is what gets placed into every `JobDataMap[Value.LOGCONFIG]`.
---
## 5. Quartz LogProvider (`QuartzLogProvider.cs`)
C# rewrite of `LogProvider.vb` — bridges Quartz `ILogProvider` ? `ILogger<T>`:
```csharp
internal class QuartzLogProvider : ILogProvider
{
private readonly ILogger _logger;
public QuartzLogProvider(ILogger logger) => _logger = logger;
public Logger GetLogger(string name) => (level, func, ex, parameters) => { ... };
// OpenNestedContext / OpenMappedContext ? NotImplementedException (same as legacy)
}
```
---
## 6. TempFiles (`TempFiles.cs`) — Service-level only
Service-level rewrite of `Service_legacy/TempFiles.vb` using `ILogger<TempFiles>`:
- `Create()` ? create `%TEMP%\EnvelopeGenerator`, or clean existing files
- `CleanUp()` ? delete the directory on service stop
> **Does NOT replace** `CommonServices.TempFiles` — that one continues to be used
> inside each job via `LogConfig`.
---
## 7. Schedulers
### `Schedulers/SchedulerFinishEnvelope.cs`
```csharp
public class SchedulerFinishEnvelope
{
// Constructor: ILogger<SchedulerFinishEnvelope>, IOptions<ServiceConfig>
// Start(licenseKey, logConfig):
// - Build JobDataMap:
// [GDPICTURE] = licenseKey
// [LOGCONFIG] = logConfig ? LogConfigFactory output
// [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString)
// [PDF_BURNER_PARAMS] = config.PDFBurnerParams
// - Schedule FinalizeDocumentJob with Quartz (interval from config)
// - Register QuartzLogProvider
// Stop(): Scheduler.Shutdown()
}
```
### `Schedulers/SchedulerEnvelopeTaskApi.cs`
```csharp
public class SchedulerEnvelopeTaskApi
{
// Constructor: ILogger<SchedulerEnvelopeTaskApi>, IOptions<ServiceConfig>
// Start(logConfig):
// - Build JobDataMap:
// [LOGCONFIG] = logConfig
// [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString)
// - Schedule APIEnvelopeJob with Quartz (interval from config)
// - Register QuartzLogProvider
// Stop(): Scheduler.Shutdown()
}
```
---
## 8. Worker (`Worker.cs`)
Full lifecycle replacing the placeholder:
```
ExecuteAsync:
1. Read ServiceConfig from IOptions<ServiceConfig>
2. Validate ConnectionString ? throw if empty
3. Build LogConfig via LogConfigFactory
4. Connect to DB (plain connection string — Worker-level only)
5. Query: SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME='GDPICTURE' AND ACTIVE=1
? throw if null/empty (matches legacy behavior)
6. TempFiles.Create()
7. await SchedulerFinishEnvelope.Start(licenseKey, logConfig)
8. await Task.Delay(2500, stoppingToken) ? preserved from legacy
9. await SchedulerEnvelopeTaskApi.Start(logConfig)
10. await Task.Delay(Timeout.Infinite, stoppingToken) ? wait for cancellation
StopAsync override:
11. await SchedulerFinishEnvelope.Stop()
12. await SchedulerEnvelopeTaskApi.Stop()
13. TempFiles.CleanUp()
```
---
## 9. Program.cs
```csharp
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddNLog("nlog.config");
builder.Services.Configure<ServiceConfig>(
builder.Configuration.GetSection("ServiceConfig"));
builder.Services.AddSingleton<TempFiles>();
builder.Services.AddSingleton<SchedulerFinishEnvelope>();
builder.Services.AddSingleton<SchedulerEnvelopeTaskApi>();
builder.Services.AddHostedService<Worker>();
builder.Services.AddWindowsService(o => o.ServiceName = "EnvelopeGenerator.Service");
var host = builder.Build();
host.Run();
```
---
## 10. Files to Create / Modify
| File | Action | Notes |
|------|--------|-------|
| `EnvelopeGenerator.Service.csproj` | **Modify** | Add packages, project refs |
| `Program.cs` | **Modify** | Full DI/NLog/WindowsService setup |
| `Worker.cs` | **Modify** | Full service lifecycle |
| `ServiceConfig.cs` | **Create** | |
| `LogConfigFactory.cs` | **Create** | Critical bridge: ServiceConfig ? LogConfig |
| `TempFiles.cs` | **Create** | Service-level only; CommonServices.TempFiles untouched |
| `QuartzLogProvider.cs` | **Create** | |
| `Schedulers/SchedulerFinishEnvelope.cs` | **Create** | |
| `Schedulers/SchedulerEnvelopeTaskApi.cs` | **Create** | |
| `appsettings.json` | **Create** | All PDFBurnerParams explicit |
| `nlog.config` | **Create** | |
**Not created** (obsolete in new architecture):
- ~~`ProjectInstaller`~~ ? replaced by `sc create` / `New-Service`
- ~~`Service.Designer.vb`~~ ? BackgroundService handles this
- ~~`Service.resx`~~ ? not applicable
---
## 11. Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| **Quartz 3.8.0** (not 3.15.0) | Must match CommonServices; same AppDomain, one version |
| **Connection string plain-text, no encryption** | `MSSQLServer.DecryptConnectionString()` is a pass-through for plain strings; `ConnectionStrings:Default` key used (same convention as Web project) |
| **Connection string in `ConnectionStrings:Default`** | Consistent with rest of the solution (`appsettings.Database.json`); `ServiceConfig` holds only service-specific settings |
| **LogConfigFactory** | Jobs have hard dependency on `LogConfig` in JobDataMap; this is the clean bridge without leaking LogConfig into the DI container |
| **2500ms delay preserved** | Legacy behavior; ensures Scheduler1 is fully initialized before Scheduler2 starts |
| **`AddWindowsService()`** | Native SCM integration; replaces ProjectInstaller/installutil entirely |
| **NLog log path = BaseDirectory/Log** | Mirrors `My.Application.Info.DirectoryPath + "\Log"` from legacy |
| **CommonServices.TempFiles untouched** | Jobs create their own TempFiles internally via LogConfig; service-level TempFiles is separate concern |
---
## Approval Required
> Please review this plan and confirm before implementation begins.

View File

@@ -0,0 +1,41 @@
using EnvelopeGenerator.Service;
using EnvelopeGenerator.Service.Schedulers;
using Microsoft.Extensions.Options;
using NLog.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
// NLog
builder.Logging.ClearProviders();
builder.Logging.AddNLog("nlog.config");
// Configuration
builder.Services.Configure<ServiceConfig>(
builder.Configuration.GetSection("ServiceConfig"));
// Resolve connection string once for singleton registrations
var connectionString = builder.Configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("ConnectionStrings:Default is not configured.");
// Services
builder.Services.AddSingleton<TempFiles>();
builder.Services.AddSingleton<SchedulerFinishEnvelope>(sp => new SchedulerFinishEnvelope(
sp.GetRequiredService<ILogger<SchedulerFinishEnvelope>>(),
sp.GetRequiredService<IOptions<ServiceConfig>>(),
connectionString));
builder.Services.AddSingleton<SchedulerEnvelopeTaskApi>(sp => new SchedulerEnvelopeTaskApi(
sp.GetRequiredService<ILogger<SchedulerEnvelopeTaskApi>>(),
sp.GetRequiredService<IOptions<ServiceConfig>>(),
connectionString));
builder.Services.AddHostedService<Worker>();
// Windows Service (SCM) support — replaces legacy ProjectInstaller/installutil
builder.Services.AddWindowsService(options =>
options.ServiceName = "EnvelopeGenerator.Service");
var host = builder.Build();
host.Run();

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net4.6.2\publish\</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<_TargetId>Folder</_TargetId>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,12 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"EnvelopeGenerator.Service": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,60 @@
using Quartz.Logging;
using QuartzLogLevel = Quartz.Logging.LogLevel;
namespace EnvelopeGenerator.Service;
/// <summary>
/// Bridges Quartz's <see cref="ILogProvider"/> to <see cref="ILogger"/>.
/// Mirrors the legacy <c>LogProvider.vb</c> behavior.
/// <c>OpenNestedContext</c> and <c>OpenMappedContext</c> are not implemented (same as legacy).
/// </summary>
internal sealed class QuartzLogProvider : ILogProvider
{
private readonly ILogger _logger;
public QuartzLogProvider(ILogger logger)
{
_logger = logger;
}
public Logger GetLogger(string name)
{
return (level, func, exception, parameters) =>
{
if (exception != null)
{
_logger.LogError(exception, exception.Message);
}
else if (func != null)
{
switch (level)
{
case QuartzLogLevel.Trace:
case QuartzLogLevel.Debug:
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
_logger.LogDebug(func(), parameters);
break;
case QuartzLogLevel.Info:
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Information))
_logger.LogInformation(func(), parameters);
break;
case QuartzLogLevel.Warn:
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Warning))
_logger.LogWarning(func(), parameters);
break;
case QuartzLogLevel.Error:
case QuartzLogLevel.Fatal:
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Error))
_logger.LogError(func(), parameters);
break;
}
}
return true;
};
}
public IDisposable OpenNestedContext(string message) => throw new NotImplementedException();
public IDisposable OpenMappedContext(string key, object value, bool destructure = false) => throw new NotImplementedException();
}

View File

@@ -0,0 +1,94 @@
using System.Collections.Specialized;
using DigitalData.Modules.Logging;
using EnvelopeGenerator.CommonServices.Jobs;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.Extensions.Options;
using Quartz;
using Quartz.Logging;
namespace EnvelopeGenerator.Service.Schedulers;
/// <summary>
/// Schedules <see cref="APIEnvelopeJob"/> via Quartz.
/// C# rewrite of the legacy <c>Scheduler_Envelopetask_API.vb</c>.
/// </summary>
public sealed class SchedulerEnvelopeTaskApi
{
private readonly ILogger<SchedulerEnvelopeTaskApi> _logger;
private readonly ServiceConfig _config;
private readonly string _connectionString;
private IScheduler? _scheduler;
private const string JobName = "SendInvitationMailsAPI";
public SchedulerEnvelopeTaskApi(
ILogger<SchedulerEnvelopeTaskApi> logger,
IOptions<ServiceConfig> config,
string connectionString)
{
_logger = logger;
_config = config.Value;
_connectionString = connectionString;
}
public async Task Start(LogConfig logConfig)
{
try
{
_logger.LogDebug("Starting SchedulerEnvelopeTaskApi...");
LogProvider.SetCurrentLogProvider(new QuartzLogProvider(_logger));
_scheduler = await SchedulerBuilder
.Create(new NameValueCollection())
.UseDefaultThreadPool(x => x.MaxConcurrency = 5)
.BuildScheduler();
var jobKey = new JobKey(JobName);
var jobData = new JobDataMap
{
{ Value.LOGCONFIG, logConfig },
{ Value.DATABASE, _connectionString }
};
_logger.LogDebug("Initialized Job [{JobName}]", JobName);
var job = JobBuilder.Create<APIEnvelopeJob>()
.UsingJobData(jobData)
.WithIdentity(jobKey)
.Build();
var trigger = TriggerBuilder.Create()
.ForJob(jobKey)
.WithIdentity($"{JobName}-trigger")
.WithSimpleSchedule(s => s
.RepeatForever()
.WithIntervalInMinutes(_config.IntervalInMin))
.StartNow()
.Build();
_logger.LogDebug("{JobName}-trigger initialized", JobName);
await _scheduler.ScheduleJob(job, trigger);
_logger.LogDebug("{JobName} scheduled", JobName);
await _scheduler.Start();
_logger.LogInformation("{JobName} started", JobName);
}
catch (Exception ex)
{
_logger.LogError(ex, "SchedulerEnvelopeTaskApi failed to start");
throw;
}
}
public async Task Stop()
{
if (_scheduler is null) return;
_logger.LogInformation("Stopping SchedulerEnvelopeTaskApi...");
await _scheduler.Shutdown();
_logger.LogInformation("SchedulerEnvelopeTaskApi stopped!");
}
}

View File

@@ -0,0 +1,97 @@
using System.Collections.Specialized;
using DigitalData.Modules.Logging;
using EnvelopeGenerator.CommonServices.Jobs;
using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.Extensions.Options;
using Quartz;
using Quartz.Logging;
namespace EnvelopeGenerator.Service.Schedulers;
/// <summary>
/// Schedules <see cref="FinalizeDocumentJob"/> via Quartz.
/// C# rewrite of the legacy <c>Scheduler_FinishEnvelope.vb</c>.
/// </summary>
public sealed class SchedulerFinishEnvelope
{
private readonly ILogger<SchedulerFinishEnvelope> _logger;
private readonly ServiceConfig _config;
private readonly string _connectionString;
private IScheduler? _scheduler;
private const string JobName = "CertificateDocumentJob";
public SchedulerFinishEnvelope(
ILogger<SchedulerFinishEnvelope> logger,
IOptions<ServiceConfig> config,
string connectionString)
{
_logger = logger;
_config = config.Value;
_connectionString = connectionString;
}
public async Task Start(string licenseKey, LogConfig logConfig)
{
try
{
_logger.LogDebug("Starting SchedulerFinishEnvelope...");
LogProvider.SetCurrentLogProvider(new QuartzLogProvider(_logger));
_scheduler = await SchedulerBuilder
.Create(new NameValueCollection())
.UseDefaultThreadPool(x => x.MaxConcurrency = 5)
.BuildScheduler();
var jobKey = new JobKey(JobName);
var jobData = new JobDataMap
{
{ Value.GDPICTURE, licenseKey },
{ Value.LOGCONFIG, logConfig },
{ Value.DATABASE, _connectionString },
{ Value.PDF_BURNER_PARAMS, _config.PDFBurnerParams }
};
_logger.LogDebug("Initialized Job [{JobName}]", JobName);
var job = JobBuilder.Create<FinalizeDocumentJob>()
.UsingJobData(jobData)
.WithIdentity(jobKey)
.Build();
var trigger = TriggerBuilder.Create()
.ForJob(jobKey)
.WithIdentity($"{JobName}-trigger")
.WithSimpleSchedule(s => s
.RepeatForever()
.WithIntervalInMinutes(_config.IntervalInMin))
.StartNow()
.Build();
_logger.LogDebug("Initialized Trigger");
await _scheduler.ScheduleJob(job, trigger);
_logger.LogDebug("Job scheduled.");
await _scheduler.Start();
_logger.LogDebug("Scheduler started!");
}
catch (Exception ex)
{
_logger.LogError(ex, "SchedulerFinishEnvelope failed to start");
throw;
}
}
public async Task Stop()
{
if (_scheduler is null) return;
_logger.LogInformation("Stopping SchedulerFinishEnvelope...");
await _scheduler.Shutdown();
_logger.LogInformation("SchedulerFinishEnvelope stopped!");
}
}

View File

@@ -0,0 +1,14 @@
using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
namespace EnvelopeGenerator.Service;
/// <summary>
/// Service-level configuration bound from the "ServiceConfig" section of appsettings.json.
/// Connection string is read separately via the standard "ConnectionStrings:Default" key.
/// </summary>
public class ServiceConfig
{
public bool Debug { get; set; } = false;
public int IntervalInMin { get; set; } = 1;
public PDFBurnerParams PDFBurnerParams { get; set; } = new PDFBurnerParams();
}

View File

@@ -0,0 +1,75 @@
namespace EnvelopeGenerator.Service;
/// <summary>
/// Service-level temp file management. Mirrors <c>TempFiles.vb</c> from the legacy project.
/// Manages <c>%TEMP%\EnvelopeGenerator</c> at host startup and shutdown.
///
/// NOTE: This is independent from <c>EnvelopeGenerator.CommonServices.TempFiles</c>, which is
/// instantiated inside each Quartz job via <see cref="DigitalData.Modules.Logging.LogConfig"/>.
/// Do not confuse the two.
/// </summary>
public sealed class TempFiles
{
private readonly ILogger<TempFiles> _logger;
public string TempPath { get; }
public TempFiles(ILogger<TempFiles> logger)
{
_logger = logger;
TempPath = Path.Combine(Path.GetTempPath(), "EnvelopeGenerator");
}
public bool Create()
{
try
{
if (!Directory.Exists(TempPath))
{
Directory.CreateDirectory(TempPath);
}
else
{
CleanUpFiles();
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create temp directory: {TempPath}", TempPath);
return false;
}
}
private void CleanUpFiles()
{
try
{
foreach (var file in Directory.GetFiles(TempPath))
{
_logger.LogDebug("Deleting temp file: {File}", file);
File.Delete(file);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clean up temp files in: {TempPath}", TempPath);
}
}
public bool CleanUp()
{
try
{
_logger.LogDebug("Deleting temp directory: {TempPath}", TempPath);
Directory.Delete(TempPath, recursive: true);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete temp directory: {TempPath}", TempPath);
return false;
}
}
}

View File

@@ -0,0 +1,111 @@
using DigitalData.Modules.Database;
using EnvelopeGenerator.Service.Schedulers;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Service;
public sealed class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
private readonly ServiceConfig _config;
private readonly string _connectionString;
private readonly TempFiles _tempFiles;
private readonly SchedulerFinishEnvelope _schedulerFinishEnvelope;
private readonly SchedulerEnvelopeTaskApi _schedulerEnvelopeTaskApi;
public Worker(
ILogger<Worker> logger,
IOptions<ServiceConfig> config,
IConfiguration configuration,
TempFiles tempFiles,
SchedulerFinishEnvelope schedulerFinishEnvelope,
SchedulerEnvelopeTaskApi schedulerEnvelopeTaskApi)
{
_logger = logger;
_config = config.Value;
_connectionString = configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("ConnectionStrings:Default is not configured.");
_tempFiles = tempFiles;
_schedulerFinishEnvelope = schedulerFinishEnvelope;
_schedulerEnvelopeTaskApi = schedulerEnvelopeTaskApi;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
_logger.LogInformation("EnvelopeGenerator.Service starting...");
// 1. Validate connection string
if (string.IsNullOrWhiteSpace(_connectionString))
throw new InvalidOperationException("Connection string is empty!");
// 2. Build LogConfig (required by CommonServices jobs via JobDataMap)
var logConfig = LogConfigFactory.Create(_config);
_logger.LogInformation("DEBUG = {Debug}", _config.Debug);
// 3. Connect to DB and load GDPicture license
_logger.LogDebug("Initializing Database...");
var database = new MSSQLServer(logConfig, _connectionString);
if (!database.DBInitialized)
throw new InvalidOperationException("Database connection could not be established!");
_logger.LogDebug("Database initialized.");
var licenseKey = database.GetScalarValue(
"SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME = 'GDPICTURE' AND ACTIVE = 1") as string;
if (string.IsNullOrWhiteSpace(licenseKey))
throw new InvalidOperationException("GDPicture license could not be loaded!");
_logger.LogDebug("GDPicture license loaded.");
// 4. Create service-level temp directory
_tempFiles.Create();
// 5. Start FinalizeDocument scheduler
_logger.LogDebug("Initializing schedulers...");
await _schedulerFinishEnvelope.Start(licenseKey!, logConfig);
// 6. Delay between schedulers (legacy behavior preserved)
await Task.Delay(2500, stoppingToken);
// 7. Start API envelope scheduler
await _schedulerEnvelopeTaskApi.Start(logConfig);
_logger.LogInformation("EnvelopeGenerator.Service started!");
// 8. Wait for cancellation (service runs until stopped)
await Task.Delay(Timeout.Infinite, stoppingToken);
}
catch (OperationCanceledException)
{
// Normal shutdown <20> not an error
}
catch (Exception ex)
{
_logger.LogError(ex, "EnvelopeGenerator.Service encountered a fatal error during startup.");
throw;
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("EnvelopeGenerator.Service stopping...");
try
{
await _schedulerFinishEnvelope.Stop();
await _schedulerEnvelopeTaskApi.Stop();
_tempFiles.CleanUp();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during service shutdown.");
}
await base.StopAsync(cancellationToken);
_logger.LogInformation("EnvelopeGenerator.Service stopped!");
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,24 @@
{
"ConnectionStrings": {
"Default": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
},
"ServiceConfig": {
"Debug": false,
"IntervalInMin": 1,
"PDFBurnerParams": {
"IgnoredLabels": [ "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" ],
"TopMargin": 0.1,
"YOffset": -0.3,
"FontName": "Arial",
"FontSize": 8,
"FontStyle": 2,
"TruncationSuffix": "..",
"TextMaxWidths": {
"position": 1.41,
"city": 1.41,
"date": 2.0
}
}
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
autoReload="true">
<targets async="true">
<target xsi:type="File"
name="file"
fileName="${basedir}/Log/service-${shortdate}.log"
layout="${longdate} | ${level:uppercase=true} | ${logger} | ${message}${onexception:${newline}${exception:format=tostring}}" />
<target xsi:type="Console"
name="console"
layout="${longdate} | ${level:uppercase=true} | ${logger} | ${message}" />
</targets>
<rules>
<logger name="*" minlevel="Debug" writeTo="file,console" />
</rules>
</nlog>

View File

@@ -23,19 +23,20 @@
"Annotations": [ "Annotations": [
{ {
"Name": "Signature", "Name": "Signature",
"MarginTop": 0 "MarginTop": 0,
"MarginLeft": 0.20
}, },
{ {
"Name": "PositionLabel", "Name": "PositionLabel",
"VerBoundAnnotName": "Signature", "VerBoundAnnotName": "Signature",
"WidthRatio": 1.2, "WidthRatio": 1.2,
"HeightRatio": 0.5, "HeightRatio": 0.5,
"MarginTopRatio": 0.22 "MarginTopRatio": 0.30
}, },
{ {
"Name": "Position", "Name": "Position",
"VerBoundAnnotName": "PositionLabel", "VerBoundAnnotName": "PositionLabel",
"WidthRatio": 1.2, "WidthRatio": 1.4,
"HeightRatio": 0.5, "HeightRatio": 0.5,
"MarginTopRatio": -0.05 "MarginTopRatio": -0.05
}, },
@@ -49,7 +50,7 @@
{ {
"Name": "City", "Name": "City",
"VerBoundAnnotName": "CityLabel", "VerBoundAnnotName": "CityLabel",
"WidthRatio": 1.2, "WidthRatio": 1.4,
"HeightRatio": 0.5, "HeightRatio": 0.5,
"MarginTopRatio": -0.05 "MarginTopRatio": -0.05
}, },

View File

@@ -11,13 +11,16 @@ async function createAnnotations(document, envelopeId, receiverId) {
//#region signatures //#region signatures
const id = generateId(envelopeId, receiverId, element.id, 'signature'); const id = generateId(envelopeId, receiverId, element.id, 'signature');
const boundingBox = new PSPDFKit.Geometry.Rect(annotParams.signature);
const annotation = new PSPDFKit.Annotations.WidgetAnnotation({ const annotation = new PSPDFKit.Annotations.WidgetAnnotation({
id: id, id: id,
pageIndex: page, pageIndex: page,
formFieldName: id, formFieldName: id,
backgroundColor: PSPDFKit.Color.LIGHT_YELLOW, backgroundColor: null,
blendMode: 'normal', blendMode: 'normal',
boundingBox: new PSPDFKit.Geometry.Rect(annotParams.signature), boundingBox: boundingBox,
}) })
const formField = new PSPDFKit.FormFields.SignatureFormField({ const formField = new PSPDFKit.FormFields.SignatureFormField({
@@ -237,7 +240,7 @@ async function createAnnotationFrameBlob(receiverName, receiverSignature, timest
Comp.SignatureProgress.SignedCount += 1; Comp.SignatureProgress.SignedCount += 1;
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
const scale = 4 const scale = 4
const fontSize = 10 const fontSize = 8
canvas.width = width * scale canvas.width = width * scale
canvas.height = height * scale canvas.height = height * scale
@@ -276,15 +279,11 @@ async function createAnnotationFrameBlob(receiverName, receiverSignature, timest
ctx.fillStyle = 'black' ctx.fillStyle = 'black'
ctx.font = `${fontSize * scale}px sans-serif` ctx.font = `${fontSize * scale}px sans-serif`
ctx.fillText((READ_AND_CONFIRM ? localized.confirmedBy : localized.signedBy) ?? 'Signed by', 15 * scale, 10 * scale) ctx.fillText((READ_AND_CONFIRM ? localized.confirmedBy : localized.signedBy) ?? 'Signed by', 15 * scale, 10 * scale)
ctx.fillText(receiverName, 15 * scale, 60 * scale) ctx.fillText(receiverName, 15 * scale, 58.5 * scale)
ctx.fillText(signatureString, 15 * scale, 70 * scale) ctx.fillText(signatureString, 15 * scale, 69 * scale)
return new Promise((resolve) => { const blob = await new Promise((resolve) => canvas.toBlob(resolve))
canvas.toBlob((blob) => { return URL.createObjectURL(blob)
const url = URL.createObjectURL(blob)
resolve(url)
})
})
} }
//required //required

View File

@@ -37,10 +37,15 @@ class App {
}); });
// Load annotations into PSPDFKit // Load annotations into PSPDFKit
this.signatureWidgets = [] // { pageIndex, boundingBox, id }
if (!READ_AND_CONFIRM) if (!READ_AND_CONFIRM)
try { try {
let signatures = await createAnnotations(this.currentDocument, this.envelopeReceiver.envelopeId, this.envelopeReceiver.receiverId); let signatures = await createAnnotations(this.currentDocument, this.envelopeReceiver.envelopeId, this.envelopeReceiver.receiverId);
await this.pdfKit.create(signatures); await this.pdfKit.create(signatures);
// Cache signature widget bounding boxes for stable frame placement
this.signatureWidgets = signatures
.filter(s => s instanceof PSPDFKit.Annotations.WidgetAnnotation && s.formFieldName?.includes('signature'))
.map(s => ({ id: s.id, pageIndex: s.pageIndex, boundingBox: s.boundingBox.toJS() }))
} catch (e) { } catch (e) {
console.error("Error loading annotations:", e); console.error("Error loading annotations:", e);
} }
@@ -66,8 +71,27 @@ class App {
if (isFormField === false && isSignature === true) { if (isFormField === false && isSignature === true) {
const left = annotation.boundingBox.left - 20 // Find the nearest signature widget on the same page to use its stable bounding box
const top = annotation.boundingBox.top - 20 const inkBBox = annotation.boundingBox
const samePageWidgets = this.signatureWidgets.filter(w => w.pageIndex === annotation.pageIndex)
const nearestWidget = samePageWidgets.reduce((nearest, w) => {
const dx = w.boundingBox.left - inkBBox.left
const dy = w.boundingBox.top - inkBBox.top
const dist = Math.sqrt(dx * dx + dy * dy)
return dist < nearest.dist ? { dist, widget: w } : nearest
}, { dist: Infinity, widget: null }).widget
// 1. Remove widget background after signing
if (nearestWidget) {
const pageAnnots = await this.pdfKit.getAnnotations(annotation.pageIndex)
const widgetAnnot = pageAnnots.find(a => a.id === nearestWidget.id)
if (widgetAnnot)
await this.pdfKit.update(widgetAnnot.set('backgroundColor', PSPDFKit.Color.TRANSPARENT))
}
const bbox = nearestWidget?.boundingBox ?? inkBBox
const left = bbox.left - 20
const top = bbox.top - 12
const width = 150 const width = 150
const height = 75 const height = 75
const timestamp = new Date() const timestamp = new Date()
@@ -330,7 +354,7 @@ class App {
} }
async handleReset(event) { async handleReset(event) {
const result = Swal.fire({ const result = await Swal.fire({
title: localized.resetConfirmTitle, title: localized.resetConfirmTitle,
text: localized.resetConfirmText, text: localized.resetConfirmText,
icon: 'question', icon: 'question',
@@ -338,7 +362,24 @@ class App {
}) })
if (result.isConfirmed) { if (result.isConfirmed) {
const result = await deleteAnnotations(this.pdfKit) await deleteAnnotations(this.pdfKit)
// Reset editable text form field values (Position, City)
const allFormFields = await this.pdfKit.getFormFields()
const editableFields = allFormFields.filter(f =>
f instanceof PSPDFKit.FormFields.TextFormField && !f.readOnly
)
for (const field of editableFields) {
await this.pdfKit.setFormFieldValues({ [field.name]: '' })
}
// Restore widget backgrounds that were made transparent after signing
for (const w of this.signatureWidgets) {
const pageAnnots = await this.pdfKit.getAnnotations(w.pageIndex)
const widgetAnnot = pageAnnots.find(a => a.id === w.id)
if (widgetAnnot)
await this.pdfKit.update(widgetAnnot.set('backgroundColor', null))
}
} }
return result return result

Some files were not shown because too many files have changed in this diff Show More