Compare commits

..

108 Commits

Author SHA1 Message Date
04cea1ca4b Refactor to use streams for PDF handling
Refactored `IPdfDigitalSignatureService` to replace `byte[]` with `Stream` for input and output, improving memory efficiency and scalability. Updated `PdfDigitalSignatureService` implementation to support stream-based operations, including signing, checking, and extracting PDFs.

Modified `SignPdfAsync` to handle multiple signatures sequentially using streams and ensured proper disposal of intermediate streams. Updated `CheckPdfSignatureAsync` and `ExtractOriginalPdfAsync` to use streams, reducing memory overhead.

Refactored `PdfSignatureController` to use streams for file handling, enabling efficient processing of large files. Introduced helper methods for stream handling and enhanced cancellation support. Improved code maintainability by consolidating common logic and reducing duplication.
2026-10-15 11:04:05 +02:00
29108594d9 Update project version to 1.5.0-beta
Updated the `EnvelopeGenerator.Server` project version from `1.4.0-beta` to `1.5.0-beta`.
This includes updates to the file version (from `1.4.0.0` to `1.5.0.0`)
and the assembly version (from `1.4.0.0` to `1.5.0.0`).
2026-10-14 15:11:19 +02:00
51c0f51738 Refactor contact info handling in PDF signing
Replaced the `ContactInfo` property with separate `Email` and `Phone` fields in `PdfSignOptionsDto` and `PdfSignatureCheckItemDto` for better granularity. Updated the `PdfDigitalSignatureService` to handle the new fields, introducing `BuildContactInfo` and `ParseContactInfo` methods for constructing and parsing contact information.

Modified the `PdfSignatureController` to reflect these changes, updating request models and the `SignPdfAsync` method. Updated the `README.md` to document the new structure. These changes improve the clarity and structure of contact information handling.
2026-10-14 15:10:55 +02:00
816bbce9c6 Remove client-side SigningTime handling
The `SigningTime` property has been removed from the `PdfSignOptionsDto` class and related client-side input handling. It is now set automatically on the server side based on the `UseUtcSigningTime` configuration (`UtcNow` or local time).

Updated the `PdfDigitalSignatureService` to always set `SigningTime` server-side, ensuring consistent and deterministic behavior. Removed `SigningTime` from the `PdfSignatureController` and adjusted the logic to exclude it from the `signOptions` object.

Updated the `README.md` to reflect these changes, clarifying that `SigningTime` is now server-side only. These changes improve security and simplify client integration.
2026-10-14 14:49:15 +02:00
5194d638ac Refactor README.md with localized and detailed guidance
The README.md has been extensively restructured to improve clarity, technical depth, and usability. Key changes include:

- Shifted most sections from English to German for localization.
- Consolidated distributed runbooks into a unified reference for PKCS#11/SoftHSM integration.
- Expanded architecture overview with deployment patterns and runtime paths.
- Replaced PKCS#11 basics with detailed setup instructions and troubleshooting tips.
- Updated runtime configuration examples with security best practices.
- Highlighted implemented services, models, and DTOs in the DI setup.
- Added sections on operational challenges, troubleshooting, and monitoring.
- Introduced an API health endpoint and smoke test recommendations.
- Clarified API behavior for signature metadata to ensure deterministic integration.
- Expanded security guidelines, including key rotation and SSH rollback procedures.
- Added a forward-looking section on potential improvements.
- Retained and updated references to official documentation and tools.

These changes aim to provide a comprehensive, structured, and production-aligned guide for developers and engineers.
2026-10-14 14:47:40 +02:00
6b3622b366 Remove test endpoint from PdfSignatureController
Removed the `[HttpGet("sign/test")]` endpoint and its `Test`
method from the `PdfSignatureController`. This method
contained hardcoded Base64 PDF data and predefined signing
options for testing purposes. Eliminated the associated
logic for decoding the Base64 string, creating signing
options, signing the PDF, and returning the signed file.

These changes streamline the controller by removing
unnecessary and potentially insecure functionality,
ensuring cleaner and more production-ready code.
2026-10-14 14:35:58 +02:00
4f8fa9afd8 Rename DefaultApplicationName to ApplicationName
Updated the `Pkcs11Options` class to rename the `DefaultApplicationName` property to `ApplicationName`, clarifying its role as a globally controlled value embedded in PDF signature metadata.

Modified `PdfDigitalSignatureService` to source `ApplicationName` directly from `Pkcs11Options`, removing client-level overrides. Updated `PdfSignatureController` to reflect this change by removing the `ApplicationName` property from `SignOptions`.

Updated `appsettings.json` and `README.md` to reflect the property name change. These changes enforce stricter configuration control, improving metadata consistency and security.
2026-10-14 14:34:59 +02:00
7a36316b7e Standardize CertificationLevel in PDF signing
The `CertificationLevel` property was removed from the `PdfSignatureOptions` class. It is now explicitly set to `PdfSignatureCertificationLevel.FormFillingAndAnnotations` in all instances where `PdfSignatureOptions` is instantiated.

Previously, `CertificationLevel` was nullable and dynamically set from the `request` object or defaulted to `PdfSignatureCertificationLevel.NoCertification`. These changes ensure consistent behavior by standardizing the certification level to `FormFillingAndAnnotations`.
2026-10-14 14:31:54 +02:00
68d9dbdcdf Add UUID and metadata support for PDF signing
Enhanced PDF signing with UUID and metadata handling:
- Added `UUID` and `OriginalDocumentSha256` to `PdfSignOptionsDto` and `PdfSignatureCheckItemDto`.
- Introduced `BusinessReason` to separate metadata from signature reasons.
- Updated `PdfDigitalSignatureService` to embed and parse metadata in signature reasons.
- Improved error handling to include metadata in validation results.
- Updated `PdfSignatureController` to handle `UUID` in signing requests.

Refactored PKCS#11 configuration:
- Replaced `LibraryPath` with `AssetsRootPath` for asset resolution.
- Added `ProxySocket` and `DefaultApplicationName` options.
- Clarified runtime behavior for asset resolution in `README.md`.

Updated documentation and examples to reflect these changes.
2026-10-14 14:24:51 +02:00
7f3d1afcb9 Add SoftHSM SSH and SFTP helper scripts 2026-10-14 13:49:56 +02:00
955ac57a1a Add original revision extraction for signed PDFs 2026-10-14 13:49:46 +02:00
b8a96330c0 Add PKCS#11 startup self-test health reporting 2026-10-14 13:49:38 +02:00
8f2744afc7 add soft hsm lib 2026-10-14 13:11:23 +02:00
c8ddfac8d0 Improve PDF signing and validation logic
Updated `.gitignore` to exclude new files and directories, including `FodyWeavers.xsd` and `appsettings.Secrets.json`.

Refactored `PdfDigitalSignatureService`:
- Improved signing time handling by calculating fallback inline.
- Simplified `SignPdfAsync` by inlining signature building.
- Added `ParseByteRanges` to extract `/ByteRange` values from PDFs.
- Introduced `CoversWholeDocument` to validate document coverage.

Enhanced signature validation:
- Integrated `ParseByteRanges` and `CoversWholeDocument`.
- Streamlined `PdfSignatureCheckItemDto` construction.
- Updated properties to calculate values inline.
- Improved `Unchanged` property to check cryptographic validity and document coverage.

These changes enhance maintainability, readability, and functionality.
2026-10-13 18:52:28 +02:00
1942d78155 Add cryptographic infrastructure and configuration
Integrated cryptographic functionality by adding a reference to `EnvelopeGenerator.Infrastructure.Crypt.csproj`. Updated `Program.cs` to include dependency injection for cryptographic services and load `appsettings.Secrets.json` for sensitive configurations. Added a new `Crypt` section in `appsettings.json` to configure PKCS#11 cryptographic operations, enhancing security and signing capabilities.
2026-10-13 14:53:49 +02:00
f361898ba4 Add PKCS#11-based PDF signing infrastructure
Introduced a cryptographic infrastructure for PDF signing and validation using PKCS#11-based hardware security modules (HSMs) or software tokens. Key changes include:

- Added `PdfDigitalSignatureService` for signing PDFs, multi-signature support, and signature validation.
- Implemented `Pkcs11ExternalSigner` and `Pkcs11PdfSigningRsa` for PKCS#11-based signing with DevExpress PDF infrastructure.
- Added utilities like `Pkcs11SessionRunner`, `Pkcs11InteropBootstrap`, and `Pkcs11ToolBridge` for session management, library initialization, and fallback mechanisms.
- Introduced `Pkcs11Options` for managing PKCS#11 configurations.
- Updated project dependencies to include `DevExpress.Document.Processor`, `System.Security.Cryptography.Pkcs`, and `Pkcs11Interop`.
- Enhanced error handling and fallback support for PKCS#11 operations.

These changes enable robust and extensible PDF signing capabilities with detailed signature metadata extraction and validation.
2026-10-13 14:45:47 +02:00
7766e2ea25 Add PKCS#11 support for PDF signing and validation
Introduced `Pkcs11Options` for configuring PKCS#11 signing workflows, including properties for library path, token selection, and signing metadata. Added `PdfSignOptionsDto` to represent signing options and `PdfSignatureCheckResultDto` with related DTOs for signature validation.

Defined `PdfSignatureCertificationLevel` enum for certification levels. Added `IPdfDigitalSignatureService` interface to support signing and validating PDF signatures. These changes enable robust and extensible handling of digital signatures.
2026-10-13 14:44:06 +02:00
63c60a91b2 Add multi-targeting support for net7.0 and net8.0
Updated the project to support both net7.0 and net8.0 frameworks.
Modified `<TargetFramework>` to `<TargetFrameworks>` and added
conditional `<ItemGroup>` entries for dependencies and project
references specific to each framework. Updated package versions
(e.g., Microsoft.Extensions.DependencyInjection to 9.0.6) and
introduced new dependencies for net8.0, including
Microsoft.EntityFrameworkCore 8.0.17. Added a `<Compile Remove>`
directive for net7.0 to exclude Crypt-related files. Ensured
compatibility and maintained backward support for net7.0.
2026-10-08 10:33:11 +02:00
a5d2c477f6 Add EnvelopeGenerator.Infrastructure.Crypt project
A new project, `EnvelopeGenerator.Infrastructure.Crypt`, has been added to the solution. This project targets `.NET 8.0` and includes several `Microsoft.Extensions` dependencies (e.g., `Configuration.Binder`, `DependencyInjection.Abstractions`, etc.) and the `Pkcs11Interop` package.

The solution file (`EnvelopeGenerator.sln`) has been updated to include the new project, its build configurations, and its nesting under the same parent as `EnvelopeGenerator.Infrastructure.Doc`.
2026-10-08 10:32:48 +02:00
3a71d57619 Enhance FES docs, plans, and SoftHSM integration
Updated README.md for clarity and added official references. Introduced detailed infrastructure and integration plans for FES, including phased implementation, deployment options, and risk mitigations. Added FES workflow diagrams in draw.io and PDF formats. Documented SoftHSM testing process and integration details in ticket.md. Improved alignment with clean architecture principles and production readiness.
2026-10-08 10:31:00 +02:00
73b6c2712c Update ResourceManager references in translation functions
Updated the `TranslateCertificationType` and `TranslateFinalEmailType` functions to use the `ResourceManager` from the `CommonServices.My.Resources.Model` namespace instead of the `Resources.Model` namespace for consistency and improved resource management.
2026-10-08 10:16:22 +02:00
200972c370 Update README with PKCS#11 integration details
Added a detailed README for the `EnvelopeGenerator.Infrastructure.Crypt` project, explaining its purpose, functionality, and integration with PKCS#11-based cryptographic workflows.

Highlights include:
- Overview of the project's role in FES workflows.
- Explanation of SoftHSM and PKCS#11 deployment patterns.
- Mapping of functionality to FES diagram steps 6-8.
- Documentation of service interfaces and DI entry point.
- Configuration guidance with JSON examples and security best practices.
- Instructions for obtaining PKCS#11 values and setting up SoftHSM.
- Checklist for IT handover and environment setup.
- Steps for running cryptographic tests with PowerShell and Linux examples.
- Common failure diagnostics and their meanings.
- Clarification of signing semantics and validation requirements.
- FAQ addressing common questions about SoftHSM and PKCS#11 usage.

This update ensures clear guidance for developers and IT teams to configure and integrate the cryptographic layer effectively.
2026-10-07 13:35:50 +02:00
9f7519941a Improve bootstrap handling in SenderEditorPage
Added a `DxLoadingPanel` to display a loading indicator during the bootstrap process. Introduced the `IsBootstrapLoading` property to track bootstrap progress. Refactored `OnParametersSetAsync` to handle bootstrap tokens more effectively, including redirecting to `/sender` if no token is provided. Updated `TryApplyBootstrapAsync` to adjust navigation behavior when the bootstrap payload is `null`.

Enhanced `OnAfterRenderAsync` to process `_pendingBootstrapToken` more cleanly and removed redundant logic. Simplified navigation and improved code readability and maintainability.
2026-10-06 13:59:39 +02:00
66e58b4a1a Add envelope settings step to wizard
Introduced a new step (step 2) in the `CreateEnvelopeWizardPopup` for configuring envelope settings, including security, reminders, and expiration options.

- Added fields for certification type, final email options, 2FA, access codes, and reminder settings.
- Updated step titles, descriptions, and total steps from 4 to 5.
- Implemented validation logic for the new settings step.
- Added methods to handle changes in settings fields and apply defaults.
- Updated `CreateEnvelopeWizardResult` and `CreateEnvelopeBootstrapPayload` to include new fields.
- Modified `SenderEditorPage` and `SenderPage` to handle new settings.
- Styled the new step with updated CSS for grids, groups, and toggles.
- Refactored state reset logic to initialize new fields.
2026-10-06 12:59:55 +02:00
de9d8c6f37 Add document preview feature to wizard popup
Introduced a document preview feature in the `CreateEnvelopeWizardPopup` component. This includes:

- Injected `PdfRenderService` for rendering PDF pages.
- Replaced document metadata display with a preview card.
- Added `SenderPdfImageEditor` for interactive PDF previews.
- Implemented state management for preview pages and overlays.
- Added fallback logic to estimate PDF page count.
- Enhanced `ResetWizardState` to reset preview-related fields.
- Styled the preview card and wrap with new CSS classes.
- Added placeholder event handlers for editor interactions.
2026-10-06 11:48:03 +02:00
47f48ba12a Add PdfFileDropZone for interactive PDF uploads
Replaced static upload message in `CreateEnvelopeWizardPopup.razor` with a new `PdfFileDropZone` component to enhance user experience. The component supports drag-and-drop functionality, file selection, and dynamic states (e.g., disabled, drag-active).

Introduced `PdfFileDropZone.razor` with customizable parameters such as `Disabled`, `HasDocument`, `UploadText`, and `DragDropHintText`. Added event handling for drag-and-drop interactions and file selection.

Updated `sender-page.css` to include styles for the new component, ensuring seamless integration with the existing layout. Adjusted styles for hover effects, drag-active state, and disabled state.

These changes improve usability and make PDF uploads more intuitive.
2026-10-06 10:31:43 +02:00
60241c9c6d Refactor receiver suggestions filtering logic
Introduced `ReceiverSuggestionFilterHandler` to refine the
filtering of recent receiver suggestions based on name and
email. Added `SuggestionLimit` constant to replace hardcoded
limits and ensure consistency. Updated UI to use the new
`FilteredRecentSuggestions` property, which leverages the
`BuildFilteredRecentSuggestions` method for grouping,
ordering, and limiting suggestions. Adjusted
`OnDocumentSelectedAsync` to use the new constant. Added
necessary `@using` directive for the filter handler.
2026-10-06 09:26:43 +02:00
2a939b4cad Add filtering logic for receiver suggestions
Introduce `ReceiverFilterCriteria` to encapsulate filtering criteria for receivers, including normalization of `FullName` and `EmailAddress`. Add `ReceiverSuggestionFilterHandler` to filter `RecentReceiverSuggestionDto` objects based on the criteria. Includes methods for matching criteria and filtering suggestions.
2026-10-06 09:26:22 +02:00
e89d471c6f Refactor receiver input and improve UI/UX
Replaced receiver list with a tag-based `DxTagBox` for better flexibility and dynamic updates. Updated receiver management logic to synchronize `_receivers` and `_receiverTags`. Enhanced accessibility with ARIA attributes for the suggestion list.

Redesigned the "Add Receiver" button with custom styles, hover effects, and an inline SVG icon. Improved the suggestion list with horizontal scrolling and nowrap styling. Styled tags and added a remove button for each tag.

Increased recent receiver suggestions fetch limit to 20. Added validation to ensure default envelope type selection. Refactored empty state handling and cleaned up redundant code for better maintainability.
2026-10-05 21:58:51 +02:00
ece676fbcf Make Take optional and refactor query handling logic
Updated the `Take` property in `ReadRecentReceiverSuggestionsQuery` to be nullable, allowing for unlimited results when not specified. Refactored the `Handle` method in `ReadRecentReceiverSuggestionsQueryHandler` to conditionally apply the `Take` limit only when provided. Renamed the cancellation token parameter for consistency. Improved code clarity and flexibility by removing redundant operations and dynamically applying the `Take` logic.
2026-10-05 17:06:47 +02:00
fac4ec28a4 Add localization and UI updates for receiver actions
Added new localized strings for "Recently used recipients" and
"Skip" in German, English, and French resource files. Updated
the `CreateEnvelopeWizardPopup.razor` to enhance the "Add
Receiver" button with a primary render style and a new CSS
class. Introduced styles for the button in `sender-page.css`
to improve its appearance with a minimum width, bold font,
and box shadow.
2026-10-05 17:02:12 +02:00
a3b11ed63f Add recent receiver suggestions to wizard
Introduced a feature to display and apply recent receiver
suggestions in the `CreateEnvelopeWizardPopup` component.
Added a new section in step 2 to show recently used receivers,
with loading and state management logic. Updated the
`ReceiverService` to fetch suggestions from the backend API.

Enhanced the UI with new styles for the suggestions section
and improved localization support. Refactored state
management to handle loading, resetting, and applying
suggestions. Minor cleanup and adjustments to ensure proper
state transitions.
2026-10-05 16:53:57 +02:00
5627483589 Make RecentReceiverSuggestionDto immutable; refactor query
Updated RecentReceiverSuggestionDto to use `init` properties,
adding immutability and a new `LastUsedAt` field. Refactored
ReadRecentReceiverSuggestionsQueryHandler to simplify query
logic, remove `IMapper` dependency, and improve determinism
in results. Introduced tie-breaking for duplicate timestamps
and optimized ordering by `LastUsedAt` and `EmailAddress`.
Removed redundant `Include` and mapping steps for clarity.
2026-10-05 16:43:34 +02:00
bf23e88cf8 Add plan for recent receiver suggestions feature
Added a detailed plan document outlining the development of a "recent receiver suggestions" feature for step 3 of the sender wizard. The plan defines team working rules, objectives, functional requirements, and architecture direction across application, server, and client layers.

The implementation is divided into six phases, covering MediatR query creation, API endpoint addition, client integration, UI updates, regression testing, and technical debt tracking. Each phase includes black-box test suggestions to ensure quality and robustness. The plan emphasizes incremental development, user approval, and quality-focused processes.
2026-10-05 15:58:58 +02:00
9435561e78 Add recent receiver suggestions query and DTO
Introduce `RecentReceiverSuggestionDto` to represent recent receiver suggestions with `FullName` and `EmailAddress`.

Add `ReadRecentReceiverSuggestionsQuery` to retrieve recent receiver suggestions scoped by sender username and limited to 3-5 results.

Implement `ReadRecentReceiverSuggestionsQueryHandler` to process the query using Entity Framework Core and AutoMapper.

Update `MappingProfile` to map `EnvelopeReceiver` to `RecentReceiverSuggestionDto`.
2026-10-05 15:55:19 +02:00
db1e91a3f6 Set C# language version to latest in project file
Added the `<LangVersion>` property to `EnvelopeGenerator.Application.csproj` with the value `latest`. This ensures the project uses the most up-to-date C# language features supported by the compiler, enabling access to new enhancements and features as they become available.
2026-10-05 15:32:41 +02:00
0c664c0995 Improve UX, security, and maintainability
- Introduced a DevExpress toast-based notification system using `DxToastProvider` and `IToastNotificationService` for consistent feedback.
- Standardized request-to-domain mapping with AutoMapper/profile conventions or reflection-based mappers, enforced at the Core library level.
- Removed dynamic SQL string composition and migrated to parameterized queries to address SQL injection vulnerabilities. Added CI guardrails to prevent reintroduction.
- Refined UX on `EnvelopeSenderPage.razor` to enforce deterministic row double-click behavior and align row action buttons/tooltips.
- Enabled SPA-style language switching for immediate UI culture updates without page reloads.
- Added sender-scoped caching for "recent receiver suggestions" with short TTL, invalidation hooks, and cross-sender isolation.
2026-10-05 15:20:58 +02:00
5db2f6f1e3 Refactor envelope type selection logic
Removed the placeholder option from the envelope type dropdown to simplify the UI. Introduced a new `GetDefaultEnvelopeTypeId()` method to centralize and enhance the logic for determining the default envelope type. This method prioritizes envelope types with titles containing "Handwritten Signature" if available, improving alignment with user preferences. Updated `_selectedEnvelopeTypeId` initialization to use the new method for better maintainability.
2026-10-05 15:04:09 +02:00
a535e20b0e Add "Next" button translations to localization files
Added a new resource entry for the "Next" button in the
localization files for German (de-DE), English (en-US), and
French (fr-FR). Translations include "Weiter" in German,
"Next" in English, and "Suivant" in French.
2026-10-05 15:01:15 +02:00
bb35a68b1f feat(sender-editor): consume bootstrap payload and auto-save draft
Accept bootstrapToken query parameter and consume one-time payload from sessionStorage-backed service.

Hydrate editor state with title, message, envelope type, receivers, and PDF document from bootstrap data.

Refactor draft save into reusable core flow and trigger non-navigating auto-save after bootstrap is applied.

Prevent stale token replay by clearing query token from URL and handling invalid tokens gracefully.
2026-10-05 14:54:06 +02:00
715add7f51 feat(sender): add create-envelope wizard with bootstrap handoff
Introduce a 4-step modal wizard on SenderPage for creating envelopes.

Validate required fields, support optional skip, receiver management, PDF upload, and keyboard shortcuts.

Persist wizard payload in sessionStorage via token-based bootstrap service and navigate to editor with bootstrapToken.

Register bootstrap storage service in both client and server DI containers and add dedicated sender wizard styling.
2026-10-05 14:53:48 +02:00
621e22701a Add markdown audit and update architecture docs
Added a "Markdown Audit Snapshot" section to document the status, gaps, and recommendations for markdown files in the repository. Included a "Documentation Audit TODO" checklist for tracking updates. Updated the "Active Architecture (Post-Migration)" section to reflect the current Blazor Auto (Server+WASM hybrid) setup. Identified and marked outdated or redundant markdown files for removal or archival. Highlighted the need for periodic synchronization of documentation with implementation progress.
2026-10-05 09:57:56 +02:00
5ef32a2b0c Adjust SenderEditor UI defaults and accordion styling
Changed the `DxAccordionItem` in `SenderEditorPage.razor` to be collapsed by default. Removed unused elements (`sender-settings-chip` and `sender-settings-grid`) from the accordion's content template.

Updated `envelope-viewer.css` to refine the accordion header's appearance: reduced font size, font weight, and padding, and added a `line-height` property for better spacing.
2026-10-02 15:23:57 +02:00
54d16eb3b8 Refactor SenderEditorPage for modernized UI design
Replaced inline styles with reusable CSS classes to improve maintainability, readability, and scalability. Updated the layout and styling of key components, including the action bar, title and message inputs, PDF metadata display, and buttons. Introduced a `DxAccordion` for envelope settings and redesigned the empty state and save result dialog. Added dynamic styling using CSS variables and centralized styles in `envelope-viewer.css`. Removed redundant inline styles and improved overall UI consistency.
2026-10-02 14:59:43 +02:00
ae3264253d Update ILogger type in SenderPage.razor
Changed the `ILogger` dependency injection type parameter from `EnvelopeSenderPage` to `SenderPage` to ensure alignment with the correct class name or intended logging context.
2026-10-02 14:49:38 +02:00
4cbfe52d5b Add localized strings and update localization logic
Added new localized strings to `Model.en.resx`, `Model.fr-FR.resx`,
and `Model.resx` for additional features/settings in English,
French, and German. Updated `SenderEditorPage.razor` to inject
`ModelLocalizer` and modified the `L` function to prioritize
localized strings from `ModelLocalizer` over `UiLocalizer`.
Included `@using EnvelopeGenerator.Domain.Resources` to ensure
access to the `Model` resource file. These changes improve
localization flexibility and maintainability.
2026-10-02 14:46:40 +02:00
27c6b017cd refactor (EnvelopeSenderPage): Rename to SenderPage.razor. 2026-10-02 14:30:11 +02:00
8378c33465 Refactor PDF rendering, UX, and security improvements
Refactored `PdfRenderController.cs` to replace JSON/base64 `byte[]` transport with binary streaming for PDF rendering, improving memory and latency performance for large files.

Introduced a DevExpress toast-based notification system using `DxToastProvider` and `IToastNotificationService` for consistent, actionable feedback.

Standardized request-to-domain mapping with AutoMapper/profile conventions or reflection-based mappers, enforced at the Core library level to reduce drift.

Addressed SQL injection risks by replacing dynamic SQL string composition with parameterized queries, conducted a repo-wide audit, and added CI guardrails.

Improved UX in `EnvelopeSenderPage.razor` by enforcing deterministic row double-click behavior and aligning row action buttons/tooltips.

Implemented SPA-style language switching for immediate UI culture updates without page reloads.

Updated the SQL Server connection string in `appsettings.json`.
2026-10-02 14:04:45 +02:00
c7c4e1194d Force full page reload on language change
Updated the `Navigation.NavigateTo` method in `LanguageControls.razor` to set the `forceLoad` parameter to `true`. This ensures that the application performs a full page reload when the language is changed, improving consistency in language updates.
2026-10-02 13:59:40 +02:00
3b367eff7f Remove EnvelopeSenderEditorPage.razor entirely
The `EnvelopeSenderEditorPage.razor` file was completely removed, including all Razor markup, Blazor components, JavaScript references, CSS styles, and C# code-behind logic.

Key changes:
- Removed the `/sender/editor_legacy` route and all associated imports (`@using`) and dependency injections (`@inject`).
- Deleted the entire HTML structure, including UI elements for PDF editing, signature placement, and receiver management.
- Removed all references to external CSS and JavaScript files.
- Deleted Blazor components like `DxPdfViewer`, `DxPopup`, and `DxTextBox`.
- Removed C# code for state management, lifecycle methods, event handlers, and helper methods.
- Eliminated localization, validation logic, session persistence, and cache management.
- Removed PDF manipulation logic (e.g., placeholder rendering with `PdfSharp`).
- Deleted receiver management logic, including email suggestions and validation.

This change removes the entire functionality of the `EnvelopeSenderEditorPage`, including its UI, backend logic, and external integrations.
2026-10-02 13:39:00 +02:00
d0c3d79fbc Refactor DI, PDF rendering, and improve security/UX
Centralized dependency injection registrations into dedicated
extension methods/modules to reduce startup composition sprawl
and improve maintainability.

Refactored `PdfRenderController.cs` and client service contract
to replace JSON/base64 `byte[]` transport with binary streaming
(`application/octet-stream` or multipart) for improved memory
and latency performance with large PDFs.

Introduced a DevExpress toast-based notification system using
`DxToastProvider` and `IToastNotificationService` for consistent,
actionable feedback across sender/receiver flows.

Standardized request-to-domain mapping with AutoMapper/profile
conventions or reflection/source-generator-based mappers to
eliminate manual mapping drift and enforce consistency across
services.

Addressed SQL injection vulnerabilities by replacing dynamic SQL
string composition with parameterized queries. Conducted a repo-
wide audit and added CI guardrails to prevent reintroduction.

Refined UX for `EnvelopeSenderPage.razor` to enforce deterministic
row double-click behavior (preview OR edit) and aligned row action
buttons/tooltips to eliminate ambiguous navigation.
2026-10-02 13:32:21 +02:00
e81c7896a1 Refactor services, DI, and improve security
- Removed unnecessary request/payload remapping in `Services/` and ensured direct request DTO usage where safe.
- Centralized DI registrations into extension methods/modules to reduce startup sprawl and improve maintainability.
- Refactored `PdfRenderController.cs` to replace JSON/base64 `byte[]` transport with binary streaming for better memory/latency performance.
- Implemented a DevExpress toast-based notification system with `DxToastProvider` and `IToastNotificationService` for consistent feedback flows.
- Standardized request-to-domain mapping using AutoMapper/profile conventions or source-generator-based mappers to eliminate drift.
- Addressed SQL injection risks by replacing dynamic SQL string composition with parameterized queries, conducted a repo-wide audit, and added CI guardrails.
2026-10-02 13:27:09 +02:00
127e2b253e Standardize request-to-domain mapping policies
Introduced a standardized mapping policy to eliminate manual
property-by-property mapping drift (e.g., in
`CreateEnvelopeCommandHandler`). This policy may leverage
AutoMapper with profile conventions or a reflection/source-
generator-based approach and will be enforced across envelope
workflows. The implementation will occur at the Core library
level to ensure consistent guardrails for all services.

Removed the instruction to manually audit redundant mappings
in `EnvelopeGenerator.Server.Client/Services/`, aligning with
the shift toward automated mapping standardization.

Updated the manual testing workflow to include the new mapping
policy requirement, emphasizing its role in improving
consistency and maintainability.
2026-10-02 13:05:02 +02:00
6d0b78072e Refactor error handling in DocumentService
Centralized `HttpRequestException` creation with a new
`CreateHttpException` method to improve code reuse and
consistency. Updated methods to handle `HttpStatusCode.NotFound`
gracefully, returning `null` where appropriate instead of
throwing exceptions. Introduced `TryGetSenderDocumentBytesByEnvelopeIdAsync`
to encapsulate fallback logic for fetching document bytes.
Removed redundant inline exception handling and standardized
error handling across methods.
2026-10-02 13:04:29 +02:00
3721ea9a1f Set envelope status and update title/message fields
Updated `UpdateAsync` to include `e.Title`, `e.Message`, and `e.TfaEnabled` assignments, ensuring database updates reflect request values.
Set `e.Status` to `EnvelopeStatus.EnvelopeSaved` during the update process.
Updated in-memory `envelope` object to reflect changes to `Title`, `Message`, `TfaEnabled`, and `Status` fields.
2026-10-02 13:02:51 +02:00
f86588551c Refactor PDF editor UI and improve PDF loading logic
Simplify and restructure conditional logic in `SenderPdfImageEditor.razor` for better readability and maintainability. Merge redundant `else` blocks and reorganize rendering logic for page previews and fields.

Enhance PDF loading and page preview generation in `SenderEditorPage.razor`. Add error handling for failed page rendering, log warnings, and ensure consistent `_pageCount` initialization based on available data.

These changes improve code clarity, robustness, and maintainability.
2026-10-02 11:51:14 +02:00
0eaa9ff08f Add request validation with FluentValidation
Introduced `ValidationBehavior` to validate MediatR requests using FluentValidation. Registered the behavior in the pipeline and added validators for `ReadDocumentDetailsQuery` and `ReadDocumentQuery`. Updated dependency injection to include validators and added FluentValidation NuGet packages. Improved exception handling with `BadRequestException` for validation failures. Refactored namespaces and imports to support the changes.
2026-10-02 10:57:51 +02:00
ec79d7622e Add support for fetching document metadata separately
Introduced a new `ReadDocumentDetailsQuery` to fetch document
metadata (`DocumentDetailsDto`) without the binary payload.
Updated `MappingProfile` to map `Document` to `DocumentDetailsDto`.

Refactored `DocumentService` to handle separate fetching of
metadata and binary data, combining them into `DocumentDto`.
Changed the metadata endpoint to `/api/Document/details`.

Added a new `GetDocumentDetails` endpoint in `DocumentController`
to return metadata without binary data. Updated
`EnvelopeReceiverPageDataService` with a new method
`GetDocumentMetadataOfSenderAsync` for fetching metadata.

These changes improve efficiency by allowing clients to load
metadata without transferring large binary data when unnecessary.
2026-10-02 10:56:47 +02:00
f558d03bdf Refactor DocumentDto to use DocumentDetailsDto
Introduce a new `DocumentDetailsDto` record to encapsulate shared document metadata and elements without binary payload. Refactor `DocumentDto` to inherit from `DocumentDetailsDto`, eliminating redundant properties and improving code reuse.

Convert `DocumentDto` from a class to a record for immutability. Add a parameterized constructor to `DocumentDto` for initializing binary data alongside metadata. Update `ByteData` to use the `init` accessor.

These changes enhance maintainability, reduce duplication, and enforce immutability by leveraging C# records and inheritance.
2026-10-02 10:55:09 +02:00
db79053c62 Refactor architecture and optimize performance
Applied a modular page architecture to `EnvelopeSenderPage.razor` by splitting large pages into smaller subcomponents, eliminating duplicated code paths, and centralizing shared logic for maintainability.

Conducted a performance review of sender/receiver flows, addressing route transitions, component lifecycle behavior, loading-state correctness, and duplicate API calls. Treated loading logic defects as architectural issues.

Audited client `Services` to remove unnecessary request/payload remapping and simplify HTTP method transformations.

Centralized dependency injection registrations into extension methods/modules to reduce startup composition sprawl.

Refactored `PdfRenderController.cs` to replace JSON/base64 `byte[]` transport with binary streaming for improved memory/latency performance in large PDF rendering.

Implemented a DevExpress toast-based notification system using `DxToastProvider` and `IToastNotificationService` for consistent, actionable feedback across sender/receiver flows.
2026-10-02 10:04:06 +02:00
85dd85aa92 Refactor architecture and optimize PDF rendering
Refactor `EnvelopeSenderPage.razor` to focus on UI composition
and state orchestration, avoiding heavy domain logic. Adopt a
modular page architecture to improve maintainability by splitting
large pages into smaller subcomponents and centralizing shared
logic. Conduct a performance review of sender/receiver flows to
optimize route transitions, component lifecycle behavior, and
loading-state correctness.

Audit `Services` for redundant request/payload remapping and
eliminate unnecessary transformations. Centralize dependency
injection registrations into dedicated extension methods to
reduce startup composition sprawl.

Refactor `PdfRenderController.cs` and its client service contract
to replace JSON/base64 transport for PDF rendering with binary
streaming (`application/octet-stream` or multipart). Add a `TODO`
comment to highlight the need for further optimization of large
PDF handling to reduce payload size and memory pressure.
2026-10-02 09:50:15 +02:00
c7bc9fbcca Refactor EnvelopeType handling and improve validation
Removed AutoMapper dependency in `ReadEnvelopeTypesQueryHandler` and replaced it with manual projection to address null-materialization issues. Updated `EnvelopeType` entity properties to nullable types to align with the database schema. Adjusted computed properties to handle null values gracefully.

Enhanced `SenderEditorPage` with authentication checks, improved handling of reminder and expiry settings, and added validation for user input. Introduced `NormalizeSettings` and `ValidateSettings` methods to ensure consistent and valid configurations. Refactored checkbox value parsing for better reliability.

Injected `AuthService` and `IStringLocalizer` for authentication and localization support. Improved error messaging and localized validation errors. Performed minor code cleanup and refactoring for better readability and maintainability.
2026-10-02 01:25:43 +02:00
1e8e5c96c4 Refactor sender dashboard and improve performance
Moved sender dashboard business/status logic into dedicated
services and extension methods to decouple domain logic from
UI components. Refactored `EnvelopeSenderPage.razor` to focus
on UI composition and state orchestration.

Applied a modular page architecture by splitting large pages
into smaller subcomponents, eliminating duplicated code, and
centralizing shared logic for maintainability.

Conducted a senior-level performance review of sender/receiver
flows, addressing route transitions, loading-state correctness,
and perceived latency issues.

Audited client services for unnecessary request/payload
remapping, removing redundant transformations, and streamlining
HTTP methods. Centralized dependency injection registrations
into extension methods/modules to reduce startup composition
sprawl and improve maintainability.
2026-10-01 16:18:12 +02:00
61d04ca51a Add advanced envelope settings and EnvelopeTypeService
Enhanced `EnvelopeDto` with new properties for advanced settings, including final email options, reminders, and expiration settings. Updated `SenderEditorPage.razor` to include a new UI section for configuring these settings, with dropdowns, checkboxes, and input fields.

Introduced `EnvelopeTypeService` to fetch envelope type data from the backend and applied defaults based on the selected envelope type. Registered the service in `Program.cs` for dependency injection.

Improved lifecycle methods in `SenderEditorPage.razor` to load and apply envelope settings. Enhanced localization, error handling, and data binding for the new settings. Updated `CreateEnvelopeCommand` to include the new properties.

Refactored code for better organization and maintainability, and added logging for debugging envelope type loading issues.
2026-10-01 16:17:34 +02:00
af674a811d Refactor EnvelopeSenderPage and improve architecture
Refactored `EnvelopeSenderPage.razor` to reduce complexity by
moving business and status logic into dedicated services and
extension methods, focusing the component on UI composition
and state orchestration.

Introduced a modular page architecture by splitting large pages
into smaller subcomponents, eliminating duplicated code paths,
and centralizing shared UI/domain logic for better maintainability.

Performed an audit of `EnvelopeGenerator.Server.Client/Services/`
to remove unnecessary request/payload remapping and redundant
transformations, ensuring direct request DTO usage where safe.

Planned a senior-level performance review for sender/receiver
flows to address route transitions, loading-state correctness,
duplicate API calls, and perceived latency issues.

Updated `AGENTS.md` to document manual testing workflow changes
and reflect the refactoring efforts.
2026-10-01 15:53:28 +02:00
123889451f Simplify payload handling in UpsertAsync method
Refactored the `UpsertAsync` method in `EnvelopeService` to remove
manual construction of the HTTP POST payload. The method now
directly uses the `CreateEnvelopeCommand` object as the payload
for the `PostAsJsonAsync` call. This change reduces redundancy,
simplifies the code, and leverages the existing object structure
for better maintainability.
2026-10-01 15:53:10 +02:00
3b5075fe44 Enhance envelope creation and update functionality
Added new optional properties to `CreateEnvelopeCommand` for detailed configuration, including `EnvelopeId`, `Language`, `UseAccessCode`, and expiration settings. Introduced `ResolveEnvelopeSettingsAsync` to centralize settings resolution logic. Updated `CreateEnvelopeCommandHandler` to handle new properties and improve envelope creation/update flow. Added `EnvelopeSettings` record for structured settings management. Enhanced SQL update logic with `UseSQLExecutor` support. Improved handling of language resolution, reminders, and expiration rules.
2026-10-01 15:52:51 +02:00
48302a374f Refactor EnvelopeSenderPage for modularity and performance
Refactored `EnvelopeSenderPage.razor` to reduce complexity by
moving business/status logic into dedicated services and
extension methods. Focused the component on UI composition
and state orchestration, avoiding heavy domain logic in the
Razor file.

Introduced a modular page architecture by splitting large
pages into smaller subcomponents, eliminating duplicated
code paths, and centralizing shared logic for better
maintainability.

Conducted a senior-level performance review for sender/
receiver flows, addressing route transitions, component
mount/unmount behavior, loading-state correctness, duplicate
API calls, and perceived latency. Treated loading logic
defects as architecture/performance issues.
2026-10-01 15:19:13 +02:00
b2c245d6bd Optimize navigation by disabling force page reloads
Updated `Navigation.NavigateTo` calls across multiple files to set `forceLoad` to `false`, preventing unnecessary full page reloads.

- **LanguageControls.razor**: Prevent full reload when switching languages.
- **EnvelopeSenderPage.razor**: Avoid reload during logout navigation.
- **LoginReceiverPage.razor**: Skip reload after successful envelope receiver login.
- **LoginSenderPage.razor**: Prevent reload after successful sender login.

These changes improve performance and enhance user experience by enabling smoother page transitions.
2026-10-01 15:07:37 +02:00
e9c0ec139a Fix null reference issue in DocumentController
Added a null-conditional operator (`?.`) to safely access the
`ByteData` property of the `senderDoc` object, preventing a
potential `NullReferenceException` when `senderDoc` is null.
2026-10-01 12:14:46 +02:00
819bdcfcfb Enhance PDF preview with rendering and editing support
Replaced `DxPdfViewer` with `SenderPdfImageEditor` to enable
PDF rendering, page previews, and field overlay editing.
Added dependency injection for `PdfRenderService` and new
state fields (`_previewPageCount`, `_previewPagePreviews`,
`_previewFileName`, `_previewFieldOverlays`) to manage PDF
metadata and interactions.

Updated `OpenPreviewAsync` to render PDF pages using
`PdfRenderService` and handle errors gracefully. Introduced
`PreviewPdfDataUrl` for generating data URLs and a static
helper `EstimatePdfPageCount` for fallback page count
estimation. Added placeholder methods for user interactions
with the editor.

Improved cleanup logic to reset state fields when the viewer
is unmounted. These changes enhance the PDF preview
functionality while maintaining backward compatibility.
2026-10-01 12:05:56 +02:00
ad7686b7c2 Refactor EnvelopeSenderPage for WebAssembly support
Updated EnvelopeSenderPage to use InteractiveWebAssembly
rendering mode. Removed unused services and authentication
logic, replacing `ReceiverPageDataService` with
`DocumentService` for document retrieval. Improved error
handling by replacing `NotFoundException` with
`HttpRequestException` and a 404 status code check.
2026-10-01 11:42:31 +02:00
ade542ded0 Add method to fetch document bytes by envelope ID
Added `GetSenderDocumentBytesByEnvelopeIdAsync` to the
`DocumentService` class. This method retrieves sender
document bytes using an HTTP GET request to the endpoint
`/api/Document?EnvelopeId={envelopeId}`. It handles HTTP
response errors by throwing an `HttpRequestException` and
supports cancellation via a `CancellationToken`.
2026-10-01 11:42:19 +02:00
01c1283622 Refactor ReceiverPage and update AGENTS.md TODOs
Updated AGENTS.md to add a technical debt TODO for modularizing
server pages by splitting large components, reducing duplication,
and centralizing shared logic.

Enhanced ReceiverPage.razor with new state variables and logic
to support a "Reject" popup feature, including state management
for visibility, submission, and validation.

Improved sharing functionality in ReceiverPage.razor by adding
error handling, user feedback, and proper state updates during
asynchronous operations to enhance maintainability and UX.
2026-10-01 10:08:47 +02:00
d969dbe843 Refactor IPdfPageImageRenderer registration
Encapsulated the registration of IPdfPageImageRenderer into a new
AddDocInfrastructure extension method in the DependencyInjection
class within the EnvelopeGenerator.Infrastructure.Doc namespace.

Updated Program.cs to use the AddDocInfrastructure method, improving
modularity and centralizing the service registration logic.
2026-09-30 17:13:09 +02:00
41779a4f58 Merge branch 'master' of http://git.dd:3000/AppStd/EnvelopeGenerator 2026-09-30 17:10:54 +02:00
bf739c1360 Improve download/print state handling and toolbar UI
Added `IsDownloading` and `IsPrinting` properties to manage
state during download and print operations in
`SenderPdfImageEditor.razor` and `SenderPdfImageEditorToolbar.razor`.
Updated `OnDownloadPdfAsync` and `OnPrintPdfAsync` to ensure
state cleanup with `finally` blocks.

Enhanced toolbar buttons to reflect busy states with dynamic
titles, disabled states, and a spinning animation. Updated
CSS in `envelope-viewer.css` to improve layout, spacing,
and responsiveness. Added hover, active, and focus-visible
styles for better interactivity. Styled a new zoom slider
with custom thumb and focus states.

Increased maximum width of key elements to 1040px and
adjusted padding/margin for better alignment. Improved
focus styles for input fields and aligned actions to the
right in specific scenarios.
2026-09-30 17:09:33 +02:00
0f167d731e Add PDF printing functionality to editor components
Enabled PDF printing in `SenderPdfImageEditor` and `SenderPdfImageEditorToolbar`. Added `CanPrintPdf` property to determine print availability and a new `OnPrintPdfAsync` method for handling print logic via JavaScript interop. Updated the toolbar with a "Print" button that triggers the print functionality.

Implemented the `printDataUrlPdf` function in `envelope-editor.js` to handle printing from a data URL. This function creates an iframe, loads the PDF, and invokes the browser's print functionality with proper cleanup of resources.
2026-09-30 17:02:05 +02:00
021d7cfe29 Add PDF download functionality to editor components
Introduced the ability to download PDFs in the `SenderPdfImageEditor` component:
- Added `PdfFileName` parameter to specify the file name.
- Implemented `CanDownloadPdf` property to enable/disable downloads.
- Created `OnDownloadPdfAsync` method to handle download logic via JS interop.

Updated `SenderPdfImageEditorToolbar` to support downloads:
- Added `CanDownloadPdf` and `DownloadPdf` parameters.
- Enabled "Download PDF" button based on `CanDownloadPdf`.
- Added `DownloadPdfAsync` method to trigger the download callback.

Enhanced `SenderEditorPage` to pass `PdfFileName` to `SenderPdfImageEditor`.

Added `downloadDataUrlAsFile` function in `envelope-editor.js` to facilitate file downloads using a temporary `<a>` element.

Made minor adjustments for consistency, including adding `ZoomStepPercent` to the toolbar and refining `SetZoomPercent` logic.
2026-09-30 16:48:51 +02:00
e2322b41cd Add zoom functionality to SenderPdfImageEditor
Introduced zoom controls to the `SenderPdfImageEditor` component, including zoom in/out, dynamic zoom levels, and a slider in the toolbar.

- Added zoom-related properties and methods to manage zoom levels.
- Updated the toolbar with zoom buttons and a slider for precise control.
- Enhanced JavaScript to handle zoom overrides and smooth scrolling.
- Modified UI elements to dynamically apply zoom styles.
- Improved accessibility and usability with appropriate attributes.
- Refactored code to integrate zoom functionality seamlessly.

These changes enhance the user experience by providing greater control over PDF viewing and editing.
2026-09-30 16:41:57 +02:00
618451f0c1 resolve merge conflicts 2026-09-30 16:37:16 +02:00
96c7cd6e8a Add "Technical Debt TODOs" section to AGENTS.md
Added a new section in AGENTS.md to document technical debt tasks.
Outlined three specific areas for improvement:
- Refactor `EnvelopeSenderPage.razor` to reduce complexity.
- Move sender dashboard logic to dedicated services/extensions.
- Keep `EnvelopeSenderPage.razor` focused on UI/state orchestration.

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

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

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

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

Added comments and refactored code for better readability
and maintainability.
2026-09-30 16:33:19 +02:00
8386b85592 Add PDF navigation to SenderPdfImageEditor
Enhanced SenderPdfImageEditor with page navigation features:
- Added methods to navigate to first, previous, next, last, or specific pages.
- Introduced `_currentPage` to track and validate the current page.
- Implemented `OnParametersSet` to initialize/clamp `_currentPage`.
- Integrated JavaScript interop for scrolling and visible page detection.

Updated SenderPdfImageEditorToolbar:
- Added navigation buttons with enable/disable states.
- Replaced static page display with input and dropdown for page selection.
- Added event callbacks for navigation actions.

Styled new toolbar elements in `envelope-viewer.css` and added:
- Input field and dropdown for page selection.
- Adjusted layout and appearance of navigation controls.

Extended `envelope-editor.js` with:
- `scrollToPdfPage` for smooth scrolling to a specific page.
- `getVisiblePdfPageNumber` to detect the most visible page.
2026-09-30 16:12:38 +02:00
640f0b95fe Add "Notes" section to document pending toolbar polish
The "Approval Gates" section in `SENDER_PDF_TOOLBAR_PLAN_EN.md`
was updated to include a new "Notes" subheading. A checklist
item was added under this section to indicate that visual
polish for the toolbar (e.g., icon sizing, balance, and
spacing adjustments) is still pending. This change provides
clarity on outstanding design tasks.
2026-09-30 15:59:10 +02:00
181f7f4dec Refine UI spacing and dimensions for compact layout
Reduced gaps, padding, and margins across various elements to create a more compact and visually streamlined layout. Adjusted `min-height`, `border-radius`, and `box-shadow` properties for toolbars and buttons to improve consistency. Updated dimensions and font sizes for value boxes and icons to align with the refined design. Applied similar adjustments for resizing states to maintain uniformity.
2026-09-30 15:58:51 +02:00
95961a296f Refactor SenderPdfImageEditor layout and styles
Refactored `SenderPdfImageEditor.razor` to improve readability and maintainability by introducing a new wrapper element (`sender-image-editor-shell`) and reorganizing the toolbar into a sticky container (`sender-image-editor__toolbar-host`). Updated conditional rendering logic to handle `PageCount` cases more effectively.

Enhanced styles in `envelope-viewer.css`:
- Added `.sender-image-editor-shell` for layout structure.
- Made toolbar sticky with `.sender-image-editor__toolbar-host`.
- Improved alignment, spacing, and responsiveness of elements.
- Adjusted padding and margins for better layout consistency.

These changes improve user experience and maintainability.
2026-09-30 15:52:46 +02:00
db613358d5 Add PDF editor toolbar with navigation and zoom controls
Introduced a `SenderPdfImageEditorToolbar` component to enhance the PDF editor with navigation, zoom, and action controls. Updated `SenderPdfImageEditor.razor` to display the toolbar when `PageCount > 0`.

Added parameters (`CurrentPage`, `TotalPages`, `ZoomPercent`) and computed properties to manage and display toolbar state dynamically.

Styled the toolbar in `envelope-viewer.css` with grid-based layout, responsive design, and hover/disabled states for buttons. Adjusted styles for smaller screens to improve usability.
2026-09-30 15:25:29 +02:00
7a126050cf Add migration plan for SenderPdfImageEditor toolbar
Introduce a detailed migration plan to design and integrate a reusable toolbar component for the `SenderPdfImageEditor` in the `EnvelopeGenerator.Server` project.

The toolbar will support page navigation, zoom in/out, PDF download, and print functionality while preserving existing signature add/remove/drag-drop behavior.

The plan includes:
- Creation of a new `SenderPdfImageEditorToolbar` component.
- Updates to `.razor`, `.css`, and `.js` files.
- A non-regression strategy with incremental feature additions and manual black-box testing after each stage.

The migration is divided into five stages:
1. Visual toolbar skeleton.
2. Page navigation.
3. Zoom in/out with mouse override.
4. Download original PDF bytes.
5. Print output.

Approval gates are defined to ensure functionality is implemented correctly and regressions are avoided.
2026-09-30 15:18:25 +02:00
7081c5f64e Refactor PDF editor styles and add throttling logic
Refactored `SenderPdfImageEditor.razor` to use culture-invariant formatting for dynamic styles and introduced the `GetPageCanvasStyle` method for calculating `aspect-ratio` and `cursor` styles. Enhanced layout control with additional CSS properties.

Updated `envelope-editor.js` to include a throttling mechanism for mouse movement events using `_moveThrottle` and `requestAnimationFrame`. Added `getClickCoordsOnPdfPageThrottled` with a promise-based approach to improve performance during rapid interactions.
2026-09-30 15:16:23 +02:00
12ec5b28fa Update cursor behavior in envelope-viewer.css
Changed the `cursor` property for `.sender-image-editor__field`
from `grab` to `move` and for its `:active` pseudo-class
from `grabbing` to `move`. This ensures consistent cursor
behavior when interacting with the element.
2026-09-30 15:15:48 +02:00
925e60a45c Update routing for sender editor pages
Refactored routing logic in `SenderEditorPage.razor` and `EnvelopeSenderEditorPage.razor`:
- Changed the route in `SenderEditorPage.razor` from `/sender/editor2` to `/sender/editor`.
- Updated `EnvelopeSenderEditorPage.razor` to use `/sender/editor_legacy` instead of `/sender/editor`.
- Adjusted `NavigationManager.NavigateTo` calls to reflect the new routes.

These changes separate the functionality of the two pages and clarify their respective roles.
2026-09-30 15:15:30 +02:00
897860b2bf Add rejection handling and dedicated page for envelopes
Introduced a new rejection flow in `ReceiverPage.razor` to detect
rejected envelopes and redirect users to a dedicated rejection
page (`ReceiverRejectedPage.razor`).

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

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

Added new CSS styles for the "Reject" button, including hover effects and dark theme support. Enhanced error handling and logging for the rejection process.
2026-09-30 14:03:56 +02:00
b0b9ba7212 Revert "Update routes and navigation logic in editor pages"
This reverts commit 33c75ea7e1.
2026-09-30 13:49:46 +02:00
33c75ea7e1 Update routes and navigation logic in editor pages
Updated the route for `SenderEditorPage.razor` to `/sender/editor` with `InteractiveWebAssembly` mode. Changed the route for `EnvelopeSenderEditorPage.razor` to `/sender/editorL` with `InteractiveServer` mode. Adjusted navigation logic in `EnvelopeSenderEditorPage.razor` to reflect the new `/sender/editorL` route for `esid`, `editQuery`, and `envelopeId` parameters.
2026-09-30 10:25:05 +02:00
513efa0833 Improve drag-and-drop in SenderPdfImageEditor
Replaced the old drag-and-drop implementation with a custom solution using mouse events for better control and precision. Added `_isDragging` state and dynamic cursor updates to enhance user feedback during drag operations.

Introduced grab offset calculations to ensure accurate field positioning relative to the pointer. Added `OnEditorMouseUp` to reset the drag state and `GetPageSize` to retrieve page dimensions, defaulting to A4 if unavailable.

Updated `envelope-editor.js` to include `pageNumber` in `getClickCoordsOnPdfPage` and removed the obsolete `getDropCoordsOnPage` function. Enhanced `EditorCoords` to include `PageNumber` for improved page identification.
2026-09-30 09:20:41 +02:00
0c81d79a9b Add drag-and-drop support for PDF field repositioning
Introduced drag-and-drop functionality for fields in the PDF editor:
- Added `SenderPdfFieldMoveRequest` record to encapsulate field movement data.
- Enhanced `SenderPdfImageEditor.razor` with drag-and-drop event handlers (`OnFieldDragStart`, `OnPageDropAsync`) and updated field structure.
- Updated `SenderEditorPage.razor` to handle field movement via the `OnMoveFieldAsync` method.
- Improved CSS styles for draggable fields and added a delete button with hover effects.
- Added `getDropCoordsOnPage` function in `envelope-editor.js` to calculate normalized drop coordinates.

These changes improve the user experience by enabling intuitive field placement and management in the PDF editor.
2026-09-30 09:08:17 +02:00
696cd1b623 Enhance PDF upload UX with progress and state handling
Refactor the PDF upload button to dynamically adjust its
appearance and behavior based on `_isPageReady` and
`_isPdfUploadInProgress` states. Add a spinner to indicate
upload progress and disable the `<InputFile>` component
during uploads or when the page is not ready.

Introduce `_isPdfUploadInProgress` state to prevent multiple
uploads and ensure consistent UI updates. Update the
`OnPdfFileSelectedAsync` method to handle this state,
including a `finally` block to reset it and trigger UI
updates.

Consolidate redundant code for disabled states and improve
conditional rendering for better maintainability. Enhance
visual feedback for loading states and make minor style
adjustments for consistency.
2026-09-29 22:18:25 +02:00
2434f67627 Add conditional rendering for PDF upload readiness
Introduced `_isPageReady` state to manage page readiness and conditionally render the PDF upload button. Disabled the upload functionality and added a loading spinner when the page is not ready. Updated `OnPdfFileSelectedAsync` to prevent uploads before readiness.

Modified the "No Document Loaded" section to use `_isPageReady` for conditional rendering. Added `SENDER_SAVE_EDIT_MIGRATION_PLAN.md` to the solution. Improved user experience with better feedback during loading states.
2026-09-29 21:41:03 +02:00
776700b247 feat(sender-editor): use rendered page metadata for field placement 2026-09-29 19:22:01 +02:00
7a114f47a6 feat(sender-editor): add PDF page rendering pipeline 2026-09-29 19:21:51 +02:00
ed44d841e7 Refactor PDF editor with custom rendering
Replaced `DxPdfViewer` with a custom PDF rendering solution in `SenderPdfImageEditor` to improve flexibility and control. Added support for displaying placeholder messages and interactive signature fields. Introduced the `SenderPdfFieldOverlay` record to encapsulate field data.

Updated `SenderEditorPage` logic to manage page counts, image URLs, and field overlays. Added a method to estimate PDF page counts. Refactored field removal logic to use index-based operations.

Enhanced styling in `envelope-viewer.css` for improved layout and responsiveness. Updated localization files with new strings for page labels and placeholders. Adjusted JavaScript selectors in `envelope-editor.js` to support the new rendering approach.
2026-09-29 16:01:42 +02:00
4bd0cc2e94 Remove DevExpress PDF viewer dependency
Transitioned Sender Editor away from DevExpress PDF viewer, introducing alternative solutions for PDF rasterization. Enforced new localization requirements for UI text using `IStringLocalizer<Resource>` with translations in DE/EN/FR resource files.

Ensured data compatibility for `CreateEnvelopeCommand` payload shape, field units, receiver-field mapping, and save/send behavior. Removed `esid` query parameter and `IMemoryCache`-based session persistence.

Prohibited use of `pdf.js` and DevExpress PDF viewer, requiring an MIT-licensed alternative if DevExpress rasterization is unsuitable. Maintained adherence to Clean Architecture layers and MediatR pattern. All code and comments are now standardized in English.
2026-09-29 15:17:56 +02:00
8a4963472c Add PDF editing and envelope management features
Introduced a new `SenderEditorPage` with a modern UI for managing envelopes, uploading PDFs, and placing signature fields. Integrated `DevExpress.Blazor.PdfViewer` for PDF rendering and interaction.

Key features include:
- PDF upload, rendering, and validation.
- Signature field placement with normalized coordinates.
- Receiver management with validation and email suggestions.
- Envelope metadata handling (title, message).
- Save as draft and send functionality with validation.
- Edit mode for existing envelopes.
- Improved UX with dynamic elements and color-coded receivers.
- Localization support for multi-language UI.

Updated `_Imports.razor` to include necessary namespaces for DevExpress components and the new `PdfEditor` namespace.
2026-09-29 11:59:06 +02:00
4d3f72a3ed Add ReceiverService and enhance DocumentService
Added `ReceiverService` to the dependency injection container in `Program.cs` and implemented its functionality for fetching receiver email suggestions via API.

Enhanced `DocumentService` with a new method `GetSenderDocumentDetailsByEnvelopeIdAsync` to fetch sender document details by envelope ID. Introduced JSON handling improvements with `JsonSerializerOptions`.

These changes improve support for receiver-related operations and extend document-related capabilities.
2026-09-29 11:58:29 +02:00
1ebd43e3b0 Remove envelope-related PDF viewer components
The following files have been removed as part of a significant refactor or deprecation of envelope-related PDF viewing and signature functionality:

- `EnvelopeReceiverPage.razor`: A detailed PDF viewer with signature handling and interactive features.
- `EnvelopeReceiverPage_DxPdfViewer.razor`: A simplified PDF viewer using the `DxPdfViewer` component.
- `EnvelopeReceiverPage_DxReportViewer.razor`: A report viewer using the `DxReportViewer` component.
- `EnvelopeReceiverPage_embed.razor`: A minimalistic PDF viewer using the `<embed>` HTML tag.

These removals indicate a transition to a new approach for handling PDF viewing and related features or their complete deprecation.
2026-09-29 09:55:13 +02:00
19aeda5aef Add migration plan for WASM-based PDF editor component
A detailed migration plan has been added to replace the DevExpress PDF viewer in `EnvelopeSenderEditorPage.razor` with a new WebAssembly (WASM)-based PDF editor component.

The plan outlines the scope, constraints, and implementation strategy, ensuring feature parity, backend compatibility, and minimal disruption to the existing user experience. Key decisions include creating a reusable `SenderPdfImageEditor` component, using server-side PDF rasterization, and removing `esid` and `IMemoryCache`-based session persistence.

The document also defines testing and acceptance criteria to verify the migration's success, focusing on visual fidelity, functionality, and backend contract consistency.
2026-09-29 09:41:09 +02:00
112 changed files with 9408 additions and 2013 deletions

1
.gitignore vendored
View File

@@ -370,3 +370,4 @@ FodyWeavers.xsd
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
/ignore/EnvelopeGenerator.Form
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.Secrets.json

View File

@@ -63,6 +63,36 @@ This section explains every markdown file in the repo: what it contains, why it
---
## Markdown Audit Snapshot (2026-10-05)
The following table summarizes repository markdown files by practical status: completed, partially complete, or removable candidates.
| File | Type | Status | Missing / Gap | Removal Recommendation |
|---|---|---|---|---|
| `AGENTS.md` | Agent onboarding | Partially complete | Route/migration statements can drift from real code over time. | Keep |
| `COPILOT_CONTEXT.md` | Architecture reference | Partially complete | Some route/render details may be out of sync with other docs; needs regular reconciliation. | Keep |
| `FORM_APPLICATION_CONTEXT.md` | Legacy-to-modern feature spec | Partially complete | Large sections still marked not implemented / needs implementation. | Keep |
| `RECEIVER_PDF_VIEWER_CONTEXT.md` | Receiver PDF viewer deep-dive | Partially complete | Long migration plan needs periodic sync with active implementation. | Keep |
| `SENDER_SAVE_EDIT_MIGRATION_PLAN.md` | Sender workflow implementation plan | Partially complete | No explicit implementation progress markers per phase. | Keep (until migration closes) |
| `SENDER_PDF_TOOLBAR_PLAN_EN.md` | Sender PDF toolbar plan | Incomplete | Explicit pending item remains (`Toolbar visual polish`). | Keep (until done) |
| `SENDER_EDITOR_WASM_PDF_EDITOR_IMPLEMENTATION_PLAN_en.md` | Sender editor WASM migration plan | Incomplete | Plan exists but completion/progress tracking is minimal. | Keep (until done) |
| `EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md` | Deployment guide | Complete (operational) | No critical gap identified. | Keep |
| `envelope-generator-ui/README.md` | Angular scaffold README | Complete but generic | Template text only; low project-specific value. | Removable or replace with project-specific summary |
| `EnvelopeGenerator.Web/wwwroot/lib/typed.js@2.1.0/README.md` | Third-party vendor README | Complete (vendor) | Not project workflow documentation. | Keep with vendor assets |
| `EnvelopeGenerator.Web/wwwroot/lib/jquery-validation/LICENSE.md` | Third-party license | Complete (legal) | No gap. | Keep |
| `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/open-iconic/README.md` | Third-party vendor README | Complete (vendor) | Not project-specific guidance. | Keep with vendor assets |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/README.md` | Legacy PSPDFKit note | Outdated | Mentions deprecated PSPDFKit-based flow; may mislead if reused. | Removable/archive |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/typed.js@2.1.0/README.md` | Duplicate vendor README | Redundant | Duplicate copy under `ignore/`. | Removable/archive |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/bootstrap-icons/README.md` | Vendor README in archive area | Secondary | Archive-only utility, not active docs. | Removable/archive |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/jquery-validation/LICENSE.md` | Duplicate license in archive area | Redundant | Duplicate copy under `ignore/`. | Removable/archive (if legal policy allows) |
| `ignore/EnvelopeGenerator/EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/README.md` | Duplicate Angular scaffold README | Redundant | Generic template + duplicate in archive path. | Removable/archive |
### Documentation Audit TODO
- [ ] Validate this markdown audit table against the current codebase and update statuses (`complete`, `partial`, `incomplete`, `removable`) when implementation changes land.
---
## Active Architecture (Post-Migration)
**Frontend:** Blazor Auto (Server+WASM hybrid)
@@ -236,6 +266,23 @@ Manual testing workflow:
3. Test sender login at `/sender/login`
4. Test receiver flow at `/envelope/login/{envelopeKey}`
## Technical Debt TODOs
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor` to reduce complexity.
- Move sender dashboard business/status logic into dedicated services and extension methods (for example effective status resolution, tab classification, receiver signed/rejected detection, and grid-layout filter sanitation).
- Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component.
- Apply a modular page architecture across Server pages (starting with `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`): split large pages into smaller subcomponents/partial modules, eliminate duplicated code paths, and centralize shared UI/domain logic for maintainability.
- Perform a senior-level page performance review across sender/receiver flows before further UX changes: verify route transitions, component mount/unmount behavior, loading-state correctness, duplicate API calls, and perceived latency (first meaningful paint and return-navigation responsiveness). Treat loading logic defects as architecture/performance issues first, not styling problems.
- Audit `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/` for unnecessary request/payload remapping and remove redundant mappings where direct request DTO usage is safe; review client HTTP methods end-to-end for similar avoidable transformation layers.
- Centralize dependency injection registrations into dedicated extension methods/modules (especially server + client service registrations in `Program.cs`) to reduce startup composition sprawl and improve maintainability.
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfRenderController.cs` + matching client service contract to remove JSON/base64 `byte[]` transport for PDF rendering; prefer binary streaming (`application/octet-stream` or multipart), avoid `data:` URL bloat where possible, and validate end-to-end memory/latency behavior with large PDFs.
- Establish a DevExpress toast-based notification architecture using `DxToastProvider` + `IToastNotificationService` (tab/page-scoped provider strategy, standardized `ToastOptions` factory, and severity-to-style mapping) so sender/receiver flows show consistent, non-duplicated, actionable feedback.
- Eliminate manual property-by-property request→domain mapping drift (example: `CreateEnvelopeCommandHandler`) by introducing a standardized mapping policy (AutoMapper/profile conventions or reflection/source-generator based mapper) and enforcing it across envelope workflows; this needs to be solved in the shared company Core library level so all services get the same guardrails by default.
- P1 security workstream: remove dynamic SQL string composition/interpolation from active save/create/update paths (`string.Format`, `$"...{input}..."`, `ToSqlParam`) and migrate to parameterized queries only; treat this as a potential SQL injection vulnerability (stability + security risk), execute repo-wide audit, and add CI guardrails to block reintroduction.
- Alternative UX design (Server component): in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`, enforce deterministic row double-click behavior as exactly one action (preview OR edit), and keep row action buttons/tooltips aligned with the same rule to eliminate ambiguous navigation.
- Implement SPA-style language switching so the UI culture updates immediately without a full page reload.
- Implement sender-scoped caching for "recent receiver suggestions" used during envelope creation (wizard/editor flows): cache key should include sender identity, support short TTL + invalidation hooks after successful envelope receiver writes, and prevent cross-sender suggestion leakage.
## Database
**SQL Server** (DD_ECM)

View File

@@ -0,0 +1,40 @@
using DigitalData.Core.Exceptions;
using FluentValidation;
using MediatR;
namespace EnvelopeGenerator.Application.Common.Behaviors;
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (_validators.Any())
{
var context = new ValidationContext<TRequest>(request);
var failures = _validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.ToList();
if (failures.Count > 0)
{
var message = string.Join("; ", failures.Select(f => f.ErrorMessage).Distinct());
throw new BadRequestException(message);
}
}
return await next();
}
}

View File

@@ -0,0 +1,99 @@
using EnvelopeGenerator.Application.Common.Enums;
using System.ComponentModel.DataAnnotations;
namespace EnvelopeGenerator.Application.Common.Configurations;
/// <summary>
/// Represents PKCS#11 signing configuration for PDF signing workflows.
/// This options model maps to the <c>Crypt:Pkcs11</c> configuration section.
/// </summary>
public sealed class Pkcs11Options
{
/// <summary>
/// Configuration section path used for binding PKCS#11 options.
/// </summary>
public const string SectionName = "Crypt:Pkcs11";
/// <summary>
/// Optional base directory that contains the <c>pkcs11</c> folder.
/// If null/empty, runtime uses the application base directory.
/// </summary>
public string? AssetsRootPath { get; set; }
/// <summary>
/// Optional numeric slot identifier used to directly select the target token slot.
/// </summary>
public ulong? SlotId { get; set; }
/// <summary>
/// Optional token label used as a fallback token selector when <see cref="SlotId"/> is not provided.
/// </summary>
public string? TokenLabel { get; set; }
/// <summary>
/// Optional PKCS#11 proxy endpoint (for example <c>tcp://host:port</c>).
/// </summary>
public string? ProxySocket { get; set; }
/// <summary>
/// Resolved absolute PKCS#11 proxy module path used at runtime.
/// Computed from <see cref="AssetsRootPath"/> and runtime defaults.
/// </summary>
public string ResolvedLibraryPath { get; private set; } = string.Empty;
/// <summary>
/// Resolved absolute pkcs11-tool path used at runtime.
/// Computed from <see cref="AssetsRootPath"/> and runtime defaults.
/// Can be null when tool fallback is not available.
/// </summary>
public string? ResolvedToolPath { get; private set; }
public void SetResolvedPaths(string resolvedLibraryPath, string? resolvedToolPath)
{
ResolvedLibraryPath = resolvedLibraryPath;
ResolvedToolPath = resolvedToolPath;
}
/// <summary>
/// User PIN used to authenticate to the PKCS#11 token session.
/// </summary>
[Required]
[MinLength(1)]
public required string UserPin { get; set; }
/// <summary>
/// Label of the private key object used to create digital signatures.
/// </summary>
[Required]
[MinLength(1)]
public required string PrivateKeyLabel { get; set; }
/// <summary>
/// Label of the certificate object returned/embedded with the signature.
/// </summary>
[Required]
[MinLength(1)]
public required string CertificateLabel { get; set; }
/// <summary>
/// Optional global default signing reason used when request-level options do not specify a value.
/// </summary>
public string? DefaultReason { get; set; }
/// <summary>
/// Application name embedded into PDF signature metadata.
/// This value is controlled via app settings and is not client-overridable.
/// </summary>
public string? ApplicationName { get; set; }
/// <summary>
/// Controls how fallback signing time is created when request-level options do not specify a value.
/// <c>true</c> uses <see cref="DateTimeOffset.UtcNow"/>, <c>false</c> uses <see cref="DateTimeOffset.Now"/>.
/// </summary>
public bool UseUtcSigningTime { get; set; } = true;
/// <summary>
/// Optional global default certification level used when request-level options do not specify a value.
/// </summary>
public PdfSignatureCertificationLevel? DefaultCertificationLevel { get; set; }
}

View File

@@ -0,0 +1,27 @@
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
/// Data Transfer Object representing document metadata and elements without binary payload.
/// </summary>
public record DocumentDetailsDto
{
/// <summary>
/// Gets or sets the unique identifier of the document.
/// </summary>
public int Id { get; init; }
/// <summary>
/// Gets or sets the envelope ID to which the document belongs.
/// </summary>
public int EnvelopeId { get; init; }
/// <summary>
/// Gets or sets the date and time when the document was added.
/// </summary>
public DateTime AddedWhen { get; init; }
/// <summary>
/// Gets or sets the collection of elements associated with the document for receiver interactions, if any.
/// </summary>
public IEnumerable<DocReceiverElementDto>? Elements { get; init; }
}

View File

@@ -3,30 +3,25 @@
/// <summary>
/// Data Transfer Object representing a document within an envelope, including optional binary data and form elements.
/// </summary>
public class DocumentDto
public record DocumentDto : DocumentDetailsDto
{
/// <summary>
/// Gets or sets the unique identifier of the document.
///
/// </summary>
public int Id { get; set; }
public DocumentDto() { }
/// <summary>
/// Gets or sets the envelope ID to which the document belongs.
///
/// </summary>
public int EnvelopeId { get; set; }
/// <summary>
/// Gets or sets the date and time when the document was added.
/// </summary>
public DateTime AddedWhen { get; set; }
/// <param name="byteData"></param>
/// <param name="details"></param>
public DocumentDto(byte[] byteData, DocumentDetailsDto details) : base(details)
{
ByteData = byteData;
}
/// <summary>
/// Gets or sets the binary data of the document, if available.
/// </summary>
public byte[]? ByteData { get; set; }
/// <summary>
/// Gets or sets the collection of elements associated with the document for receiver interactions, if any.
/// </summary>
public IEnumerable<DocReceiverElementDto>? Elements { get; set; }
public byte[]? ByteData { get; init; }
}

View File

@@ -86,6 +86,41 @@ public record EnvelopeDto : IEnvelope
/// </summary>
public int? CertificationType { get; set; }
/// <summary>
///
/// </summary>
public int? FinalEmailToCreator { get; set; }
/// <summary>
///
/// </summary>
public int? FinalEmailToReceivers { get; set; }
/// <summary>
///
/// </summary>
public bool? SendReminderEmails { get; set; }
/// <summary>
///
/// </summary>
public int? FirstReminderDays { get; set; }
/// <summary>
///
/// </summary>
public int? ReminderIntervalDays { get; set; }
/// <summary>
///
/// </summary>
public int? ExpiresWhenDays { get; set; }
/// <summary>
///
/// </summary>
public int? ExpiresWarningWhenDays { get; set; }
/// <summary>
///
/// </summary>

View File

@@ -0,0 +1,16 @@
namespace EnvelopeGenerator.Application.Common.Dto;
public sealed class PdfPageImageDto
{
public int PageNumber { get; init; }
public double WidthPt { get; init; }
public double HeightPt { get; init; }
public int WidthPx { get; init; }
public int HeightPx { get; init; }
public string ImageDataUrl { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,6 @@
namespace EnvelopeGenerator.Application.Common.Dto;
public sealed class PdfRenderRequestDto
{
public byte[]? PdfBytes { get; init; }
}

View File

@@ -0,0 +1,15 @@
using EnvelopeGenerator.Application.Common.Enums;
namespace EnvelopeGenerator.Application.Common.Dto;
public sealed record PdfSignOptionsDto
{
public string? UUID { get; set; }
public string? Name { get; set; }
public string? Reason { get; set; }
public string? Location { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? ApplicationName { get; set; }
public PdfSignatureCertificationLevel? CertificationLevel { get; set; }
}

View File

@@ -0,0 +1,57 @@
namespace EnvelopeGenerator.Application.Common.Dto;
public sealed record PdfSignaturePrincipalDto(
string? CommonName,
string? Organization,
string? Country,
string? Full);
public sealed record PdfSignatureCertificateInfoDto(
PdfSignaturePrincipalDto IssuedTo,
PdfSignaturePrincipalDto IssuedBy,
string? SerialNumber,
DateTimeOffset? ValidFrom,
DateTimeOffset? ValidTo,
bool SelfSigned,
bool Expired,
bool NotYetValid,
bool IsSigner,
int? Version,
string? KeyAlgorithm,
string? SignatureAlgorithm,
string? Pem,
int? KeySizeBits);
public sealed record PdfSignatureCheckItemDto(
bool CoversWholeDoc,
string? Reason,
string? BusinessReason,
string? Location,
string? DeclaredName,
string? ContactInfo,
string? Email,
string? Phone,
string? DigestAlgorithm,
string? SignatureAlgorithm,
string? SignerName,
string? Issuer,
string? SerialNumber,
DateTimeOffset? ValidFrom,
DateTimeOffset? ValidTo,
string ExpiryCheckedAgainst,
IReadOnlyList<PdfSignatureCertificateInfoDto> Certificates,
bool Unchanged,
string? FieldName,
bool IsCryptographicallyValid,
string? SigningTime,
string? Filter,
string? SubFilter,
string? ApplicationName,
string? UUID,
string? OriginalDocumentSha256,
string? MessageDigestHex,
string? Error);
public sealed record PdfSignatureCheckResultDto(
bool Signed,
IReadOnlyList<PdfSignatureCheckItemDto> Signatures);

View File

@@ -0,0 +1,10 @@
namespace EnvelopeGenerator.Application.Common.Dto;
public sealed record Pkcs11SelfTestStatusDto(
bool Completed,
bool Success,
string Mode,
string Summary,
DateTimeOffset? CheckedAtUtc,
string? InteropError,
string? ToolError);

View File

@@ -0,0 +1,10 @@
namespace EnvelopeGenerator.Application.Common.Dto.Receiver;
public class RecentReceiverSuggestionDto
{
public required string FullName { get; init; }
public required string EmailAddress { get; init; }
public required DateTime LastUsedAt { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace EnvelopeGenerator.Application.Common.Enums;
public enum PdfSignatureCertificationLevel
{
NoCertification = 0,
NoChangesAllowed = 1,
FormFillingAndSigning = 2,
FormFillingAndAnnotations = 3
}

View File

@@ -0,0 +1,8 @@
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Interfaces.Doc;
public interface IPdfPageImageRenderer
{
Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,47 @@
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services;
/// <summary>
/// Defines PDF digital-signing operations for the application boundary.
/// </summary>
public interface IPdfDigitalSignatureService
{
/// <summary>
/// Creates a digital signature for the provided PDF bytes and returns
/// a new signed PDF document.
/// </summary>
/// <param name="inputPdf">Source PDF content stream.</param>
/// <param name="outputPdf">Destination stream for signed PDF output.</param>
/// <param name="options">Optional signing options that map to PDF signature metadata fields.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, CancellationToken cancellationToken = default);
/// <summary>
/// Applies multiple signatures to the provided PDF in sequence and returns
/// the final signed PDF document.
/// </summary>
/// <param name="inputPdf">Source PDF content stream.</param>
/// <param name="outputPdf">Destination stream for signed PDF output.</param>
/// <param name="optionsList">Ordered signing options; one signature is applied per item.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default);
/// <summary>
/// Checks whether the provided PDF contains signature field(s) and whether
/// those signatures are cryptographically valid.
/// </summary>
/// <param name="inputPdf">PDF content stream to inspect.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
/// <returns>A structured result containing signature presence and per-signature details.</returns>
Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default);
/// <summary>
/// Extracts the original signed revision from a signed PDF.
/// For multi-revision files this returns the first signed revision bytes.
/// </summary>
/// <param name="inputPdf">Signed PDF content stream.</param>
/// <param name="outputPdf">Destination stream for original revision output.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
Task ExtractOriginalPdfAsync(Stream inputPdf, Stream outputPdf, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,8 @@
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services;
public interface IPkcs11SelfTestStatusProvider
{
Pkcs11SelfTestStatusDto GetStatus();
}

View File

@@ -32,6 +32,7 @@ public class MappingProfile : Profile
.ForMember(dest => dest.EnvelopeTypeTitle,
opt => opt.MapFrom(src => src.Type != null ? src.Type.Title : null));
CreateMap<Document, DocumentDto>();
CreateMap<Document, DocumentDetailsDto>();
CreateMap<History, HistoryDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
CreateMap<History, HistoryCreateDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
CreateMap<EnvelopeReceiver, EnvelopeReceiverDto>();

View File

@@ -10,6 +10,8 @@ using System.Reflection;
using MediatR;
using EnvelopeGenerator.Application.DocReceiverElements.Commands;
using EnvelopeGenerator.Application.DocReceiverElements.Behaviors;
using EnvelopeGenerator.Application.Common.Behaviors;
using FluentValidation;
namespace EnvelopeGenerator.Application;
@@ -60,6 +62,9 @@ public static class DependencyInjection
{
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
// Generic request validation
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
// Register SignCommand pipeline behaviors in execution order
// 0. EnvelopeReceiverResolutionBehavior - Resolves EnvelopeReceiver from query parameters (executes FIRST)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
@@ -80,6 +85,8 @@ public static class DependencyInjection
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
});
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
return services;
}
}

View File

@@ -0,0 +1,44 @@
using AutoMapper;
using AutoMapper.QueryableExtensions;
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.Documents.Queries;
/// <summary>
/// Represents a query to read document metadata/details without binary payload.
/// </summary>
/// <param name="EnvelopeId">The identifier of the envelope associated with the document.</param>
/// <param name="UserId">The unique identifier of the sending user. Optional.</param>
public record ReadDocumentDetailsQuery(int EnvelopeId, int? UserId = null) : IRequest<DocumentDetailsDto?>;
/// <summary>
/// Handles queries for reading document details without loading BYTE_DATA.
/// </summary>
public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetailsQuery, DocumentDetailsDto?>
{
private readonly IRepository<Document> _repo;
private readonly IMapper _mapper;
public ReadDocumentDetailsQueryHandler(IRepository<Document> repo, IMapper mapper)
{
_repo = repo;
_mapper = mapper;
}
public async Task<DocumentDetailsDto?> Handle(ReadDocumentDetailsQuery query, CancellationToken cancel)
{
var docQuery = _repo.Query.AsNoTracking();
if (query.UserId is int userId)
docQuery = docQuery.Where(doc => doc.Envelope.UserId == userId);
return await docQuery
.Where(d => d.EnvelopeId == query.EnvelopeId)
.ProjectTo<DocumentDetailsDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync(cancel);
}
}

View File

@@ -0,0 +1,13 @@
using FluentValidation;
namespace EnvelopeGenerator.Application.Documents.Queries;
public class ReadDocumentDetailsQueryValidator : AbstractValidator<ReadDocumentDetailsQuery>
{
public ReadDocumentDetailsQueryValidator()
{
RuleFor(x => x.EnvelopeId)
.GreaterThan(0)
.WithMessage("EnvelopeId is required.");
}
}

View File

@@ -0,0 +1,23 @@
using FluentValidation;
namespace EnvelopeGenerator.Application.Documents.Queries;
public class ReadDocumentQueryValidator : AbstractValidator<ReadDocumentQuery>
{
public ReadDocumentQueryValidator()
{
RuleFor(x => x)
.Must(x => (x.Id ?? 0) > 0 || (x.EnvelopeId ?? 0) > 0)
.WithMessage("Id or EnvelopeId is required.");
RuleFor(x => x.Id)
.GreaterThan(0)
.When(x => x.Id.HasValue)
.WithMessage("Id must be greater than 0.");
RuleFor(x => x.EnvelopeId)
.GreaterThan(0)
.When(x => x.EnvelopeId.HasValue)
.WithMessage("EnvelopeId must be greater than 0.");
}
}

View File

@@ -4,6 +4,7 @@
<TargetFrameworks>net7.0;net8.0;net9.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
@@ -19,6 +20,8 @@
<PackageReference Include="DigitalData.Core.Client" Version="2.1.0" />
<PackageReference Include="DigitalData.Core.Exceptions" Version="1.1.0" />
<PackageReference Include="DigitalData.EmailProfilerDispatcher" Version="3.1.1" />
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />

View File

@@ -16,5 +16,9 @@ public class MappingProfile : Profile
public MappingProfile()
{
CreateMap<Receiver, ReceiverDto>();
CreateMap<EnvelopeReceiver, RecentReceiverSuggestionDto>()
.ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.Name))
.ForMember(dest => dest.EmailAddress, opt => opt.MapFrom(src => src.Receiver.EmailAddress));
}
}

View File

@@ -0,0 +1,98 @@
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
/// <summary>
/// Reads recent receiver suggestions for envelope creation.
/// </summary>
/// <remarks>
/// Sender scope is assigned via chain method to avoid exposing username mutation from API query binding.
/// </remarks>
public record ReadRecentReceiverSuggestionsQuery : IRequest<IReadOnlyList<RecentReceiverSuggestionDto>>
{
/// <summary>
/// Sender username used for scoping suggestion results.
/// </summary>
internal string? Username { get; private init; }
/// <summary>
/// Desired number of results.
/// </summary>
public int? Take { get; init; } = null;
/// <summary>
/// Returns a new query instance scoped to the specified sender username.
/// </summary>
/// <param name="username">Sender username from authenticated claims.</param>
/// <returns>Scoped query instance.</returns>
public ReadRecentReceiverSuggestionsQuery ForSender(string username)
{
return this with { Username = username };
}
}
/// <summary>
/// Handles <see cref="ReadRecentReceiverSuggestionsQuery"/>.
/// </summary>
public class ReadRecentReceiverSuggestionsQueryHandler(IRepository<EnvelopeReceiver> repository)
: IRequestHandler<ReadRecentReceiverSuggestionsQuery, IReadOnlyList<RecentReceiverSuggestionDto>>
{
/// <summary>
/// Retrieves distinct recent receiver suggestions grouped by receiver email.
/// </summary>
/// <param name="request">Suggestion query.</param>
/// <param name="cancel">Cancellation token.</param>
/// <returns>Ordered list of recent receiver suggestions.</returns>
public async Task<IReadOnlyList<RecentReceiverSuggestionDto>> Handle(ReadRecentReceiverSuggestionsQuery request, CancellationToken cancel)
{
var query = repository.Query
.AsNoTracking()
.Where(er => er.Receiver != null);
if (request.Username is not null)
query = query.Where(er => er.Envelope != null && er.Envelope.User.Username == request.Username);
// 1) For each email, compute the latest usage timestamp.
var latestPerEmail = query
.GroupBy(er => er.Receiver!.EmailAddress)
.Select(g => new
{
EmailAddress = g.Key,
LastUsedAt = g.Max(er => er.AddedWhen),
});
// 2) Join back to rows that match the latest timestamp per email.
var latestRows =
from er in query
join latest in latestPerEmail
on new { EmailAddress = er.Receiver!.EmailAddress, LastUsedAt = er.AddedWhen }
equals new { latest.EmailAddress, latest.LastUsedAt }
select new
{
EmailAddress = er.Receiver!.EmailAddress,
FullName = er.Name,
LastUsedAt = er.AddedWhen,
};
// 3) Tie-break: if multiple rows share the same latest timestamp for an email,
// pick a deterministic name via Max(FullName), then order by most recent usage.
var suggestions = latestRows
.GroupBy(x => new { x.EmailAddress, x.LastUsedAt })
.Select(g => new RecentReceiverSuggestionDto
{
EmailAddress = g.Key.EmailAddress,
FullName = g.Max(x => x.FullName) ?? string.Empty,
LastUsedAt = g.Key.LastUsedAt,
})
.OrderByDescending(x => x.LastUsedAt)
.ThenBy(x => x.EmailAddress);
return request.Take is int take
? await suggestions.Take(take).ToListAsync(cancel)
: await suggestions.ToListAsync(cancel);
}
}

View File

@@ -1,6 +1,5 @@
using EnvelopeGenerator.Application.Common.Dto;
using MediatR;
using AutoMapper;
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Domain.Entities;
using Microsoft.EntityFrameworkCore;
@@ -18,17 +17,14 @@ public record ReadEnvelopeTypesQuery : IRequest<IEnumerable<EnvelopeTypeDto>>;
public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQuery, IEnumerable<EnvelopeTypeDto>>
{
private readonly IRepository<EnvelopeType> _repository;
private readonly IMapper _mapper;
/// <summary>
///
/// </summary>
/// <param name="repository"></param>
/// <param name="mapper"></param>
public ReadEnvelopeTypesQueryHandler(IRepository<EnvelopeType> repository, IMapper mapper)
public ReadEnvelopeTypesQueryHandler(IRepository<EnvelopeType> repository)
{
_repository = repository;
_mapper = mapper;
}
/// <summary>
@@ -39,7 +35,28 @@ public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQu
/// <returns></returns>
public async Task<IEnumerable<EnvelopeTypeDto>> Handle(ReadEnvelopeTypesQuery request, CancellationToken cancellationToken)
{
var types = await _repository.Query.ToListAsync(cancellationToken);
return _mapper.Map<IEnumerable<EnvelopeTypeDto>>(types);
// TODO: Temporary manual projection to avoid null-materialization crashes from legacy nullable DB columns in EnvelopeType.
// Revisit and switch to AutoMapper query projection (ProjectTo) after schema nullability and mapping contracts are stabilized.
return await _repository.Query
.AsNoTracking()
.Select(t => new EnvelopeTypeDto
{
Id = t.Id,
Title = t.Title ?? string.Empty,
Language = string.IsNullOrWhiteSpace(t.Language) ? "de-DE" : t.Language,
ExpiresDays = t.ExpiresDays,
CertificationType = (int?)t.CertificationType,
UseAccessCode = t.UseAccessCode,
FinalEmailToCreator = (int?)t.FinalEmailToCreator,
FinalEmailToReceivers = (int?)t.FinalEmailToReceivers,
AddedWhen = t.AddedWhen,
ChangedWhen = t.ChangedWhen,
ExpiresWarningDays = t.ExpiresWarningDays,
SendReminderEmails = t.SendReminderEmails,
FirstReminderDays = t.FirstReminderDays,
ReminderIntervalDays = t.ReminderIntervalDays,
ContractType = t.ContractType
})
.ToListAsync(cancellationToken);
}
}

View File

@@ -12,8 +12,66 @@ namespace EnvelopeGenerator.Application.Envelopes.Commands;
/// </summary>
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
{
/// <summary>
/// Optionale Umschlag-ID fuer Aktualisierungsvorgaenge.
/// </summary>
public int? EnvelopeId { get; init; }
/// <summary>
/// Kennung des ausgewaehlten Umschlagtyps (Vorlage).
/// </summary>
public int? EnvelopeTypeId { get; set; }
/// <summary>
/// Sprachcode fuer Umschlaginhalt und Benachrichtigungen.
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Gibt an, ob der Zugriffscode fuer Empfaenger aktiviert ist.
/// </summary>
public bool? UseAccessCode { get; set; }
/// <summary>
/// Zertifizierungstyp fuer den Signaturprozess.
/// </summary>
public int? CertificationType { get; set; }
/// <summary>
/// Finale E-Mail-Einstellung fuer den Ersteller.
/// </summary>
public int? FinalEmailToCreator { get; set; }
/// <summary>
/// Finale E-Mail-Einstellung fuer die Empfaenger.
/// </summary>
public int? FinalEmailToReceivers { get; set; }
/// <summary>
/// Gibt an, ob Erinnerungs-E-Mails aktiviert sind.
/// </summary>
public bool? SendReminderEmails { get; set; }
/// <summary>
/// Anzahl der Tage bis zur ersten Erinnerung.
/// </summary>
public int? FirstReminderDays { get; set; }
/// <summary>
/// Intervall in Tagen zwischen Erinnerungs-E-Mails.
/// </summary>
public int? ReminderIntervalDays { get; set; }
/// <summary>
/// Anzahl der Tage bis zum Ablauf des Umschlags.
/// </summary>
public int? ExpiresWhenDays { get; set; }
/// <summary>
/// Anzahl der Tage vor Ablauf, ab denen gewarnt wird.
/// </summary>
public int? ExpiresWarningWhenDays { get; set; }
/// <summary>
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
/// </summary>
@@ -29,10 +87,19 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
/// </summary>
public bool TFAEnabled { get; set; } = false;
/// <summary>
/// Gibt an, ob der Umschlag nach dem Speichern sofort versendet werden soll.
/// </summary>
public bool Send { get; init; }
/// <summary>
/// Optionaler PDF-Dokumentinhalt als Base64.
/// </summary>
public CreateEnvelopeDocumentDto? Document { get; init; }
/// <summary>
/// Empfaengerliste inklusive optionaler Signaturfeld-Koordinaten.
/// </summary>
public List<CreateEnvelopeReceiverDto> Receivers { get; init; } = new();
/// <summary>

View File

@@ -33,6 +33,8 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => _provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
private IRepository<EnvelopeType> EnvelopeTypeRepository => _provider.GetRequiredService<IRepository<EnvelopeType>>();
private IRepository<Receiver> ReceiverRepository => _provider.GetRequiredService<IRepository<Receiver>>();
private IRepository<DocReceiverElement> DocReceiverElementRepository => _provider.GetRequiredService<IRepository<DocReceiverElement>>();
@@ -73,6 +75,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
var now = DateTime.Now;
var title = request.Title?.Trim() ?? string.Empty;
var message = request.Message?.Trim() ?? string.Empty;
var settings = await ResolveEnvelopeSettingsAsync(request, existing: null, cancel);
Envelope envelope;
if (request.EnvelopeId is int envelopeId)
@@ -85,11 +88,27 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
if (envelope.UserId != request.UserId)
throw new ForbiddenException();
settings = await ResolveEnvelopeSettingsAsync(request, envelope, cancel);
await Repository.UpdateAsync(e =>
{
e.Title = title;
e.Message = message;
e.TfaEnabled = request.TFAEnabled;
e.EnvelopeTypeId = settings.EnvelopeTypeId;
e.Language = settings.Language;
e.UseAccessCode = settings.UseAccessCode;
e.CertificationType = settings.CertificationType;
e.FinalEmailToCreator = settings.FinalEmailToCreator;
e.FinalEmailToReceivers = settings.FinalEmailToReceivers;
e.SendReminderEmails = settings.SendReminderEmails;
e.FirstReminderDays = settings.FirstReminderDays;
e.ReminderIntervalDays = settings.ReminderIntervalDays;
e.ExpiresWhenDays = settings.ExpiresWhenDays;
e.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
e.ContractType = settings.ContractType;
e.ExpiresWhen = settings.ExpiresWhen;
e.ExpiresWarningWhen = settings.ExpiresWarningWhen;
e.Status = EnvelopeStatus.EnvelopeSaved;
e.ChangedWhen = now;
}, e => e.Id == envelopeId, cancel);
@@ -97,6 +116,20 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
envelope.Title = title;
envelope.Message = message;
envelope.TfaEnabled = request.TFAEnabled;
envelope.EnvelopeTypeId = settings.EnvelopeTypeId;
envelope.Language = settings.Language;
envelope.UseAccessCode = settings.UseAccessCode;
envelope.CertificationType = settings.CertificationType;
envelope.FinalEmailToCreator = settings.FinalEmailToCreator;
envelope.FinalEmailToReceivers = settings.FinalEmailToReceivers;
envelope.SendReminderEmails = settings.SendReminderEmails;
envelope.FirstReminderDays = settings.FirstReminderDays;
envelope.ReminderIntervalDays = settings.ReminderIntervalDays;
envelope.ExpiresWhenDays = settings.ExpiresWhenDays;
envelope.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
envelope.ContractType = settings.ContractType;
envelope.ExpiresWhen = settings.ExpiresWhen;
envelope.ExpiresWarningWhen = settings.ExpiresWarningWhen;
envelope.Status = EnvelopeStatus.EnvelopeSaved;
envelope.ChangedWhen = now;
}
@@ -114,12 +147,69 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
AddedWhen = now,
ChangedWhen = now,
Comment = string.Empty,
Language = "de-DE",
EnvelopeTypeId = settings.EnvelopeTypeId,
Language = settings.Language,
TfaEnabled = request.TFAEnabled,
UseAccessCode = false,
SendReminderEmails = false
UseAccessCode = settings.UseAccessCode,
CertificationType = settings.CertificationType,
FinalEmailToCreator = settings.FinalEmailToCreator,
FinalEmailToReceivers = settings.FinalEmailToReceivers,
SendReminderEmails = settings.SendReminderEmails,
FirstReminderDays = settings.FirstReminderDays,
ReminderIntervalDays = settings.ReminderIntervalDays,
ExpiresWhenDays = settings.ExpiresWhenDays,
ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays,
ExpiresWhen = settings.ExpiresWhen,
ExpiresWarningWhen = settings.ExpiresWarningWhen,
ContractType = settings.ContractType
}, cancel);
if (request.UseSQLExecutor)
{
await Repository.UpdateAsync(e =>
{
e.Title = title;
e.Message = message;
e.TfaEnabled = request.TFAEnabled;
e.EnvelopeTypeId = settings.EnvelopeTypeId;
e.Language = settings.Language;
e.UseAccessCode = settings.UseAccessCode;
e.CertificationType = settings.CertificationType;
e.FinalEmailToCreator = settings.FinalEmailToCreator;
e.FinalEmailToReceivers = settings.FinalEmailToReceivers;
e.SendReminderEmails = settings.SendReminderEmails;
e.FirstReminderDays = settings.FirstReminderDays;
e.ReminderIntervalDays = settings.ReminderIntervalDays;
e.ExpiresWhenDays = settings.ExpiresWhenDays;
e.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
e.ExpiresWhen = settings.ExpiresWhen;
e.ExpiresWarningWhen = settings.ExpiresWarningWhen;
e.ContractType = settings.ContractType;
e.Status = EnvelopeStatus.EnvelopeSaved;
e.ChangedWhen = now;
}, e => e.Id == envelope.Id, cancel);
envelope.Title = title;
envelope.Message = message;
envelope.TfaEnabled = request.TFAEnabled;
envelope.EnvelopeTypeId = settings.EnvelopeTypeId;
envelope.Language = settings.Language;
envelope.UseAccessCode = settings.UseAccessCode;
envelope.CertificationType = settings.CertificationType;
envelope.FinalEmailToCreator = settings.FinalEmailToCreator;
envelope.FinalEmailToReceivers = settings.FinalEmailToReceivers;
envelope.SendReminderEmails = settings.SendReminderEmails;
envelope.FirstReminderDays = settings.FirstReminderDays;
envelope.ReminderIntervalDays = settings.ReminderIntervalDays;
envelope.ExpiresWhenDays = settings.ExpiresWhenDays;
envelope.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
envelope.ExpiresWhen = settings.ExpiresWhen;
envelope.ExpiresWarningWhen = settings.ExpiresWarningWhen;
envelope.ContractType = settings.ContractType;
envelope.Status = EnvelopeStatus.EnvelopeSaved;
envelope.ChangedWhen = now;
}
await Mediator.Send(new CreateHistoryCommand
{
EnvelopeId = envelope.Id,
@@ -143,6 +233,105 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
return _mapper.Map<EnvelopeDto>(envelope);
}
private async Task<EnvelopeSettings> ResolveEnvelopeSettingsAsync(CreateEnvelopeCommand request, Envelope? existing, CancellationToken cancel)
{
EnvelopeType? selectedType = null;
if (request.EnvelopeTypeId is int envelopeTypeId && envelopeTypeId > 0)
{
selectedType = await EnvelopeTypeRepository.Query
.FirstOrDefaultAsync(t => t.Id == envelopeTypeId, cancel)
?? throw new BadRequestException($"Envelope type {envelopeTypeId} not found.");
}
var expiresWhenDays = Math.Max(0,
request.ExpiresWhenDays
?? selectedType?.ExpiresWhenDays
?? existing?.ExpiresWhenDays
?? 0);
var expiresWarningWhenDays = Math.Max(0,
request.ExpiresWarningWhenDays
?? selectedType?.ExpiresWarningWhenDays
?? existing?.ExpiresWarningWhenDays
?? 0);
var useAccessCode = request.UseAccessCode
?? selectedType?.UseAccessCode
?? existing?.UseAccessCode
?? false;
if (request.TFAEnabled)
useAccessCode = true;
return new EnvelopeSettings(
EnvelopeTypeId: request.EnvelopeTypeId ?? existing?.EnvelopeTypeId,
Language: ResolveLanguage(request.Language, selectedType?.Language, existing?.Language),
UseAccessCode: useAccessCode,
CertificationType: request.CertificationType
?? (int?)selectedType?.CertificationType
?? existing?.CertificationType
?? (int)CertificationType.AdvancedElectronicSignature,
FinalEmailToCreator: request.FinalEmailToCreator
?? (int?)selectedType?.FinalEmailToCreator
?? existing?.FinalEmailToCreator
?? (int)FinalEmailType.No,
FinalEmailToReceivers: request.FinalEmailToReceivers
?? (int?)selectedType?.FinalEmailToReceivers
?? existing?.FinalEmailToReceivers
?? (int)FinalEmailType.No,
SendReminderEmails: request.SendReminderEmails
?? selectedType?.SendReminderEmails
?? existing?.SendReminderEmails
?? false,
FirstReminderDays: Math.Max(0,
request.FirstReminderDays
?? selectedType?.FirstReminderDays
?? existing?.FirstReminderDays
?? 0),
ReminderIntervalDays: Math.Max(0,
request.ReminderIntervalDays
?? selectedType?.ReminderIntervalDays
?? existing?.ReminderIntervalDays
?? 0),
ExpiresWhenDays: expiresWhenDays,
ExpiresWarningWhenDays: expiresWarningWhenDays,
ExpiresWhen: expiresWhenDays > 0 ? DateTime.Now.AddDays(expiresWhenDays) : null,
ExpiresWarningWhen: expiresWarningWhenDays > 0 ? DateTime.Now.AddDays(expiresWarningWhenDays) : null,
ContractType: existing?.ContractType
?? selectedType?.ContractType
?? (int)ContractType.Contract);
}
private static string ResolveLanguage(string? preferred, string? fallback1, string? fallback2)
{
if (!string.IsNullOrWhiteSpace(preferred))
return preferred.Trim();
if (!string.IsNullOrWhiteSpace(fallback1))
return fallback1.Trim();
if (!string.IsNullOrWhiteSpace(fallback2))
return fallback2.Trim();
return "de-DE";
}
private sealed record EnvelopeSettings(
int? EnvelopeTypeId,
string Language,
bool UseAccessCode,
int CertificationType,
int FinalEmailToCreator,
int FinalEmailToReceivers,
bool SendReminderEmails,
int FirstReminderDays,
int ReminderIntervalDays,
int ExpiresWhenDays,
int ExpiresWarningWhenDays,
DateTime? ExpiresWhen,
DateTime? ExpiresWarningWhen,
int ContractType);
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
{
if (!string.IsNullOrWhiteSpace(envelope.User?.Email))

View File

@@ -123,6 +123,9 @@
<data name="Back" xml:space="preserve">
<value>Zurück</value>
</data>
<data name="Next" xml:space="preserve">
<value>Weiter</value>
</data>
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
<value>Klicken Sie auf den</value>
</data>
@@ -564,6 +567,12 @@
<data name="AddReceiver" xml:space="preserve">
<value>Empfänger hinzufügen</value>
</data>
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
<value>Zuletzt verwendete Empfänger</value>
</data>
<data name="Skip" xml:space="preserve">
<value>Überspringen</value>
</data>
<data name="DeleteReceiver" xml:space="preserve">
<value>Empfänger löschen</value>
</data>
@@ -735,7 +744,9 @@
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Fehler beim Laden der Datei: {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Bitte unterzeichnen Sie das beigefügte Dokument.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Kein Dokument geladen</value></data>
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Keine Seiten zur Anzeige verfügbar.</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Noch keine Empfänger hinzugefügt.</value></data>
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Seite</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Signaturfeld platzieren</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Alle Signaturfelder entfernen</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Bitte wählen Sie eine PDF-Datei aus.</value></data>

View File

@@ -123,6 +123,9 @@
<data name="Back" xml:space="preserve">
<value>Back</value>
</data>
<data name="Next" xml:space="preserve">
<value>Next</value>
</data>
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
<value>Click the</value>
</data>
@@ -564,6 +567,12 @@
<data name="AddReceiver" xml:space="preserve">
<value>Add Receiver</value>
</data>
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
<value>Recently used recipients</value>
</data>
<data name="Skip" xml:space="preserve">
<value>Skip</value>
</data>
<data name="DeleteReceiver" xml:space="preserve">
<value>Delete Receiver</value>
</data>
@@ -735,7 +744,9 @@
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Error loading file: {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Please sign the attached document.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>No document loaded</value></data>
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>No pages to display.</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>No receivers added yet.</value></data>
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Place signature field</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Remove all signature fields</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Please select a PDF file.</value></data>

View File

@@ -123,6 +123,9 @@
<data name="Back" xml:space="preserve">
<value>Retour</value>
</data>
<data name="Next" xml:space="preserve">
<value>Suivant</value>
</data>
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
<value>Cliquez sur</value>
</data>
@@ -564,6 +567,12 @@
<data name="AddReceiver" xml:space="preserve">
<value>Ajouter un destinataire</value>
</data>
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
<value>Destinataires recents</value>
</data>
<data name="Skip" xml:space="preserve">
<value>Passer</value>
</data>
<data name="DeleteReceiver" xml:space="preserve">
<value>Supprimer le destinataire</value>
</data>
@@ -735,7 +744,9 @@
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Erreur lors du chargement du fichier : {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Veuillez signer le document ci-joint.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Aucun document chargé</value></data>
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Aucune page à afficher.</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Aucun destinataire ajouté pour le moment.</value></data>
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Placer un champ de signature</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Supprimer tous les champs de signature</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Veuillez sélectionner un fichier PDF.</value></data>

View File

@@ -22,41 +22,41 @@ namespace EnvelopeGenerator.Domain.Entities
public string Language { get; set; }
[Column("EXPIRES_DAYS")]
public int ExpiresDays { get; set; }
public int? ExpiresDays { get; set; }
[Column("CERTIFICATION_TYPE")]
public Constants.CertificationType CertificationType { get; set; } = Constants.CertificationType.AdvancedElectronicSignature;
public Constants.CertificationType? CertificationType { get; set; }
[Column("USE_ACCESS_CODE")]
public bool UseAccessCode { get; set; }
public bool? UseAccessCode { get; set; }
[Column("FINAL_EMAIL_TO_CREATOR")]
public Constants.FinalEmailType FinalEmailToCreator { get; set; }
public Constants.FinalEmailType? FinalEmailToCreator { get; set; }
[Column("FINAL_EMAIL_TO_RECEIVERS")]
public Constants.FinalEmailType FinalEmailToReceivers { get; set; }
public Constants.FinalEmailType? FinalEmailToReceivers { get; set; }
[Required]
[Column("ADDED_WHEN", TypeName = "datetime")]
public DateTime AddedWhen { get; set; }
[Column("CHANGED_WHEN", TypeName = "datetime")]
public DateTime ChangedWhen { get; set; }
public DateTime? ChangedWhen { get; set; }
[Column("EXPIRES_WARNING_DAYS")]
public int ExpiresWarningDays { get; set; }
public int? ExpiresWarningDays { get; set; }
[Column("SEND_REMINDER_EMAILS")]
public bool SendReminderEmails { get; set; } = false;
public bool? SendReminderEmails { get; set; }
[Column("FIRST_REMINDER_DAYS")]
public int FirstReminderDays { get; set; } = 0;
public int? FirstReminderDays { get; set; }
[Column("REMINDER_INTERVAL_DAYS")]
public int ReminderIntervalDays { get; set; } = 0;
public int? ReminderIntervalDays { get; set; }
[Column("CONTRACT_TYPE")]
public int ContractType { get; set; }
public int? ContractType { get; set; }
[NotMapped]
public bool TFA_Enabled { get; set; } = false;
@@ -70,14 +70,14 @@ namespace EnvelopeGenerator.Domain.Entities
[NotMapped]
public int ExpiresWhenDays
{
get => ExpiresDays;
get => ExpiresDays ?? 0;
set => ExpiresDays = value;
}
[NotMapped]
public int ExpiresWarningWhenDays
{
get => ExpiresWarningDays;
get => ExpiresWarningDays ?? 0;
set => ExpiresWarningDays = value;
}

View File

@@ -276,4 +276,61 @@
<data name="YesWithAttachment" xml:space="preserve">
<value>Yes, with Attachment</value>
</data>
<data name="ElectronicSignature" xml:space="preserve">
<value>Electronic Signature</value>
</data>
<data name="EnvelopeType" xml:space="preserve">
<value>Envelope Type</value>
</data>
<data name="Language" xml:space="preserve">
<value>Language</value>
</data>
<data name="CertificationType" xml:space="preserve">
<value>Certification</value>
</data>
<data name="TFAEnabled" xml:space="preserve">
<value>2FA enabled</value>
</data>
<data name="UseAccessCode" xml:space="preserve">
<value>Use Access Code</value>
</data>
<data name="FinalEmailToCreator" xml:space="preserve">
<value>Final Email to Creator</value>
</data>
<data name="FinalEmailToReceivers" xml:space="preserve">
<value>Final Email to Receivers</value>
</data>
<data name="SendReminderEmails" xml:space="preserve">
<value>Send Reminder Emails</value>
</data>
<data name="FirstReminderDays" xml:space="preserve">
<value>First Reminder (Days)</value>
</data>
<data name="ReminderIntervalDays" xml:space="preserve">
<value>Reminder Interval (Days)</value>
</data>
<data name="ExpiresWhenDays" xml:space="preserve">
<value>Expires After (Days)</value>
</data>
<data name="ExpiresWarningWhenDays" xml:space="preserve">
<value>Expiry Warning (Days)</value>
</data>
<data name="SenderEditor_EnvelopeSettings" xml:space="preserve">
<value>Envelope Settings</value>
</data>
<data name="SenderEditor_AdvancedOptional" xml:space="preserve">
<value>Advanced</value>
</data>
<data name="SenderEditor_SelectTemplate" xml:space="preserve">
<value>Select Template</value>
</data>
<data name="SenderEditor_Validation_ReminderInterval" xml:space="preserve">
<value>Please enter a reminder interval greater than 0 days.</value>
</data>
<data name="SenderEditor_Validation_FirstReminder" xml:space="preserve">
<value>Please enter valid days for the first reminder.</value>
</data>
<data name="SenderEditor_Validation_ExpiryWarning" xml:space="preserve">
<value>The expiry warning cannot be after the expiry date.</value>
</data>
</root>

View File

@@ -113,4 +113,61 @@
<data name="MessageInvitationSent" xml:space="preserve">
<value>Invitation envoyee</value>
</data>
<data name="ElectronicSignature" xml:space="preserve">
<value>Signature electronique</value>
</data>
<data name="EnvelopeType" xml:space="preserve">
<value>Type d'enveloppe</value>
</data>
<data name="Language" xml:space="preserve">
<value>Langue</value>
</data>
<data name="CertificationType" xml:space="preserve">
<value>Certification</value>
</data>
<data name="TFAEnabled" xml:space="preserve">
<value>2FA active</value>
</data>
<data name="UseAccessCode" xml:space="preserve">
<value>Utiliser un code d'acces</value>
</data>
<data name="FinalEmailToCreator" xml:space="preserve">
<value>E-mail final au createur</value>
</data>
<data name="FinalEmailToReceivers" xml:space="preserve">
<value>E-mail final aux destinataires</value>
</data>
<data name="SendReminderEmails" xml:space="preserve">
<value>Envoyer des e-mails de rappel</value>
</data>
<data name="FirstReminderDays" xml:space="preserve">
<value>Premier rappel (jours)</value>
</data>
<data name="ReminderIntervalDays" xml:space="preserve">
<value>Intervalle de rappel (jours)</value>
</data>
<data name="ExpiresWhenDays" xml:space="preserve">
<value>Expire apres (jours)</value>
</data>
<data name="ExpiresWarningWhenDays" xml:space="preserve">
<value>Avertissement d'expiration (jours)</value>
</data>
<data name="SenderEditor_EnvelopeSettings" xml:space="preserve">
<value>Parametres de l'enveloppe</value>
</data>
<data name="SenderEditor_AdvancedOptional" xml:space="preserve">
<value>Avance</value>
</data>
<data name="SenderEditor_SelectTemplate" xml:space="preserve">
<value>Selectionner un modele</value>
</data>
<data name="SenderEditor_Validation_ReminderInterval" xml:space="preserve">
<value>Veuillez saisir un intervalle de rappel superieur a 0 jour.</value>
</data>
<data name="SenderEditor_Validation_FirstReminder" xml:space="preserve">
<value>Veuillez saisir des jours valides pour le premier rappel.</value>
</data>
<data name="SenderEditor_Validation_ExpiryWarning" xml:space="preserve">
<value>L'avertissement d'expiration ne peut pas etre apres la date d'expiration.</value>
</data>
</root>

View File

@@ -279,4 +279,61 @@
<data name="YesWithAttachment" xml:space="preserve">
<value>Ja, mit Anhang</value>
</data>
<data name="ElectronicSignature" xml:space="preserve">
<value>Elektronische Signatur</value>
</data>
<data name="EnvelopeType" xml:space="preserve">
<value>Umschlagtyp</value>
</data>
<data name="Language" xml:space="preserve">
<value>Sprache</value>
</data>
<data name="CertificationType" xml:space="preserve">
<value>Zertifizierung</value>
</data>
<data name="TFAEnabled" xml:space="preserve">
<value>2FA aktiviert</value>
</data>
<data name="UseAccessCode" xml:space="preserve">
<value>Zugriffscode verwenden</value>
</data>
<data name="FinalEmailToCreator" xml:space="preserve">
<value>Finale E-Mail an Ersteller</value>
</data>
<data name="FinalEmailToReceivers" xml:space="preserve">
<value>Finale E-Mail an Empfänger</value>
</data>
<data name="SendReminderEmails" xml:space="preserve">
<value>Erinnerungs-E-Mails senden</value>
</data>
<data name="FirstReminderDays" xml:space="preserve">
<value>Erste Erinnerung (Tage)</value>
</data>
<data name="ReminderIntervalDays" xml:space="preserve">
<value>Erinnerungsintervall (Tage)</value>
</data>
<data name="ExpiresWhenDays" xml:space="preserve">
<value>Läuft ab nach (Tage)</value>
</data>
<data name="ExpiresWarningWhenDays" xml:space="preserve">
<value>Ablaufwarnung (Tage)</value>
</data>
<data name="SenderEditor_EnvelopeSettings" xml:space="preserve">
<value>Envelope-Einstellungen</value>
</data>
<data name="SenderEditor_AdvancedOptional" xml:space="preserve">
<value>Erweitert</value>
</data>
<data name="SenderEditor_SelectTemplate" xml:space="preserve">
<value>Vorlage auswählen</value>
</data>
<data name="SenderEditor_Validation_ReminderInterval" xml:space="preserve">
<value>Bitte geben Sie ein Erinnerungsintervall größer als 0 Tage ein.</value>
</data>
<data name="SenderEditor_Validation_FirstReminder" xml:space="preserve">
<value>Bitte geben Sie gültige Tage für die erste Erinnerung ein.</value>
</data>
<data name="SenderEditor_Validation_ExpiryWarning" xml:space="preserve">
<value>Die Ablaufwarnung darf nicht nach dem Ablaufdatum liegen.</value>
</data>
</root>

View File

@@ -105,10 +105,10 @@ Public Class frmEnvelopeMainData
End If
End Sub
Private Function TranslateCertificationType(pType As CertificationType) As String
Return Resources.Model.ResourceManager.GetString(pType.ToString())
Return CommonServices.My.Resources.Model.ResourceManager.GetString(pType.ToString())
End Function
Private Function TranslateFinalEmailType(pType As FinalEmailType) As String
Return Resources.Model.ResourceManager.GetString(pType.ToString())
Return CommonServices.My.Resources.Model.ResourceManager.GetString(pType.ToString())
End Function
Private Sub btOK_Click(sender As Object, e As EventArgs) Handles btOK.Click

View File

@@ -0,0 +1,26 @@
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Infrastructure.Crypt.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Infrastructure.Crypt.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCryptInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
services
.AddOptions<Pkcs11Options>()
.Bind(configuration.GetSection(Pkcs11Options.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddSingleton<IPdfDigitalSignatureService, PdfDigitalSignatureService>();
services.AddSingleton<Pkcs11SelfTestStatusStore>();
services.AddSingleton<IPkcs11SelfTestStatusProvider>(sp => sp.GetRequiredService<Pkcs11SelfTestStatusStore>());
services.AddHostedService<Pkcs11StartupSelfTestHostedService>();
return services;
}
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DevExpress.Document.Processor" Version="25.2.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.6" />
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="9.0.6" />
<PackageReference Include="Pkcs11Interop" Version="5.3.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="resources\pkcs11\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<TargetPath>pkcs11\%(RecursiveDir)%(Filename)%(Extension)</TargetPath>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,384 @@
# EnvelopeGenerator.Infrastructure.Crypt
Umfassende technische Dokumentation der PKCS#11/SoftHSM-Integration fuer `EnvelopeGenerator`.
Dieses Dokument konsolidiert und ersetzt die verteilten Runbooks aus:
- `pkcs11-proxy-production/README.md`
- `pkcs11-proxy-production/SSH_TEMP_ACCESS_MUST_DO.md`
- aeltere Abschnitte in diesem README
Zielgruppe: Senior Developer, Platform Engineers, DevOps, Security Owner.
---
## 1. Architekturueberblick
### 1.1 Ziel
`EnvelopeGenerator.Infrastructure.Crypt` stellt die kryptographische Infrastruktur fuer PDF-Signaturen bereit:
- Signaturerzeugung mit Schluesseln in SoftHSM/PKCS#11
- Zertifikat-Auslese aus Token
- Signaturpruefung auf PDF-Ebene
- Health/Startup-Selbsttest fuer Betriebsfaehigkeit
### 1.2 Topologie (aktueller Betriebsmodus)
Der aktuell implementierte Produktions-nahe Pfad ist:
1. Linux hostet SoftHSM + `pkcs11-daemon` (Proxy-Server).
2. Windows hostet `EnvelopeGenerator.Server`.
3. Windows laedt ein Windows-kompatibles Proxy-PKCS#11-Modul (`libpkcs11-proxy.dll`).
4. PKCS#11-Operationen werden ueber Socket (`PKCS11_PROXY_SOCKET`) an Linux weitergeleitet.
Wichtig:
- Ein Windows-Prozess kann **kein Linux `.so`** direkt laden.
- Private Keys verbleiben im Token (nicht exportierbar).
### 1.3 Laufzeitpfade in der Anwendung
`Pkcs11InteropBootstrap` loest Assets relativ zu `Crypt:Pkcs11:AssetsRootPath` auf:
- Modul: `pkcs11\windows\proxy\libpkcs11-proxy.dll`
- Tool-Fallback: `pkcs11\windows\opensc\tools\pkcs11-tool.exe`
Wenn `AssetsRootPath` leer ist, wird `AppContext.BaseDirectory` verwendet.
---
## 2. Linux-Seite: SoftHSM + Proxy Setup
### 2.1 Basispakete
Beispiel (Debian/Ubuntu):
```bash
sudo apt-get update && sudo apt-get install -y softhsm2 opensc p11-kit libengine-pkcs11-openssl
```
### 2.2 SoftHSM-Modulpfad pruefen
```bash
ldconfig -p | grep softhsm
find /usr -name "libsofthsm2.so" 2>/dev/null
```
### 2.3 Token initialisieren
```bash
softhsm2-util --show-slots
softhsm2-util --init-token --slot <uninitialized-slot> --label CompanyProdToken --so-pin <so-pin> --pin <user-pin>
softhsm2-util --show-slots
```
Hinweis: Nach `--init-token` wird der Slot i. d. R. neu zugewiesen.
### 2.4 Schluesselpaar erzeugen
```bash
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
--slot <slot-id> --login --pin <user-pin> \
--keypairgen --key-type rsa:3072 --label company-sign-key-v1 --id 01
```
### 2.5 Zertifikat importieren
```bash
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
--slot <slot-id> --login --pin <user-pin> \
--write-object ./company-sign-cert-v1.der --type cert --id 01 --label company-sign-cert-v1
```
### 2.6 Objektzustand verifizieren
```bash
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot <slot-id> --login --pin <user-pin> --list-objects
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot <slot-id> --login --pin <user-pin> --list-objects --type cert
```
### 2.7 Proxy-Daemon endpoint stabilisieren
Ein wiederkehrendes Betriebsproblem war ein zufaelliger/local-only Listener. Fix:
```bash
sudo mkdir -p /etc/systemd/system/pkcs11-proxy.service.d
printf "[Service]\nEnvironment=PKCS11_DAEMON_SOCKET=tcp://0.0.0.0:5657\n" | sudo tee /etc/systemd/system/pkcs11-proxy.service.d/override.conf
sudo systemctl daemon-reload
sudo systemctl restart pkcs11-proxy.service
```
Pruefung:
```bash
systemctl show pkcs11-proxy.service -p Environment --no-pager
ss -ltnp | grep 5657
```
---
## 3. Web-API/Server-Seite: Installation und Asset-Bereitstellung
### 3.1 Bezugsquellen der Windows-Artefakte
Fuer den Proxy-Betrieb werden auf Windows benoetigt:
1. Proxy-PKCS#11-DLL (`libpkcs11-proxy.dll`)
2. Abhaengige Runtime-DLLs (z. B. MinGW runtime, je nach Build)
3. `pkcs11-tool.exe` + zugehoerige OpenSC-DLLs fuer Fallback
Beschaffung:
- Build aus derselben `pkcs11-proxy`-Codebasis/Commit wie Linux-Daemon (Kompatibilitaet sicherstellen)
- oder kontrolliert bereitgestellte, intern freigegebene Binaries
### 3.2 Empfohlene Ablage im Repo
Bundled Assets liegen unter:
- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/proxy/`
- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/opensc/tools/`
- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/opensc/pkcs11/`
Diese werden beim Build in den Output kopiert (siehe `.csproj`-Copy-Regeln).
### 3.3 Deployment-Muster
Es gibt zwei gueltige Wege:
1. **Bundled (Dev/QA, schnelle Reproduzierbarkeit)**
- Assets werden mit dem Build ausgeliefert.
- `AssetsRootPath = null` -> Aufloesung via `AppContext.BaseDirectory`.
2. **Zentraler Asset-Root (Prod, betriebliche Kontrolle)**
- Assets in zentralem Pfad (z. B. `E:\DataFiles\Digital Data\EnvelopeGenerator`).
- `AssetsRootPath` in `appsettings.json` auf diesen Root setzen.
### 3.4 Anwendungskonfiguration (`Crypt:Pkcs11`)
Beispiel:
```json
{
"Crypt": {
"Pkcs11": {
"AssetsRootPath": "E:\\DataFiles\\Digital Data\\EnvelopeGenerator",
"SlotId": 2021549410,
"TokenLabel": "CompanyProdToken",
"ProxySocket": "tcp://172.24.12.64:5657",
"UserPin": "<secret>",
"PrivateKeyLabel": "company-sign-key-v1",
"CertificateLabel": "company-sign-cert-v1",
"ApplicationName": "signFLOW",
"DefaultReason": null,
"UseUtcSigningTime": true,
"DefaultCertificationLevel": "NoCertification"
}
}
}
```
Sicherheitsregel:
- `UserPin` niemals in versionierte Dateien einchecken.
- Secrets nur in Secret Store / nicht versionierte `appsettings.Secrets.json` / Vault.
---
## 4. Implementierte Services, Modelle, DTOs
### 4.1 DI und Startverhalten
- `AddCryptInfrastructure(...)` in `DependencyInjection/ServiceCollectionExtensions.cs`
- bindet `Pkcs11Options`
- registriert `IPdfDigitalSignatureService`
- registriert Startup-Self-Test Hosted Service
### 4.2 Konfigurationsmodell
- `Pkcs11Options` (`EnvelopeGenerator.Application/Common/Configurations/Pkcs11Options.cs`)
- `AssetsRootPath`, `SlotId`, `TokenLabel`, `ProxySocket`
- `UserPin`, `PrivateKeyLabel`, `CertificateLabel`
- `ApplicationName`, `DefaultReason`, `UseUtcSigningTime`, `DefaultCertificationLevel`
- `ResolvedLibraryPath`, `ResolvedToolPath` (Runtime-resolved)
### 4.3 Kernservices
1. `PdfDigitalSignatureService`
- Signiert PDF via DevExpress `PdfDeferredSignatureBuilder`
- Nutzt `Pkcs11ExternalSigner` fuer externe Signatur
- Erzwingt `ApplicationName` aus Config (nicht client-overridable)
- Erweitert `Reason` um strukturiertes Metaformat:
- `SFMETA:v1;doc=<uuid>;sha256=<hash>`
- Parse beim Verify und Mapping in DTO
2. `Pkcs11InteropBootstrap`
- Loest Modul-/Toolpfade auf
- Setzt `PKCS11_PROXY_SOCKET`/`PKCS11_DAEMON_SOCKET`
- PATH-Ergaenzung fuer native Abhaengigkeiten
- Warmup via `pkcs11-tool -L`
3. `Pkcs11SessionRunner`
- Interop Library-Load mit mehreren Init-Modi (Fallback-Reihenfolge)
- Slot-Resolution (`SlotId`/`TokenLabel`)
- Session Login/Logout
4. `Pkcs11ToolBridge`
- Fallback via Prozessaufruf von `pkcs11-tool`
- Cert read und DigestInfo-Signatur
5. `Pkcs11StartupSelfTestHostedService`
- Fuehrt beim Start Interop-Probe aus
- Fallback-Probe bei Interop-Fehler
- Schreibt strukturierten Status
### 4.4 Wichtige Interfaces/DTOs
- `IPdfDigitalSignatureService`
- `SignPdfAsync(...)`, `CheckPdfSignatureAsync(...)`, `ExtractOriginalPdfAsync(...)`
- `IPkcs11SelfTestStatusProvider`
- `PdfSignOptionsDto`
- `UUID`, `Reason`, `Name`, `Location`, `Email`, `Phone`, `CertificationLevel`
- `PdfSignatureCheckResultDto` / `PdfSignatureCheckItemDto`
- inklusive `BusinessReason`, `UUID`, `OriginalDocumentSha256`
- `Pkcs11SelfTestStatusDto`
---
## 5. Betriebserfahrungen und Challenges
### 5.1 Interop vs Tool-Verhalten
Beobachtetes Muster in der realen Umgebung:
- `pkcs11-tool` konnte erfolgreich auf Token zugreifen und signieren.
- `Pkcs11Interop` scheiterte mit `CKR_FUNCTION_NOT_SUPPORTED` bzw. fehlendem `C_Initialize` Pointer in bestimmten Modi.
Konsequenz:
- Architektur wurde auf **interop-first + controlled tool-fallback** erweitert.
- Health-Endpunkt differenziert Modus (`interop`, `pkcs11-tool`, `failed`).
### 5.2 Typische Fehlbilder
- `CKR_DEVICE_ERROR` bei Socket-/Proxy-Problemen
- `CRYPT_PROVIDER_UNAVAILABLE` bei Pfad/ABI/Dependency-Mismatch
- `CRYPT_SLOT_NOT_FOUND` bei Slot-/Label-Mismatch
- `CKR_PIN_INCORRECT` bei PIN-Fehler
- 307 Redirects bei HTTP/HTTPS-Mix in manuellen Tests
### 5.3 Plattformfallen
- MSVC vs MinGW Build-Unterschiede fuer Proxy-Client
- fehlende Laufzeit-DLLs (`PATH`/Side-by-side)
- Architektur-Mismatch x86 vs x64
- Daemon-Bindings nicht stabil (random localhost port statt fixed endpoint)
---
## 6. Health, Monitoring und Verifikation
### 6.1 API Health
- Endpoint: `GET /api/health/crypt`
- Liefert:
- `success`, `mode`, `summary`, `interopError`, `toolError`, `checkedAtUtc`
Empfehlung:
- In Monitoring aufnehmen und bei `mode=pkcs11-tool` ueber laengere Zeit Warnung triggern.
### 6.2 Smoke-Test (empfohlen vor Release)
1. `GET /api/health/crypt` -> `success=true`
2. `POST /api/PdfSignature/sign` mit Test-PDF
3. `POST /api/PdfSignature/is-signed`
4. Pruefen:
- `signed=true`
- `allSignaturesCryptographicallyValid=true`
- `UUID`/`OriginalDocumentSha256` korrekt gemappt
---
## 7. Sicherheits- und Betriebsrichtlinien
### 7.1 Geheimnisse
- PIN, SO-PIN, Root-Creds niemals in Doku/Screenshots/Repo.
- Nach Troubleshooting: Credentials rotieren.
### 7.2 Temporare SSH-Freigaben zwingend rueckbauen
Wenn SSH temporaer geoeffnet wurde, MUSS danach Rollback erfolgen:
```bash
sudo systemctl disable --now ssh && sudo ufw delete allow from 172.24.10.11 to any port 22 proto tcp && sudo ufw status verbose && sudo systemctl status ssh --no-pager
```
Optional harte Bereinigung:
```bash
sudo apt-get purge -y openssh-server && sudo apt-get autoremove -y
```
### 7.3 Schluesselrotation
- Neue Labels (`...-v2`) anlegen
- App umkonfigurieren
- Sign/Verify Regression testen
- Altmaterial erst nach Freigabe entfernen
---
## 8. API-Verhalten bei Signaturmetadaten
Aktueller Standard:
- `UUID` kommt aus Sign-Input (nullable string). Wenn ungeltig/leer -> neue Guid wird erzeugt.
- `Reason` wird intern angereichert mit strukturiertem Meta (`SFMETA:v1`).
- `ApplicationName` ist rein serverseitig aus `appsettings` gesetzt.
- `SigningTime` ist rein serverseitig und wird automatisch gesetzt (`UseUtcSigningTime=true` => `UtcNow`, sonst lokale Zeit).
- `CertificationLevel` wird im Controller serverseitig gesetzt (`FormFillingAndAnnotations`), nicht vom Client.
Damit ist das Verhalten deterministisch und manipulationsaermer fuer Integrationsclients.
---
## 9. Troubleshooting-Checkliste (praxisnah)
1. Erreichbarkeit Linux Proxy:
- `Test-NetConnection <linux-host> -Port 5657`
2. Token sichtbar?
- `pkcs11-tool --module <dll> -L -v`
3. Zertifikat lesbar?
- `--read-object --type cert --label <cert-label>`
4. Signatur technisch moeglich?
- `--sign -m SHA256-RSA-PKCS ...`
5. App-Health check:
- `/api/health/crypt`
6. App-sign/verify end-to-end:
- `/api/PdfSignature/sign` + `/api/PdfSignature/is-signed`
Wenn Interop scheitert, aber Tool funktioniert:
- Betrieb bleibt verfuegbar (Fallback),
- aber Incident/Bug fuer Interop-Kompatibilitaet erfassen.
---
## 10. Ausblick / empfohlene Verbesserungen
1. Binary Transport statt Base64 bei grossen PDFs in Render/Sign-Pipelines.
2. Persistente Metriken fuer Fallback-Quote (`interop` vs `tool`).
3. Zertifikatskette/OCSP/CRL Validierung als optionales Policy-Modul.
4. CI-Pruefung auf unsichere SQL-Muster (parallel zum Security-Workstream).
5. Standardisierte Toast/UX-Rueckmeldungen fuer Signierfehler in UI.
---
## 11. Referenzen
- SoftHSMv2: `https://github.com/softhsm/SoftHSMv2`
- OpenSC / pkcs11-tool: `https://github.com/OpenSC/OpenSC/wiki/Using-pkcs11-tool-and-OpenSSL`
- PKCS#11 Standard: `https://www.oasis-open.org/committees/pkcs11/`

View File

@@ -0,0 +1,617 @@
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Enums;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using DevExpress.Office.DigitalSignatures;
using DevExpress.Pdf;
using Microsoft.Extensions.Options;
using System.Formats.Asn1;
using System.Security.Cryptography.Pkcs;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.RegularExpressions;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options) : IPdfDigitalSignatureService
{
private static readonly Regex SignatureReasonMetaRegex = new(@"\|\s*SFMETA:v1;doc=(?<doc>[0-9a-fA-F-]{36});sha256=(?<sha>[A-Fa-f0-9]{64})$", RegexOptions.CultureInvariant | RegexOptions.Compiled);
public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? optionsModel = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(inputPdf);
ArgumentNullException.ThrowIfNull(outputPdf);
cancellationToken.ThrowIfCancellationRequested();
var (workingInput, ownsWorkingInput) = await EnsureSeekableInputAsync(inputPdf, cancellationToken);
try
{
workingInput.Position = 0;
var sourceHashHex = Convert.ToHexString(await SHA256.HashDataAsync(workingInput, cancellationToken));
workingInput.Position = 0;
var pkcs11Options = options.Value;
var request = optionsModel ?? new PdfSignOptionsDto();
var uuid = ResolveUuid(request.UUID);
var reasonWithMeta = BuildReasonWithMeta(request.Reason ?? pkcs11Options.DefaultReason, uuid, sourceHashHex);
using var signer = new PdfDocumentSigner(workingInput);
var digestCalculator = new DigestCalculator(HashAlgorithmType.SHA256);
var signerInfo = new ExternalSignerInfo(PdfSignatureType.Pkcs7, 32768, digestCalculator);
var signatureFieldInfo = new PdfSignatureFieldInfo(1)
{
Name = $"Sig_{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"
};
var builder = new PdfDeferredSignatureBuilder(signerInfo, signatureFieldInfo)
{
Name = request.Name ?? pkcs11Options.CertificateLabel,
Reason = reasonWithMeta,
Location = request.Location,
ContactInfo = BuildContactInfo(request.Email, request.Phone),
SigningTime = pkcs11Options.UseUtcSigningTime ? DateTimeOffset.UtcNow : DateTimeOffset.Now,
ApplicationName = pkcs11Options.ApplicationName,
CertificationLevel = ToDevExpressCertificationLevel(request.CertificationLevel)
?? ToDevExpressCertificationLevel(pkcs11Options.DefaultCertificationLevel)
?? PdfCertificationLevel.NoCertification
};
var deferredSigner = signer.SignDeferred(builder);
var externalSigner = new Pkcs11ExternalSigner(pkcs11Options);
deferredSigner.Sign(outputPdf, externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid));
}
finally
{
if (ownsWorkingInput)
await workingInput.DisposeAsync();
}
}
public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(inputPdf);
ArgumentNullException.ThrowIfNull(outputPdf);
ArgumentNullException.ThrowIfNull(optionsList);
cancellationToken.ThrowIfCancellationRequested();
if (optionsList.Count == 0)
{
await inputPdf.CopyToAsync(outputPdf, cancellationToken);
return;
}
var (currentInput, ownsCurrentInput) = await EnsureSeekableInputAsync(inputPdf, cancellationToken);
try
{
for (var i = 0; i < optionsList.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var isLast = i == optionsList.Count - 1;
if (isLast)
{
await SignPdfAsync(currentInput, outputPdf, optionsList[i], cancellationToken);
continue;
}
var intermediateOutput = new MemoryStream();
await SignPdfAsync(currentInput, intermediateOutput, optionsList[i], cancellationToken);
if (ownsCurrentInput)
await currentInput.DisposeAsync();
intermediateOutput.Position = 0;
currentInput = intermediateOutput;
ownsCurrentInput = true;
}
}
finally
{
if (ownsCurrentInput)
await currentInput.DisposeAsync();
}
}
public async Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(inputPdf);
cancellationToken.ThrowIfCancellationRequested();
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
using var pdfStream = new MemoryStream(pdfBytes);
using var signer = new PdfDocumentSigner(pdfStream);
var signatures = signer.GetSignatureInfo();
var byteRanges = ParseByteRanges(pdfBytes);
if (signatures is null || signatures.Count == 0)
{
return new PdfSignatureCheckResultDto(
Signed: false,
Signatures: []);
}
var items = new List<PdfSignatureCheckItemDto>(signatures.Count);
for (var i = 0; i < signatures.Count; i++)
{
var signature = signatures[i];
try
{
var pkcs7 = signer.GetPdfPkcs7Signature(signature.FieldName);
bool valid = pkcs7.VerifySignature();
var byteRange = i < byteRanges.Count ? byteRanges[i] : null;
var coversWholeDocument = CoversWholeDocument(byteRange, pdfBytes.LongLength);
var certificate = pkcs7.GetSignatureCertificate();
var issuedTo = ParsePrincipal(certificate?.Subject);
var issuedBy = ParsePrincipal(certificate?.Issuer);
var reasonMeta = ParseReasonMeta(signature.Reason);
var contact = ParseContactInfo(signature.ContactInfo);
items.Add(new PdfSignatureCheckItemDto(
CoversWholeDoc: coversWholeDocument,
Reason: signature.Reason,
BusinessReason: reasonMeta.BusinessReason,
Location: signature.Location,
DeclaredName: signature.SignerName,
ContactInfo: signature.ContactInfo,
Email: contact.Email,
Phone: contact.Phone,
DigestAlgorithm: InferDigestAlgorithm(certificate),
SignatureAlgorithm: InferSignatureAlgorithm(certificate),
SignerName: issuedTo.CommonName ?? signature.SignerName,
Issuer: issuedBy.Full,
SerialNumber: ToColonSerial(certificate?.SerialNumber),
ValidFrom: certificate is null ? null : new DateTimeOffset(certificate.NotBefore).ToUniversalTime(),
ValidTo: certificate is null ? null : new DateTimeOffset(certificate.NotAfter).ToUniversalTime(),
ExpiryCheckedAgainst: "now",
Certificates: [BuildCertificateInfo(certificate, DateTimeOffset.UtcNow)],
Unchanged: valid && coversWholeDocument,
FieldName: signature.FieldName,
IsCryptographicallyValid: valid,
SigningTime: signature.SigningTime?.ToUniversalTime().ToString("O"),
Filter: signature.Filter,
SubFilter: signature.SubFilter,
ApplicationName: null,
UUID: reasonMeta.UUID,
OriginalDocumentSha256: reasonMeta.OriginalDocumentSha256,
MessageDigestHex: TryExtractMessageDigestHex(signature.Contents),
Error: null));
}
catch (Exception ex)
{
var reasonMeta = ParseReasonMeta(signature.Reason);
var contact = ParseContactInfo(signature.ContactInfo);
items.Add(new PdfSignatureCheckItemDto(
CoversWholeDoc: false,
Reason: signature.Reason,
BusinessReason: reasonMeta.BusinessReason,
Location: signature.Location,
DeclaredName: signature.SignerName,
ContactInfo: signature.ContactInfo,
Email: contact.Email,
Phone: contact.Phone,
DigestAlgorithm: null,
SignatureAlgorithm: null,
SignerName: signature.SignerName,
Issuer: null,
SerialNumber: null,
ValidFrom: null,
ValidTo: null,
ExpiryCheckedAgainst: "now",
Certificates: [],
Unchanged: false,
FieldName: signature.FieldName,
IsCryptographicallyValid: false,
SigningTime: signature.SigningTime?.ToUniversalTime().ToString("O"),
Filter: signature.Filter,
SubFilter: signature.SubFilter,
ApplicationName: null,
UUID: reasonMeta.UUID,
OriginalDocumentSha256: reasonMeta.OriginalDocumentSha256,
MessageDigestHex: null,
Error: ex.Message));
}
}
return new PdfSignatureCheckResultDto(
Signed: true,
Signatures: items);
}
public async Task ExtractOriginalPdfAsync(Stream inputPdf, Stream outputPdf, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(inputPdf);
ArgumentNullException.ThrowIfNull(outputPdf);
cancellationToken.ThrowIfCancellationRequested();
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
var byteRanges = ParseByteRanges(pdfBytes);
if (byteRanges.Count == 0)
{
await outputPdf.WriteAsync(pdfBytes, cancellationToken);
return;
}
var revisionLength = byteRanges
.Select(GetSignedRevisionLength)
.Where(x => x > 0)
.DefaultIfEmpty(pdfBytes.LongLength)
.Min();
if (revisionLength > pdfBytes.LongLength)
throw new InvalidOperationException("Invalid ByteRange in signed PDF.");
await outputPdf.WriteAsync(pdfBytes.AsMemory(0, (int)revisionLength), cancellationToken);
}
private static async Task<(Stream Stream, bool OwnsStream)> EnsureSeekableInputAsync(Stream input, CancellationToken cancellationToken)
{
if (input.CanSeek)
{
input.Position = 0;
return (input, false);
}
var buffered = new MemoryStream();
await input.CopyToAsync(buffered, cancellationToken);
buffered.Position = 0;
return (buffered, true);
}
private static async Task<byte[]> ReadAllBytesAsync(Stream input, CancellationToken cancellationToken)
{
if (input is MemoryStream memory && memory.TryGetBuffer(out var buffer))
return buffer.AsSpan(0, (int)memory.Length).ToArray();
if (input.CanSeek)
input.Position = 0;
using var ms = new MemoryStream();
await input.CopyToAsync(ms, cancellationToken);
return ms.ToArray();
}
private static PdfSignatureCertificateInfoDto BuildCertificateInfo(X509Certificate2? certificate, DateTimeOffset now)
{
if (certificate is null)
{
return new PdfSignatureCertificateInfoDto(
IssuedTo: new PdfSignaturePrincipalDto(null, null, null, null),
IssuedBy: new PdfSignaturePrincipalDto(null, null, null, null),
SerialNumber: null,
ValidFrom: null,
ValidTo: null,
SelfSigned: false,
Expired: false,
NotYetValid: false,
IsSigner: true,
Version: null,
KeyAlgorithm: null,
SignatureAlgorithm: null,
Pem: null,
KeySizeBits: null);
}
var issuedTo = ParsePrincipal(certificate.Subject);
var issuedBy = ParsePrincipal(certificate.Issuer);
var validFrom = new DateTimeOffset(certificate.NotBefore).ToUniversalTime();
var validTo = new DateTimeOffset(certificate.NotAfter).ToUniversalTime();
return new PdfSignatureCertificateInfoDto(
IssuedTo: issuedTo,
IssuedBy: issuedBy,
SerialNumber: ToColonSerial(certificate.SerialNumber),
ValidFrom: validFrom,
ValidTo: validTo,
SelfSigned: string.Equals(certificate.Subject, certificate.Issuer, StringComparison.OrdinalIgnoreCase),
Expired: validTo < now,
NotYetValid: validFrom > now,
IsSigner: true,
Version: certificate.Version,
KeyAlgorithm: certificate.PublicKey.Oid?.FriendlyName ?? certificate.PublicKey.Oid?.Value,
SignatureAlgorithm: certificate.SignatureAlgorithm.FriendlyName ?? certificate.SignatureAlgorithm.Value,
Pem: ToPem(certificate.RawData),
KeySizeBits: TryGetKeySize(certificate));
}
private static int? TryGetKeySize(X509Certificate2 certificate)
{
try
{
return certificate.PublicKey.Key.KeySize;
}
catch
{
return null;
}
}
private static string? InferDigestAlgorithm(X509Certificate2? certificate)
{
if (certificate?.SignatureAlgorithm is null)
return null;
var source = (certificate.SignatureAlgorithm.FriendlyName ?? certificate.SignatureAlgorithm.Value ?? string.Empty).ToUpperInvariant();
if (source.Contains("SHA512", StringComparison.Ordinal)) return "SHA-512";
if (source.Contains("SHA384", StringComparison.Ordinal)) return "SHA-384";
if (source.Contains("SHA256", StringComparison.Ordinal)) return "SHA-256";
if (source.Contains("SHA1", StringComparison.Ordinal)) return "SHA-1";
return certificate.SignatureAlgorithm.FriendlyName ?? certificate.SignatureAlgorithm.Value;
}
private static string? InferSignatureAlgorithm(X509Certificate2? certificate)
{
var oidName = certificate?.PublicKey.Oid?.FriendlyName?.ToUpperInvariant();
if (oidName is not null && oidName.Contains("RSA", StringComparison.Ordinal))
return "RSA";
if (oidName is not null && oidName.Contains("ECDSA", StringComparison.Ordinal))
return "ECDSA";
return certificate?.PublicKey.Oid?.FriendlyName ?? certificate?.PublicKey.Oid?.Value;
}
private static PdfSignaturePrincipalDto ParsePrincipal(string? dn)
{
if (string.IsNullOrWhiteSpace(dn))
return new PdfSignaturePrincipalDto(null, null, null, dn);
string? commonName = null;
string? organization = null;
string? country = null;
foreach (var part in dn.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var equalsIndex = part.IndexOf('=');
if (equalsIndex <= 0 || equalsIndex >= part.Length - 1)
continue;
var key = part[..equalsIndex].Trim();
var value = part[(equalsIndex + 1)..].Trim();
if (key.Equals("CN", StringComparison.OrdinalIgnoreCase)) commonName = value;
else if (key.Equals("O", StringComparison.OrdinalIgnoreCase)) organization = value;
else if (key.Equals("C", StringComparison.OrdinalIgnoreCase)) country = value;
}
return new PdfSignaturePrincipalDto(commonName, organization, country, dn);
}
private static PdfCertificationLevel? ToDevExpressCertificationLevel(PdfSignatureCertificationLevel? value)
{
return value is null ? null : (PdfCertificationLevel)(int)value.Value;
}
private static string? ToColonSerial(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return null;
var normalized = raw.Replace(":", string.Empty, StringComparison.Ordinal).Trim();
if (normalized.Length % 2 != 0)
return normalized;
return string.Join(':', Enumerable.Range(0, normalized.Length / 2).Select(i => normalized.Substring(i * 2, 2).ToUpperInvariant()));
}
private static string ToPem(byte[] der)
{
var base64 = Convert.ToBase64String(der, Base64FormattingOptions.InsertLineBreaks);
var sb = new StringBuilder();
sb.AppendLine("-----BEGIN CERTIFICATE-----");
sb.AppendLine(base64);
sb.Append("-----END CERTIFICATE-----");
return sb.ToString();
}
private static string? TryExtractMessageDigestHex(byte[]? signatureContents)
{
if (signatureContents is null || signatureContents.Length == 0)
return null;
try
{
var cms = new SignedCms();
cms.Decode(signatureContents);
var signerInfo = cms.SignerInfos.Count > 0 ? cms.SignerInfos[0] : null;
if (signerInfo is null)
return null;
foreach (CryptographicAttributeObject attr in signerInfo.SignedAttributes)
{
if (!string.Equals(attr.Oid?.Value, "1.2.840.113549.1.9.4", StringComparison.Ordinal))
continue;
foreach (var value in attr.Values)
{
var octets = TryReadOctetString(value.RawData);
if (octets is not null && octets.Length > 0)
return Convert.ToHexString(octets);
}
}
}
catch
{
return null;
}
return null;
}
private static byte[]? TryReadOctetString(byte[] rawData)
{
try
{
var reader = new AsnReader(rawData, AsnEncodingRules.BER);
var octets = reader.ReadOctetString();
if (reader.HasData)
return null;
return octets;
}
catch
{
return null;
}
}
private static List<long[]> ParseByteRanges(byte[] pdfBytes)
{
var text = Encoding.Latin1.GetString(pdfBytes);
var matches = Regex.Matches(text, @"/ByteRange\s*\[(?<values>[\d\s]+)\]", RegexOptions.CultureInvariant);
var ranges = new List<long[]>(matches.Count);
foreach (Match match in matches)
{
var raw = match.Groups["values"].Value;
var parts = raw.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
var values = new long[parts.Length];
var ok = true;
for (var i = 0; i < parts.Length; i++)
{
if (!long.TryParse(parts[i], out values[i]))
{
ok = false;
break;
}
}
if (ok && values.Length >= 4 && values.Length % 2 == 0)
ranges.Add(values);
}
return ranges;
}
private static bool CoversWholeDocument(long[]? byteRange, long fileLength)
{
if (byteRange is null || byteRange.Length < 4 || byteRange.Length % 2 != 0)
return false;
if (byteRange[0] != 0)
return false;
long previousEnd = -1;
for (var i = 0; i < byteRange.Length; i += 2)
{
var start = byteRange[i];
var length = byteRange[i + 1];
if (start < 0 || length < 0)
return false;
var end = start + length;
if (previousEnd > start)
return false;
previousEnd = end;
}
return previousEnd == fileLength;
}
private static long GetSignedRevisionLength(long[] byteRange)
{
if (byteRange.Length < 2)
return -1;
var start = byteRange[^2];
var length = byteRange[^1];
if (start < 0 || length < 0)
return -1;
return start + length;
}
private static string ResolveUuid(string? uuid)
{
return Guid.TryParse(uuid, out var parsed)
? parsed.ToString()
: Guid.NewGuid().ToString();
}
private static string BuildReasonWithMeta(string? businessReason, string uuid, string sourceHashHex)
{
var trimmedReason = string.IsNullOrWhiteSpace(businessReason) ? null : businessReason.Trim();
var prefix = string.IsNullOrWhiteSpace(trimmedReason) ? string.Empty : trimmedReason + " ";
return $"{prefix}| SFMETA:v1;doc={uuid};sha256={sourceHashHex}";
}
private static string? BuildContactInfo(string? email, string? phone)
{
var normalizedEmail = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
var normalizedPhone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim();
if (normalizedEmail is null && normalizedPhone is null)
return null;
if (normalizedEmail is null)
return $"tel:{normalizedPhone}";
if (normalizedPhone is null)
return $"mailto:{normalizedEmail}";
return $"mailto:{normalizedEmail};tel:{normalizedPhone}";
}
private static (string? Email, string? Phone) ParseContactInfo(string? contactInfo)
{
if (string.IsNullOrWhiteSpace(contactInfo))
return (null, null);
string? email = null;
string? phone = null;
foreach (var part in contactInfo.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (part.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
{
email = part[7..].Trim();
continue;
}
if (part.StartsWith("tel:", StringComparison.OrdinalIgnoreCase))
{
phone = part[4..].Trim();
continue;
}
if (email is null && part.Contains('@', StringComparison.Ordinal))
{
email = part.Trim();
continue;
}
if (phone is null)
phone = part.Trim();
}
return (
string.IsNullOrWhiteSpace(email) ? null : email,
string.IsNullOrWhiteSpace(phone) ? null : phone);
}
private static (string? BusinessReason, string? UUID, string? OriginalDocumentSha256) ParseReasonMeta(string? reason)
{
if (string.IsNullOrWhiteSpace(reason))
return (reason, null, null);
var match = SignatureReasonMetaRegex.Match(reason);
if (!match.Success)
return (reason, null, null);
var businessReason = reason[..match.Index].Trim();
if (string.IsNullOrWhiteSpace(businessReason))
businessReason = null;
return (
businessReason,
match.Groups["doc"].Value,
match.Groups["sha"].Value.ToUpperInvariant());
}
}

View File

@@ -0,0 +1,43 @@
using System.Security.Cryptography;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
internal static class Pkcs11DigestInfoBuilder
{
public static byte[] Build(byte[] hash, HashAlgorithmName hashAlgorithm)
{
byte[] prefix = hashAlgorithm.Name switch
{
"SHA256" =>
[
0x30, 0x31,
0x30, 0x0d,
0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01,
0x05, 0x00,
0x04, 0x20
],
"SHA384" =>
[
0x30, 0x41,
0x30, 0x0d,
0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x02,
0x05, 0x00,
0x04, 0x30
],
"SHA512" =>
[
0x30, 0x51,
0x30, 0x0d,
0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x03,
0x05, 0x00,
0x04, 0x40
],
_ => throw new NotSupportedException($"Hash algorithm '{hashAlgorithm.Name}' is not supported.")
};
var digestInfo = new byte[prefix.Length + hash.Length];
Buffer.BlockCopy(prefix, 0, digestInfo, 0, prefix.Length);
Buffer.BlockCopy(hash, 0, digestInfo, prefix.Length, hash.Length);
return digestInfo;
}
}

View File

@@ -0,0 +1,49 @@
using EnvelopeGenerator.Application.Common.Configurations;
using DevExpress.Office.DigitalSignatures;
using DevExpress.Pdf;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
internal sealed class Pkcs11ExternalSigner(Pkcs11Options options) : Pkcs7SignerBase
{
private readonly Pkcs11PdfSigningRsa rsa = new(options);
private readonly DigestCalculator digestCalculator = new(HashAlgorithmType.SHA256);
private readonly IReadOnlyList<byte[]> certificates = [ReadCertificateBytesWithFallback(options)];
protected override IDigestCalculator DigestCalculator => digestCalculator;
protected override string SigningAlgorithmOID => "1.2.840.113549.1.1.1";
protected override IEnumerable<byte[]> GetCertificates() => certificates;
protected override byte[] SignDigest(byte[] digest)
{
return rsa.SignHash(digest, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1);
}
private static byte[] ReadCertificateBytesWithFallback(Pkcs11Options options)
{
try
{
return Pkcs11SessionRunner.Run(options, session =>
{
var attrs = new List<Net.Pkcs11Interop.HighLevelAPI.IObjectAttribute>
{
session.Factories.ObjectAttributeFactory.Create(Net.Pkcs11Interop.Common.CKA.CKA_CLASS, Net.Pkcs11Interop.Common.CKO.CKO_CERTIFICATE),
session.Factories.ObjectAttributeFactory.Create(Net.Pkcs11Interop.Common.CKA.CKA_LABEL, options.CertificateLabel)
};
var handles = session.FindAllObjects(attrs);
if (handles.Count == 0)
throw new InvalidOperationException($"Certificate '{options.CertificateLabel}' not found in token.");
var values = session.GetAttributeValue(handles[0], new List<Net.Pkcs11Interop.Common.CKA> { Net.Pkcs11Interop.Common.CKA.CKA_VALUE });
return values[0].GetValueAsByteArray();
});
}
catch when (Pkcs11ToolBridge.IsAvailable(options))
{
return Pkcs11ToolBridge.ReadCertificateBytes(options);
}
}
}

View File

@@ -0,0 +1,119 @@
using EnvelopeGenerator.Application.Common.Configurations;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
internal static class Pkcs11InteropBootstrap
{
private static readonly object Sync = new();
private static bool Initialized;
private const string ProxyRelativePath = "pkcs11\\windows\\proxy\\libpkcs11-proxy.dll";
private const string ToolRelativePath = "pkcs11\\windows\\opensc\\tools\\pkcs11-tool.exe";
public static void EnsureLoaded(Pkcs11Options options)
{
if (Initialized)
return;
lock (Sync)
{
if (Initialized)
return;
var libraryPath = ResolveLibraryPath(options);
var toolPath = ResolveToolPath(options);
options.SetResolvedPaths(libraryPath, toolPath);
EnsureNativeDependencySearchPath(options.ResolvedLibraryPath);
if (!string.IsNullOrWhiteSpace(options.ProxySocket))
{
Environment.SetEnvironmentVariable("PKCS11_PROXY_SOCKET", options.ProxySocket);
Environment.SetEnvironmentVariable("PKCS11_DAEMON_SOCKET", options.ProxySocket);
}
if (!string.IsNullOrWhiteSpace(options.ResolvedToolPath))
{
var toolDir = Path.GetDirectoryName(options.ResolvedToolPath);
if (!string.IsNullOrWhiteSpace(toolDir))
EnsurePathContains(toolDir);
}
WarmupWithPkcs11Tool(options);
Initialized = true;
}
}
private static string ResolveLibraryPath(Pkcs11Options options)
{
var assetsRoot = string.IsNullOrWhiteSpace(options.AssetsRootPath)
? AppContext.BaseDirectory
: options.AssetsRootPath;
var resolved = Path.Combine(assetsRoot, ProxyRelativePath);
if (File.Exists(resolved))
return resolved;
throw new FileNotFoundException($"PKCS#11 proxy module not found. Checked path: {resolved}", resolved);
}
private static string? ResolveToolPath(Pkcs11Options options)
{
var assetsRoot = string.IsNullOrWhiteSpace(options.AssetsRootPath)
? AppContext.BaseDirectory
: options.AssetsRootPath;
var resolved = Path.Combine(assetsRoot, ToolRelativePath);
if (File.Exists(resolved))
return resolved;
return null;
}
private static void WarmupWithPkcs11Tool(Pkcs11Options options)
{
if (string.IsNullOrWhiteSpace(options.ResolvedToolPath))
return;
if (!File.Exists(options.ResolvedToolPath))
return;
var process = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = options.ResolvedToolPath,
Arguments = $"--module \"{options.ResolvedLibraryPath}\" -L",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
}
};
process.Start();
process.WaitForExit(15000);
}
private static void EnsureNativeDependencySearchPath(string libraryPath)
{
var directory = Path.GetDirectoryName(libraryPath);
if (string.IsNullOrWhiteSpace(directory))
return;
EnsurePathContains(directory);
}
private static void EnsurePathContains(string directory)
{
var currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
var entries = currentPath.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (entries.Any(p => string.Equals(p, directory, StringComparison.OrdinalIgnoreCase)))
return;
var updatedPath = string.IsNullOrWhiteSpace(currentPath)
? directory
: directory + ";" + currentPath;
Environment.SetEnvironmentVariable("PATH", updatedPath);
}
}

View File

@@ -0,0 +1,64 @@
using EnvelopeGenerator.Application.Common.Configurations;
using Net.Pkcs11Interop.Common;
using Net.Pkcs11Interop.HighLevelAPI;
using System.Security.Cryptography;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
internal sealed class Pkcs11PdfSigningRsa(Pkcs11Options options) : RSA
{
public override int KeySize
{
get => KeySizeValue;
set => KeySizeValue = value;
}
public override KeySizes[] LegalKeySizes => [new(1024, 8192, 8)];
public override string? KeyExchangeAlgorithm => "RSA";
public override string SignatureAlgorithm => "RSA";
public override byte[] SignHash(byte[] hash, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding)
{
if (padding != RSASignaturePadding.Pkcs1)
throw new NotSupportedException("Only RSA PKCS#1 v1.5 padding is supported.");
byte[] digestInfo = Pkcs11DigestInfoBuilder.Build(hash, hashAlgorithm);
try
{
return Pkcs11SessionRunner.Run(options, session =>
{
var attrs = new List<IObjectAttribute>
{
session.Factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, CKO.CKO_PRIVATE_KEY),
session.Factories.ObjectAttributeFactory.Create(CKA.CKA_LABEL, options.PrivateKeyLabel)
};
var handles = session.FindAllObjects(attrs);
if (handles.Count == 0)
throw new InvalidOperationException($"Private key '{options.PrivateKeyLabel}' not found in token.");
var mechanism = session.Factories.MechanismFactory.Create(CKM.CKM_RSA_PKCS);
return session.Sign(mechanism, handles[0], digestInfo);
});
}
catch when (Pkcs11ToolBridge.IsAvailable(options))
{
return Pkcs11ToolBridge.SignDigestInfo(options, digestInfo);
}
}
public override bool VerifyHash(byte[] hash, byte[] signature, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding)
=> throw new NotSupportedException();
public override byte[] Decrypt(byte[] data, RSAEncryptionPadding padding)
=> throw new NotSupportedException();
public override byte[] Encrypt(byte[] data, RSAEncryptionPadding padding)
=> throw new NotSupportedException();
public override RSAParameters ExportParameters(bool includePrivateParameters)
=> throw new NotSupportedException();
public override void ImportParameters(RSAParameters parameters)
=> throw new NotSupportedException();
}

View File

@@ -0,0 +1,24 @@
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using System.Threading;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
internal sealed class Pkcs11SelfTestStatusStore : IPkcs11SelfTestStatusProvider
{
private Pkcs11SelfTestStatusDto _status = new(
Completed: false,
Success: false,
Mode: "pending",
Summary: "PKCS#11 startup self-test has not run yet.",
CheckedAtUtc: null,
InteropError: null,
ToolError: null);
public Pkcs11SelfTestStatusDto GetStatus() => Volatile.Read(ref _status);
public void SetStatus(Pkcs11SelfTestStatusDto status)
{
Volatile.Write(ref _status, status);
}
}

View File

@@ -0,0 +1,124 @@
using EnvelopeGenerator.Application.Common.Configurations;
using Net.Pkcs11Interop.Common;
using Net.Pkcs11Interop.HighLevelAPI;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
internal static class Pkcs11SessionRunner
{
public static T Run<T>(Pkcs11Options options, Func<ISession, T> action)
{
Pkcs11InteropBootstrap.EnsureLoaded(options);
var factories = new Pkcs11InteropFactories();
using var pkcs11 = LoadLibraryWithFallback(factories, options.ResolvedLibraryPath, options.ProxySocket);
var slot = ResolveSlot(pkcs11.GetSlotList(SlotsType.WithTokenPresent), options);
using var session = slot.OpenSession(SessionType.ReadOnly);
session.Login(CKU.CKU_USER, options.UserPin);
try
{
return action(session);
}
finally
{
session.Logout();
}
}
private static IPkcs11Library LoadLibraryWithFallback(Pkcs11InteropFactories factories, string libraryPath, string? proxySocket)
{
List<Exception> errors = [];
foreach (string candidate in BuildLibraryCandidates(libraryPath))
{
try
{
return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.MultiThreaded);
}
catch (Exception ex)
{
errors.Add(new InvalidOperationException($"{candidate} | MultiThreaded+WithFunctionList", ex));
}
try
{
return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.SingleThreaded);
}
catch (Exception ex)
{
errors.Add(new InvalidOperationException($"{candidate} | SingleThreaded+WithFunctionList", ex));
}
try
{
return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.MultiThreaded, InitType.WithoutFunctionList);
}
catch (Exception ex)
{
errors.Add(new InvalidOperationException($"{candidate} | MultiThreaded+WithoutFunctionList", ex));
}
try
{
return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.SingleThreaded, InitType.WithoutFunctionList);
}
catch (Exception ex)
{
errors.Add(new InvalidOperationException($"{candidate} | SingleThreaded+WithoutFunctionList", ex));
}
}
var socketInfo = string.IsNullOrWhiteSpace(proxySocket) ? "<empty>" : proxySocket;
throw new AggregateException($"Unable to initialize PKCS#11 library '{libraryPath}' with any supported initialization mode. PKCS11_PROXY_SOCKET={socketInfo}", errors);
}
private static IEnumerable<string> BuildLibraryCandidates(string configuredPath)
{
var unique = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
configuredPath
};
var directory = Path.GetDirectoryName(configuredPath);
if (!string.IsNullOrWhiteSpace(directory))
{
string[] alternates =
[
"client.dll",
"pkcs11-proxy.dll",
"libpkcs11-proxy.dll"
];
foreach (var fileName in alternates)
{
var candidate = Path.Combine(directory, fileName);
if (File.Exists(candidate))
unique.Add(candidate);
}
}
return unique;
}
private static ISlot ResolveSlot(IEnumerable<ISlot> slots, Pkcs11Options options)
{
if (options.SlotId.HasValue)
{
var bySlotId = slots.FirstOrDefault(s => s.SlotId == options.SlotId.Value);
if (bySlotId is not null)
return bySlotId;
}
if (!string.IsNullOrWhiteSpace(options.TokenLabel))
{
var byTokenLabel = slots.FirstOrDefault(s =>
string.Equals(s.GetTokenInfo().Label?.Trim(), options.TokenLabel.Trim(), StringComparison.Ordinal));
if (byTokenLabel is not null)
return byTokenLabel;
}
throw new InvalidOperationException("Unable to resolve PKCS#11 slot. Check Crypt:Pkcs11:SlotId / TokenLabel.");
}
}

View File

@@ -0,0 +1,112 @@
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Dto;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Net.Pkcs11Interop.Common;
using Net.Pkcs11Interop.HighLevelAPI;
using System.Security.Cryptography;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
internal sealed class Pkcs11StartupSelfTestHostedService(
IOptions<Pkcs11Options> options,
Pkcs11SelfTestStatusStore statusStore,
ILogger<Pkcs11StartupSelfTestHostedService> logger) : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
{
var pkcs11 = options.Value;
try
{
RunInteropProbe(pkcs11);
statusStore.SetStatus(new Pkcs11SelfTestStatusDto(
Completed: true,
Success: true,
Mode: "interop",
Summary: "PKCS#11 startup self-test succeeded via interop.",
CheckedAtUtc: DateTimeOffset.UtcNow,
InteropError: null,
ToolError: null));
logger.LogInformation("PKCS#11 startup self-test succeeded (mode=interop, library={LibraryPath}, proxy={ProxySocket}).", pkcs11.ResolvedLibraryPath, pkcs11.ProxySocket);
return Task.CompletedTask;
}
catch (Exception interopEx) when (Pkcs11ToolBridge.IsAvailable(pkcs11))
{
try
{
RunToolProbe(pkcs11);
statusStore.SetStatus(new Pkcs11SelfTestStatusDto(
Completed: true,
Success: true,
Mode: "pkcs11-tool",
Summary: "PKCS#11 interop failed, tool fallback succeeded.",
CheckedAtUtc: DateTimeOffset.UtcNow,
InteropError: interopEx.Message,
ToolError: null));
logger.LogWarning(interopEx, "PKCS#11 interop self-test failed, but tool fallback is healthy (mode=pkcs11-tool, tool={ToolPath}).", pkcs11.ResolvedToolPath);
return Task.CompletedTask;
}
catch (Exception toolEx)
{
statusStore.SetStatus(new Pkcs11SelfTestStatusDto(
Completed: true,
Success: false,
Mode: "failed",
Summary: "PKCS#11 startup self-test failed for both interop and tool fallback.",
CheckedAtUtc: DateTimeOffset.UtcNow,
InteropError: interopEx.Message,
ToolError: toolEx.Message));
logger.LogError(toolEx, "PKCS#11 startup self-test failed for both interop and tool fallback. library={LibraryPath}, proxy={ProxySocket}, tool={ToolPath}", pkcs11.ResolvedLibraryPath, pkcs11.ProxySocket, pkcs11.ResolvedToolPath);
return Task.CompletedTask;
}
}
catch (Exception ex)
{
statusStore.SetStatus(new Pkcs11SelfTestStatusDto(
Completed: true,
Success: false,
Mode: "failed",
Summary: "PKCS#11 startup self-test failed via interop and no tool fallback is available.",
CheckedAtUtc: DateTimeOffset.UtcNow,
InteropError: ex.Message,
ToolError: null));
logger.LogError(ex, "PKCS#11 startup self-test failed (mode=interop, no fallback available). library={LibraryPath}, proxy={ProxySocket}", pkcs11.ResolvedLibraryPath, pkcs11.ProxySocket);
return Task.CompletedTask;
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private static void RunInteropProbe(Pkcs11Options options)
{
Pkcs11SessionRunner.Run(options, session =>
{
EnsureObjectExists(session, CKO.CKO_CERTIFICATE, options.CertificateLabel, "certificate");
EnsureObjectExists(session, CKO.CKO_PRIVATE_KEY, options.PrivateKeyLabel, "private key");
return true;
});
}
private static void RunToolProbe(Pkcs11Options options)
{
_ = Pkcs11ToolBridge.ReadCertificateBytes(options);
var testDigest = SHA256.HashData([1, 2, 3, 4]);
var digestInfo = Pkcs11DigestInfoBuilder.Build(testDigest, HashAlgorithmName.SHA256);
_ = Pkcs11ToolBridge.SignDigestInfo(options, digestInfo);
}
private static void EnsureObjectExists(ISession session, CKO objectClass, string label, string objectType)
{
var attrs = new List<IObjectAttribute>
{
session.Factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, objectClass),
session.Factories.ObjectAttributeFactory.Create(CKA.CKA_LABEL, label)
};
var handles = session.FindAllObjects(attrs);
if (handles.Count == 0)
throw new InvalidOperationException($"PKCS#11 {objectType} '{label}' not found in token.");
}
}

View File

@@ -0,0 +1,147 @@
using EnvelopeGenerator.Application.Common.Configurations;
using System.Diagnostics;
using System.Security.Cryptography;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
internal static class Pkcs11ToolBridge
{
public static bool IsAvailable(Pkcs11Options options)
{
return !string.IsNullOrWhiteSpace(options.ResolvedToolPath) && File.Exists(options.ResolvedToolPath);
}
public static byte[] ReadCertificateBytes(Pkcs11Options options)
{
EnsureAvailable(options);
var outputFile = Path.Combine(Path.GetTempPath(), $"pkcs11-cert-{Guid.NewGuid():N}.der");
try
{
var args = CreateBaseArgs(options);
args.Add("--read-object");
args.Add("--type");
args.Add("cert");
args.Add("--label");
args.Add(options.CertificateLabel);
args.Add("--output-file");
args.Add(outputFile);
Execute(options, args);
return File.ReadAllBytes(outputFile);
}
finally
{
DeleteIfExists(outputFile);
}
}
public static byte[] SignDigestInfo(Pkcs11Options options, byte[] digestInfo)
{
EnsureAvailable(options);
var inputFile = Path.Combine(Path.GetTempPath(), $"pkcs11-sign-in-{Guid.NewGuid():N}.bin");
var outputFile = Path.Combine(Path.GetTempPath(), $"pkcs11-sign-out-{Guid.NewGuid():N}.bin");
try
{
File.WriteAllBytes(inputFile, digestInfo);
var args = CreateBaseArgs(options);
args.Add("--sign");
args.Add("--mechanism");
args.Add("RSA-PKCS");
args.Add("--type");
args.Add("privkey");
args.Add("--label");
args.Add(options.PrivateKeyLabel);
args.Add("--input-file");
args.Add(inputFile);
args.Add("--output-file");
args.Add(outputFile);
Execute(options, args);
return File.ReadAllBytes(outputFile);
}
finally
{
DeleteIfExists(inputFile);
DeleteIfExists(outputFile);
}
}
private static List<string> CreateBaseArgs(Pkcs11Options options)
{
var args = new List<string>
{
"--module",
options.ResolvedLibraryPath,
"--login",
"--pin",
options.UserPin
};
if (options.SlotId.HasValue)
{
args.Add("--slot");
args.Add(options.SlotId.Value.ToString());
}
else if (!string.IsNullOrWhiteSpace(options.TokenLabel))
{
args.Add("--token-label");
args.Add(options.TokenLabel);
}
return args;
}
private static void Execute(Pkcs11Options options, IReadOnlyList<string> arguments)
{
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = options.ResolvedToolPath!,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
}
};
foreach (var argument in arguments)
process.StartInfo.ArgumentList.Add(argument);
if (!string.IsNullOrWhiteSpace(options.ProxySocket))
{
process.StartInfo.Environment["PKCS11_PROXY_SOCKET"] = options.ProxySocket;
process.StartInfo.Environment["PKCS11_DAEMON_SOCKET"] = options.ProxySocket;
}
process.Start();
string stdOut = process.StandardOutput.ReadToEnd();
string stdErr = process.StandardError.ReadToEnd();
if (!process.WaitForExit(30000))
{
try { process.Kill(entireProcessTree: true); } catch { }
throw new TimeoutException($"pkcs11-tool timed out. Args: {string.Join(' ', process.StartInfo.ArgumentList)}");
}
if (process.ExitCode != 0)
throw new InvalidOperationException($"pkcs11-tool failed with exit code {process.ExitCode}. StdOut: {stdOut} StdErr: {stdErr}");
}
private static void EnsureAvailable(Pkcs11Options options)
{
if (!IsAvailable(options))
throw new InvalidOperationException("pkcs11-tool is not available at resolved pkcs11 assets path.");
}
private static void DeleteIfExists(string path)
{
if (File.Exists(path))
File.Delete(path);
}
}

View File

@@ -0,0 +1,301 @@
# FES Signature Infrastructure Plan
## 1. Goal
Build a reusable and modular cryptography infrastructure in `EnvelopeGenerator.Infrastructure.Crypt` to support FES (Advanced Electronic Signature) workflows.
This module must:
- use PKCS#11 providers (SoftHSMv2 first, real HSM later),
- stay independent from UI/API concerns,
- expose clean interfaces to `Application` layer use-cases,
- support future extensions (timestamping, certificate chain validation, multiple key algorithms).
---
## 2. Context and Constraints
1. SoftHSMv2 is **not** a REST service. It is a PKCS#11 module loaded as a native library.
2. `EnvelopeGenerator.Server` is presentation only; cryptographic implementation belongs to infrastructure.
3. Clean Architecture boundaries must be respected:
- `Application` depends on abstractions.
- `Infrastructure.Crypt` implements abstractions.
4. Initial target framework is `net8.0`.
5. We need production-ready behavior, not only PoC signing.
---
## 3. High-Level Design
## 3.1 Architecture slice
- **Application layer (contracts/use-cases):**
- request signing hash/document,
- request certificate/public key material,
- request verification,
- consume signature evidence result.
- **Infrastructure.Crypt layer (this project):**
- PKCS#11 session handling,
- key/certificate object discovery,
- hash signing operations,
- local cryptographic verification,
- certificate parsing helpers,
- error normalization.
## 3.2 Provider model
Define provider-agnostic APIs and add one concrete provider first:
- `Pkcs11SignatureProvider` (primary)
- future providers:
- `RemoteSigningProvider` (if a dedicated signing gateway is introduced)
- `MockSignatureProvider` (for deterministic test scenarios)
---
## 4. Deliverables (Phase by Phase)
## Phase 0 - Contracts and Domain Language
Create neutral models and interfaces in `Infrastructure.Crypt` (or shared abstractions if needed later):
- `SigningAlgorithm` (enum/value object)
- `Pkcs11ProviderOptions`
- `KeyLocator` (label/id/slot/token selectors)
- `SignHashRequest`
- `SignHashResult`
- `VerifySignatureRequest`
- `VerifySignatureResult`
- `CertificateDescriptor`
- `SignatureEvidence` (technical evidence payload)
Interfaces:
- `ISignatureProvider`
- `ICertificateProvider`
- `ISignatureVerifier`
- `ICryptographicHealthCheck`
Acceptance criteria:
- no dependency on ASP.NET types,
- no UI/API naming leakage,
- all model names are business-neutral.
## Phase 1 - PKCS#11 Core Adapter (SoftHSM-ready)
Implement:
- `Pkcs11LibraryLoader`
- `Pkcs11SessionFactory`
- `Pkcs11ObjectFinder`
- `Pkcs11SignatureProvider`
- `Pkcs11CertificateProvider`
Capabilities:
1. load module path from options (`libsofthsm2.so` or proxy module when required),
2. enumerate slots/tokens,
3. open read-write session,
4. login with user pin,
5. find private key by label/id,
6. sign digest with selected mechanism,
7. read certificate object by label/id.
Acceptance criteria:
- deterministic disposal of sessions,
- no leaked native handles,
- detailed typed errors (not raw exception strings only).
## Phase 2 - Verification and Certificate Utilities
Implement:
- `DefaultSignatureVerifier`
- `X509CertificateParser`
- optional `CertificateChainValidator` (toggleable)
Capabilities:
1. verify signature using returned certificate/public key,
2. expose certificate metadata (`thumbprint`, `subject`, `issuer`, `notBefore`, `notAfter`),
3. map validation failures into stable error codes.
Acceptance criteria:
- same request yields same verification outcome,
- invalid signature returns structured negative result.
## Phase 3 - Evidence Builder
Implement:
- `SignatureEvidenceBuilder`
Evidence payload should contain:
- transaction/correlation id,
- algorithm,
- hash metadata,
- key locator snapshot (safe subset),
- certificate fingerprint and subject,
- sign timestamp (UTC),
- verification outcome,
- provider type (`PKCS11`).
Acceptance criteria:
- no secret values in evidence (pin, private key id internals),
- evidence serializable for storage/audit.
## Phase 4 - Dependency Injection Module
Implement:
- `CryptInfrastructureDependencyInjection` extension methods
Methods:
- `AddCryptInfrastructure(...)`
- `AddPkcs11Provider(...)`
Acceptance criteria:
- one-line registration from composition root,
- options validation on startup.
## Phase 5 - Test Suite (`EnvelopeGenerator.Tests`)
Add test categories for `net8.0`:
1. `Unit`:
- request validation,
- algorithm mapping,
- evidence building,
- error mapping.
2. `Integration` (conditional/manual profile):
- SoftHSM slot listing,
- login,
- sign hash,
- verify,
- certificate read.
Acceptance criteria:
- unit tests run in CI without native SoftHSM,
- integration tests run when module path + pins are provided via environment variables.
---
## 5. Proposed Project Structure
```text
EnvelopeGenerator.Infrastructure.Crypt/
Abstractions/
ISignatureProvider.cs
ICertificateProvider.cs
ISignatureVerifier.cs
ICryptographicHealthCheck.cs
Models/
SigningAlgorithm.cs
SignHashRequest.cs
SignHashResult.cs
VerifySignatureRequest.cs
VerifySignatureResult.cs
SignatureEvidence.cs
CertificateDescriptor.cs
KeyLocator.cs
Options/
Pkcs11ProviderOptions.cs
Pkcs11/
Pkcs11LibraryLoader.cs
Pkcs11SessionFactory.cs
Pkcs11ObjectFinder.cs
Pkcs11SignatureProvider.cs
Pkcs11CertificateProvider.cs
Verification/
DefaultSignatureVerifier.cs
X509CertificateParser.cs
Evidence/
SignatureEvidenceBuilder.cs
DependencyInjection/
CryptInfrastructureDependencyInjection.cs
Exceptions/
CryptographicOperationException.cs
Pkcs11ProviderException.cs
```
---
## 6. Configuration Strategy
Use options (bound from host config) with environment override support:
- `Crypt:Provider = PKCS11`
- `Crypt:Pkcs11:AssetsRootPath` (base path that contains bundled `pkcs11` assets)
- `Crypt:Pkcs11:SlotId` or token selectors
- `Crypt:Pkcs11:UserPin` (from secret store/env, not plain committed value)
- `Crypt:Pkcs11:PrivateKeyLabel`
- `Crypt:Pkcs11:CertificateLabel`
- `Crypt:Pkcs11:LoginType`
Validation rules:
- resolved module path must exist under configured/default assets root,
- at least one key locator required,
- pin required for signing operations.
---
## 7. Error Model
Define stable error codes to prevent provider-specific leakage:
- `CRYPT_PROVIDER_UNAVAILABLE`
- `CRYPT_SLOT_NOT_FOUND`
- `CRYPT_LOGIN_FAILED`
- `CRYPT_KEY_NOT_FOUND`
- `CRYPT_CERT_NOT_FOUND`
- `CRYPT_SIGN_FAILED`
- `CRYPT_VERIFY_FAILED`
Map low-level exceptions to these codes with safe diagnostics.
---
## 8. Non-Functional Requirements
1. **Security:** never log pins or raw private key handles.
2. **Reliability:** deterministic cleanup (`IDisposable`/`IAsyncDisposable`).
3. **Observability:** structured logs with correlation id.
4. **Performance:** avoid repeated login per operation when a safe session strategy is available.
5. **Extensibility:** algorithm/provider mapping must be open for extension.
---
## 9. Implementation Order (Small Tasks)
1. Add models + interfaces.
2. Add options + validators.
3. Add PKCS#11 low-level loader/session wrappers.
4. Implement `Pkcs11SignatureProvider` for hash signing.
5. Implement certificate retrieval.
6. Implement verifier.
7. Implement evidence builder.
8. Add DI registration extension.
9. Add unit tests.
10. Add optional integration tests (SoftHSM profile).
---
## 10. Definition of Done
Done means:
1. `Infrastructure.Crypt` exposes generic cryptographic services usable by Application use-cases.
2. SoftHSM-backed PKCS#11 sign/verify path works in integration tests.
3. All sensitive configuration is externalized.
4. Structured error codes are returned for expected failure scenarios.
5. Documentation exists for host registration and required settings.

View File

@@ -0,0 +1,228 @@
# FES (Advanced Electronic Signature) Summary and Integration Plan - EN
This document summarizes the shared diagram flow (`Ablauf SignFlow Unternehmenszertifikat`) and provides a small-step integration plan for the `EnvelopeGenerator` solution.
---
## 1) Simplified flow summary
Flow logic:
1. The sender uploads a document and defines signers.
2. If a valid company certificate already exists in HSM, it is used with the company key pair.
3. If no valid company certificate exists, CA issuance/renewal flow is required.
4. The signer receives an e-mail with a signing link.
5. The signer completes SMS OTP (2FA) and performs UI signature interaction.
6. Backend computes the hash for the current signing stage and sends it to HSM.
7. HSM signs the hash with the private key and returns signature + certificate context.
8. Backend embeds digital signature and certificate into PDF and writes audit trail.
9. After all signers complete, the final artifact is archived.
10. The final signed document is distributed to signers.
Important: The visible UI signature is not yet the legal electronic signature. The legal signature is produced in backend+HSM steps.
---
## 2) Relation to current codebase (as-is)
- `EnvelopeGenerator.Server` is the presentation layer host (Blazor + API hosting).
- Business flow is handled in `Application` via MediatR pipelines.
- Current receiver flow is centered around UI overlay/signature capture.
- Missing FES core: PKCS#11 hash-sign-verify + certificate-based PDF embedding pipeline.
Critical deployment fact:
- `EnvelopeGenerator.Server` is planned to run on Windows.
- SoftHSM is currently planned to run on Linux.
- A Windows process cannot directly load Linux PKCS#11 module (`libsofthsm2.so`).
- Therefore, architecture must include a compatibility bridge (service or proxy) unless HSM/SoftHSM is local to Windows.
Conclusion: FES is not a replacement of the current flow; it is a cryptographic hardening layer behind it.
---
## 3) Why SoftHSM tests are foundational
The tests defined in `SOFTHSM_TEST_KILAVUZU_TR.md` are the technical prerequisite for FES:
- `slots/login` -> HSM connectivity and authentication
- `certificate` -> certificate retrieval path
- `sign` -> hash signing with private key
- `verify` -> signature and certificate consistency validation
Without these tests passing, diagram steps 6-8 should not go live.
---
## 4) Target architecture (clean architecture aligned)
### Deployment topology options (must choose one)
1. **Linux signing service (recommended)**
- Windows `EnvelopeGenerator.Server` calls Linux signing API over HTTPS.
- Linux signing service performs PKCS#11 operations against SoftHSM.
- Private keys never leave Linux/HSM boundary.
2. **PKCS#11 proxy bridge**
- Windows host loads a Windows PKCS#11 proxy client DLL.
- Proxy forwards PKCS#11 operations to Linux side connected to SoftHSM.
3. **Windows-local SoftHSM (dev/test)**
- SoftHSM also installed on Windows and used locally.
- Useful for local testing, not preferred production topology.
Decision note:
- For production-critical FES, choose option 1 by default unless enterprise HSM policy requires option 2.
- Keep option 3 for development convenience only.
### Layer responsibilities
- `EnvelopeGenerator.Server`:
- API endpoints, auth orchestration, input validation.
- FES branch routing: standard-sign path vs external signing-service/proxy path.
- No private-key material handling.
- `EnvelopeGenerator.Application`:
- Use cases such as `SignWithHsmCommand`.
- MediatR pipeline orchestration for status/history/audit.
- FES evidence model aggregation (ip/email/phone/user-agent/otp proof/timestamps).
- `EnvelopeGenerator.Infrastructure`:
- Implementations for `IPkcs11Service`, `IPdfSignatureService`, `ITimestampService`.
- Pkcs11Interop integration and cryptographic operations.
- Optional client for Linux signing service if topology option 1 is selected.
- `Domain`:
- Signature evidence and audit-trail models.
---
## 5) Small-step integration plan
## Phase 0 - Scope and contracts
1. Finalize signing algorithms (for example `SHA256_RSA_PKCS`).
2. Confirm strategy: company certificate vs signer-specific certificate.
3. Freeze mandatory audit fields:
- identity, OTP proof, IP, user-agent, transaction id, timestamps, hash metadata.
4. Freeze deployment topology decision:
- option 1 (Linux signing service), option 2 (PKCS#11 proxy), or option 3 (Windows-local SoftHSM).
5. Define production host mapping:
- where `EnvelopeGenerator.Server` runs,
- where HSM/SoftHSM runs,
- where certificate lifecycle is managed.
Deliverable: Technical decision record (ADR-style markdown).
## Phase 1 - PKCS#11 connector (infrastructure)
1. Define `IPkcs11Service` interface.
2. Add `Pkcs11Interop` implementation.
3. Add config model:
- library path, slot, token label, key label, certificate label.
4. Ensure secure secret handling for PIN.
5. Ensure host-compatible module loading:
- Windows server must load Windows DLL,
- Linux server must load Linux `.so`.
Deliverable: Testable PKCS#11 service with health/slot/login/sign/verify capabilities.
## Phase 1.5 - Cross-host bridge implementation
1. If option 1 selected:
- implement Linux signing service API contract (`/sign`, `/certificate`, `/health`),
- implement authenticated HTTP client in `EnvelopeGenerator.Server`.
2. If option 2 selected:
- install/configure PKCS#11 proxy client DLL on Windows,
- install/configure proxy server on Linux and bind to SoftHSM.
Deliverable: Windows-to-Linux signing communication path proven end-to-end.
## Phase 2 - Application use case
1. Add `SignDocumentHashCommand` (or equivalent).
2. Pipeline flow:
- compute/receive hash,
- sign with HSM,
- attach certificate context,
- verify,
- produce evidence object.
3. Add resilient error mapping and retry strategy.
Deliverable: HSM signing use case in Application layer.
## Phase 3 - PDF signature embedding
1. Implement PDF signature embedding via `IPdfSignatureService`.
2. Build signer-specific audit-trail pages.
3. Append all audit pages to final PDF artifact.
Deliverable: Signed PDF with certificate context and audit trail pages.
## Phase 4 - Presentation/API integration
1. Integrate FES option into submit flow (feature flag guarded).
2. Add operational endpoints:
- HSM health/check
- signature evidence query (restricted roles)
3. Keep DTOs safe; avoid exposing sensitive cryptographic material.
4. In `SignatureController.Submit`, add FES branch that sends signing payload + audit context to selected signing backend.
Deliverable: API-level orchestration with minimal UI disruption.
## Phase 5 - Audit, archive, distribution
1. Persist signature evidence records.
2. Include hash/cert metadata in archival package.
3. Add signed artifact reference to distribution e-mails.
Deliverable: Alignment with diagram steps 9-10.
## Phase 6 - Testing and operations
1. Postman collection + Newman automation.
2. Positive/negative matrix:
- wrong pin, wrong slot, missing cert, expired cert, HSM timeout.
3. Observability:
- structured logs, correlation id, latency metrics.
Deliverable: Production readiness checklist.
---
## 6) Transition strategy from test API to production flow
1. Keep `/api/pkcs11-test/*` only for non-production.
2. In production, use the same internal services through business endpoints only.
3. Roll out by feature flag:
- `FesEnabled=false` initially
- pilot sender group
- full rollout
4. Keep explicit environment matrix:
- local dev (option 3),
- integration/staging (option 1 or 2),
- production (option 1 or 2 only).
---
## 7) Risks and mitigations
- Certificate expiry risk -> proactive monitoring and renewal alerting.
- HSM connectivity risk -> retry policy, circuit breaker, fallback queue.
- PIN/secret exposure risk -> vault-backed secrets and rotation policy.
- Verification failure risk -> block PDF sealing and move envelope to technical hold state.
- Cross-host connectivity risk -> mTLS/JWT auth + timeout/retry/circuit breaker + health probe.
---
## 8) Short done checklist
- [ ] PKCS#11 service interface + implementation
- [ ] Application command + pipeline behaviors
- [ ] PDF signature embedding
- [ ] Audit-trail generation
- [ ] API integration + feature flag
- [ ] Postman/Newman regression suite
- [ ] Operational runbook and alerting rules

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,84 @@
10/6/26, 10:46 PM
Test SoftHSM : SWINFRA-54
Projects / Infrastruktur Software / Issues /
Enter search…
SWINFRA-54
All…
Created by Marvin Kamm 3 months ago
Visible to issue readers 1
Updated by Matthias Dewald about 1 month ago
# **Test SoftHSM**
Project Priorität Status **SWI** Infrastruktur Softwa H Hoch O Offen re… Bearbeiter Fälligkeit Projekteinfluss MD _ ? ? Matthias Dewald Kein fälligkeit Kein projektbezug Boards No visible boards
Bitte eine Test-VM mit der Software SoftHSM bereitstellen.
Laut Info von @Henning Emrich soll die Installation unter Debian Linux deutlich einfacher sein, als unter Windows. Bitte prüfen.
@Hakan Tek & @OlgunR
Sollen bitte die API Ansteuerung testen
FYI
@Marlon Schreiber @Jan-Ulrich Hoss @Henning Emrich @Hakan Tek @OlgunR
**Attachments** 1
Files Attached to Comments
**PDF**
Ablauf SignFlow Un ternehme… 83 kB
MD _ **Matthias Dewald** Commented 3 months ago Ist für Dienstag 30.6 10:00 Uhr Eingeplant
HT **Hakan Tek** Commented 3 months ago
Für die Integration auf der .NET-Seite können wir die Pkcs11Interop-Bibliothek und die entsprechende DevExpress-Infrastruktur verwenden. @OlgunR, falls du noch weitere Vorschläge hast, füge sie bitte hinzu. @Marvin Kamm, @Henning Emrich, @Matthias Dewald, um mit den Tests beginnen zu können, benötigen wir die entsprechenden Verbindungsdaten für SoftHSM (Slot-ID, PIN, Bibliothekspfad usw.).
FYI
@Marlon Schreiber @Jan-Ulrich Hoss
bugtracker.dd:8080/issue/SWINFRA-54/Test-SoftHSM
1/2
10/6/26, 10:46 PM
Test SoftHSM : SWINFRA-54
MD
## **Matthias Dewald** Commented 3 months ago
Hallo zusammen, SoftHSM ist auf der 172.24.12.64 installiert:
Der Proxy läuft auf Port 5657 und erwartet Verbindungen über die Proxy-Bibliothek (libpkcs11-proxy.so)
@Hakan Tek Bitte testen
MD
## **Matthias Dewald** Commented about 1 month ago
Ablauf SignFlow Unternehmenszertifikat.drawio.pdf
**PDF**
Ablauf SignFlow Un ternehme… 83 kB
bugtracker.dd:8080/issue/SWINFRA-54/Test-SoftHSM
2/2

View File

@@ -0,0 +1,18 @@
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace EnvelopeGenerator.Infrastructure.Doc;
public static class DependencyInjection
{
public static IServiceCollection AddDocInfrastructure(this IServiceCollection services)
{
services.AddScoped<IPdfPageImageRenderer, PdfPageImageRenderer>();
return services;
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DevExpress.Document.Processor" Version="25.2.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,57 @@
using DevExpress.Drawing;
using DevExpress.Pdf;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
namespace EnvelopeGenerator.Infrastructure.Doc;
public class PdfPageImageRenderer : IPdfPageImageRenderer
{
private const float RenderDpi = 110f;
public Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(pdfBytes);
if (pdfBytes.Length == 0)
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(Array.Empty<PdfPageImageDto>());
using var stream = new MemoryStream(pdfBytes, writable: false);
using var processor = new PdfDocumentProcessor();
processor.LoadDocument(stream);
var pages = processor.Document.Pages;
if (pages.Count == 0)
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(Array.Empty<PdfPageImageDto>());
var renderOptions = PdfPageRenderingParameters.CreateWithResolution(RenderDpi);
var results = new List<PdfPageImageDto>(pages.Count);
for (var i = 0; i < pages.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var pageNumber = i + 1;
var page = pages[i];
var cropBox = page.CropBox;
using DXBitmap bitmap = processor.CreateDXBitmap(pageNumber, renderOptions);
using var imageStream = new MemoryStream();
bitmap.Save(imageStream, DXImageFormat.Png);
var dataUrl = $"data:image/png;base64,{Convert.ToBase64String(imageStream.ToArray())}";
results.Add(new PdfPageImageDto
{
PageNumber = pageNumber,
WidthPt = cropBox.Width,
HeightPt = cropBox.Height,
WidthPx = bitmap.Width,
HeightPx = bitmap.Height,
ImageDataUrl = dataUrl
});
}
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(results);
}
}

View File

@@ -0,0 +1,7 @@
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
public sealed record SenderPdfFieldMoveRequest(
int Index,
int Page,
double XPt,
double YPt);

View File

@@ -0,0 +1,9 @@
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
public sealed record SenderPdfFieldOverlay(
int Index,
int Page,
double XPt,
double YPt,
string ReceiverName,
string Color);

View File

@@ -0,0 +1,458 @@
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Application.Common.Dto
@using System.Globalization
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<Resource> UiLocalizer
@inject IJSRuntime JSRuntime
<div class="sender-image-editor-shell">
@if (PageCount > 0)
{
<div class="sender-image-editor__toolbar-host">
<SenderPdfImageEditorToolbar CurrentPage="@_currentPage"
TotalPages="@PageCount"
ZoomPercent="@_zoomPercent"
MinZoomPercent="@MinZoomPercent"
MaxZoomPercent="@MaxZoomPercent"
ZoomStepPercent="@ZoomStepPercent"
GoFirstPage="OnGoFirstPageAsync"
GoPreviousPage="OnGoPreviousPageAsync"
GoNextPage="OnGoNextPageAsync"
GoLastPage="OnGoLastPageAsync"
GoToPage="OnGoToPageAsync"
ZoomOut="OnZoomOutAsync"
ZoomIn="OnZoomInAsync"
SetZoomPercent="OnSetZoomPercentAsync"
CanDownloadPdf="@CanDownloadPdf"
CanPrintPdf="@CanPrintPdf"
IsDownloading="@_isDownloading"
IsPrinting="@_isPrinting"
DownloadPdf="OnDownloadPdfAsync"
PrintPdf="OnPrintPdfAsync" />
</div>
}
<div class="pdf-editor-wrapper sender-image-editor sender-editor-pdf-viewer"
style="cursor: @(IsPlacementMode ? "crosshair" : (_isDragging ? "grabbing" : "default"));"
tabindex="0"
@onclick="HandleClickAsync"
@onwheel="OnEditorWheelAsync"
@onwheel:preventDefault="true"
@onscroll="OnEditorScrollAsync"
@onmousemove="OnEditorMouseMoveAsync"
@onmouseup="OnEditorMouseUp"
@onmouseleave="OnEditorMouseUp">
@if (PageCount <= 0)
{
<div class="sender-image-editor__empty">@L("SenderEditor_NoPagesToDisplay", "Keine Seiten zur Anzeige verfügbar.")</div>
}
else if (Pages.Count == 0 && !string.IsNullOrWhiteSpace(PdfDataUrl))
{
<div class="sender-image-editor__native-preview" style="@GetZoomStyle()">
<iframe class="sender-image-editor__native-frame"
src="@PdfDataUrl"
title="PDF Preview"></iframe>
</div>
}
else
{
<div class="sender-image-editor__pages" style="@GetZoomStyle()">
@for (var pageIndex = 0; pageIndex < PageCount; pageIndex++)
{
var pagePreview = pageIndex < Pages.Count ? Pages[pageIndex] : null;
var pageNumber = pagePreview?.PageNumber ?? (pageIndex + 1);
var widthPt = pagePreview?.WidthPt ?? 595.0;
var heightPt = pagePreview?.HeightPt ?? 842.0;
<div class="sender-image-editor__page-block"
data-editor-page="@pageNumber"
data-page-width-pt="@widthPt"
data-page-height-pt="@heightPt">
<div class="sender-image-editor__page-canvas"
data-editor-page-canvas="@pageNumber"
style="@GetPageCanvasStyle(widthPt, heightPt)">
@if (!string.IsNullOrWhiteSpace(pagePreview?.ImageDataUrl))
{
<img class="sender-image-editor__page-image"
src="@(pagePreview!.ImageDataUrl)"
alt="PDF Page @pageNumber"
draggable="false" />
}
else
{
<div class="sender-image-editor__page-placeholder">@L("SenderEditor_PageLabel", "Seite") @pageNumber</div>
}
@foreach (var field in Fields.Where(f => f.Page == pageNumber))
{
<div class="sender-image-editor__field"
style="@GetFieldStyle(field, widthPt, heightPt)"
title="@field.ReceiverName"
@onmousedown="args => OnFieldMouseDownAsync(args, field)"
@onmousedown:preventDefault="true"
@onclick:stopPropagation="true">
<span class="sender-image-editor__field-label">@field.ReceiverName</span>
<button type="button"
class="sender-image-editor__field-remove"
title='@L("Delete", "Löschen")'
draggable="false"
@onclick:stopPropagation="true"
@onclick="() => RemoveField.InvokeAsync(field.Index)">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
}
</div>
</div>
}
</div>
}
</div>
</div>
@code {
[Parameter]
public bool IsPlacementMode { get; set; }
[Parameter]
public int PageCount { get; set; }
[Parameter]
public IReadOnlyList<PdfPageImageDto> Pages { get; set; } = Array.Empty<PdfPageImageDto>();
[Parameter]
public string? PdfDataUrl { get; set; }
[Parameter]
public string? PdfFileName { get; set; }
[Parameter]
public IReadOnlyList<SenderPdfFieldOverlay> Fields { get; set; } = Array.Empty<SenderPdfFieldOverlay>();
[Parameter]
public EventCallback<MouseEventArgs> EditorClick { get; set; }
[Parameter]
public EventCallback<int> RemoveField { get; set; }
[Parameter]
public EventCallback<SenderPdfFieldMoveRequest> MoveField { get; set; }
private int? _draggedFieldIndex;
private bool _isDragging;
private double _grabOffsetXPt;
private double _grabOffsetYPt;
private int _currentPage = 1;
private int _zoomPercent = 112;
private bool _zoomOverrideInitialized;
private bool _isDownloading;
private bool _isPrinting;
private const int MinZoomPercent = 50;
private const int MaxZoomPercent = 300;
private const int ZoomStepPercent = 5;
private bool CanDownloadPdf => !string.IsNullOrWhiteSpace(PdfDataUrl);
private bool CanPrintPdf => !string.IsNullOrWhiteSpace(PdfDataUrl);
private static string GetFieldStyle(SenderPdfFieldOverlay field, double pageWidthPt, double pageHeightPt)
{
var leftPercent = (field.XPt / pageWidthPt) * 100.0;
var topPercent = (field.YPt / pageHeightPt) * 100.0;
var widthPercent = (127.44 / pageWidthPt) * 100.0;
var heightPercent = (141.12 / pageHeightPt) * 100.0;
var left = leftPercent.ToString("F4", CultureInfo.InvariantCulture);
var top = topPercent.ToString("F4", CultureInfo.InvariantCulture);
var width = widthPercent.ToString("F4", CultureInfo.InvariantCulture);
var height = heightPercent.ToString("F4", CultureInfo.InvariantCulture);
return string.Join(' ',
"position:absolute;",
$"left:{left}%;",
$"top:{top}%;",
$"width:{width}%;",
$"height:{height}%;",
"border:2px solid;",
"border-radius:8px;",
"display:flex;",
"align-items:flex-start;",
"justify-content:space-between;",
"gap:0.35rem;",
"padding:0.2rem 0.3rem;",
"overflow:hidden;",
"z-index:30;",
$"border-color:{field.Color};",
$"background-color:{field.Color}22;");
}
private string GetPageCanvasStyle(double pageWidthPt, double pageHeightPt)
{
var ratio = pageHeightPt <= 0 ? 595.0 / 842.0 : pageWidthPt / pageHeightPt;
var ratioText = ratio.ToString("F6", CultureInfo.InvariantCulture);
var cursor = _isDragging
? "grabbing"
: (IsPlacementMode ? "crosshair" : "default");
return $"aspect-ratio:{ratioText};cursor:{cursor};";
}
private string GetZoomStyle()
{
var factor = (_zoomPercent / 100.0).ToString("F4", CultureInfo.InvariantCulture);
return $"zoom:{factor};";
}
protected override void OnParametersSet()
{
if (PageCount <= 0)
{
_currentPage = 1;
return;
}
_currentPage = Math.Max(1, Math.Min(_currentPage, PageCount));
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (_zoomOverrideInitialized)
return;
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.ensureZoomOverride",
"sender-editor-pdf-viewer");
_zoomOverrideInitialized = true;
}
private async Task HandleClickAsync(MouseEventArgs args)
{
if (EditorClick.HasDelegate)
await EditorClick.InvokeAsync(args);
}
private async Task OnFieldMouseDownAsync(MouseEventArgs args, SenderPdfFieldOverlay field)
{
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
"envelopeEditor.getClickCoordsOnPdfPage",
"sender-editor-pdf-viewer",
args.ClientX,
args.ClientY);
if (coords is null)
return;
var pageNumber = coords.PageNumber;
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
var pointerXPt = coords.NormX * pageWidthPt;
var pointerYPt = coords.NormY * pageHeightPt;
_draggedFieldIndex = field.Index;
_isDragging = true;
_grabOffsetXPt = pointerXPt - field.XPt;
_grabOffsetYPt = pointerYPt - field.YPt;
}
private async Task OnEditorMouseMoveAsync(MouseEventArgs args)
{
if (_draggedFieldIndex is null || !MoveField.HasDelegate)
return;
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
"envelopeEditor.getClickCoordsOnPdfPage",
"sender-editor-pdf-viewer",
args.ClientX,
args.ClientY);
if (coords is null)
return;
var pageNumber = coords.PageNumber;
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
const double sigWidthPt = 1.77 * 72;
const double sigHeightPt = 1.96 * 72;
var xPt = (coords.NormX * pageWidthPt) - _grabOffsetXPt;
var yPt = (coords.NormY * pageHeightPt) - _grabOffsetYPt;
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - sigWidthPt));
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - sigHeightPt));
await MoveField.InvokeAsync(new SenderPdfFieldMoveRequest(_draggedFieldIndex.Value, pageNumber, xPt, yPt));
}
private async Task OnGoFirstPageAsync()
{
await NavigateToPageAsync(1);
}
private async Task OnGoPreviousPageAsync()
{
await NavigateToPageAsync(_currentPage - 1);
}
private async Task OnGoNextPageAsync()
{
await NavigateToPageAsync(_currentPage + 1);
}
private async Task OnGoLastPageAsync()
{
await NavigateToPageAsync(PageCount);
}
private async Task OnGoToPageAsync(int page)
{
await NavigateToPageAsync(page);
}
private async Task NavigateToPageAsync(int targetPage)
{
if (PageCount <= 0)
return;
var page = Math.Max(1, Math.Min(targetPage, PageCount));
_currentPage = page;
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.scrollToPdfPage",
"sender-editor-pdf-viewer",
page);
}
private async Task OnEditorScrollAsync(EventArgs _)
{
if (PageCount <= 0)
return;
var visiblePage = await JSRuntime.InvokeAsync<int?>(
"envelopeEditor.getVisiblePdfPageNumber",
"sender-editor-pdf-viewer");
if (!visiblePage.HasValue)
return;
var clamped = Math.Max(1, Math.Min(visiblePage.Value, PageCount));
if (clamped == _currentPage)
return;
_currentPage = clamped;
await InvokeAsync(StateHasChanged);
}
private async Task OnZoomOutAsync()
{
SetZoomPercent(_zoomPercent - ZoomStepPercent);
await InvokeAsync(StateHasChanged);
}
private async Task OnZoomInAsync()
{
SetZoomPercent(_zoomPercent + ZoomStepPercent);
await InvokeAsync(StateHasChanged);
}
private async Task OnSetZoomPercentAsync(int zoomPercent)
{
SetZoomPercent(zoomPercent);
await InvokeAsync(StateHasChanged);
}
private async Task OnEditorWheelAsync(WheelEventArgs args)
{
if (args.CtrlKey)
{
if (args.DeltaY < 0)
await OnZoomInAsync();
else if (args.DeltaY > 0)
await OnZoomOutAsync();
return;
}
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.scrollPdfViewerByDelta",
"sender-editor-pdf-viewer",
args.DeltaY);
}
private void SetZoomPercent(int value)
{
_zoomPercent = Math.Max(MinZoomPercent, Math.Min(value, MaxZoomPercent));
}
private async Task OnDownloadPdfAsync()
{
if (string.IsNullOrWhiteSpace(PdfDataUrl) || _isDownloading || _isPrinting)
return;
_isDownloading = true;
await InvokeAsync(StateHasChanged);
try
{
var fallbackName = string.IsNullOrWhiteSpace(PdfFileName)
? "document.pdf"
: PdfFileName;
var fileName = fallbackName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
? fallbackName
: $"{fallbackName}.pdf";
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.downloadDataUrlAsFile",
PdfDataUrl,
fileName,
"application/pdf");
}
finally
{
_isDownloading = false;
await InvokeAsync(StateHasChanged);
}
}
private async Task OnPrintPdfAsync()
{
if (string.IsNullOrWhiteSpace(PdfDataUrl) || _isPrinting || _isDownloading)
return;
_isPrinting = true;
await InvokeAsync(StateHasChanged);
try
{
await JSRuntime.InvokeVoidAsync(
"envelopeEditor.printDataUrlPdf",
PdfDataUrl,
"application/pdf");
}
finally
{
_isPrinting = false;
await InvokeAsync(StateHasChanged);
}
}
private void OnEditorMouseUp(MouseEventArgs args)
{
_draggedFieldIndex = null;
_isDragging = false;
}
private (double WidthPt, double HeightPt) GetPageSize(int pageNumber)
{
var page = Pages.FirstOrDefault(p => p.PageNumber == pageNumber);
return page is null ? (595.0, 842.0) : (page.WidthPt, page.HeightPt);
}
private sealed record EditorCoords(double NormX, double NormY, int PageIndex, int PageNumber);
private string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -0,0 +1,267 @@
<div class="sender-image-toolbar" @onclick:stopPropagation="true">
<div class="sender-image-toolbar__group sender-image-toolbar__group--nav" role="group" aria-label="Page navigation">
<button type="button" class="sender-image-toolbar__icon-btn" title="First page" disabled="@(!CanGoPrevious)" @onclick="GoToFirstAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M16 7L11 12L16 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" class="sender-image-toolbar__icon-btn" title="Previous page" disabled="@(!CanGoPrevious)" @onclick="GoToPreviousAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M15 7L10 12L15 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="sender-image-toolbar__value-box">
<input type="number"
min="1"
max="@TotalPagesDisplay"
value="@CurrentPageDisplay"
class="sender-image-toolbar__page-input"
@onchange="OnPageInputChangedAsync"
disabled="@(TotalPagesDisplay <= 0)" />
<span class="sender-image-toolbar__page-total">of @TotalPagesDisplay</span>
</div>
<div class="sender-image-toolbar__select-wrap" title="Page list">
<button type="button" class="sender-image-toolbar__icon-btn sender-image-toolbar__icon-btn--caret" disabled="@(TotalPagesDisplay <= 0)">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<select class="sender-image-toolbar__page-select"
aria-label="Select page"
disabled="@(TotalPagesDisplay <= 0)"
value="@CurrentPageDisplay"
@onchange="OnPageSelectChangedAsync">
@for (var pageNumber = 1; pageNumber <= TotalPagesDisplay; pageNumber++)
{
<option value="@pageNumber">@pageNumber</option>
}
</select>
</div>
<button type="button" class="sender-image-toolbar__icon-btn" title="Next page" disabled="@(!CanGoNext)" @onclick="GoToNextAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M9 7L14 12L9 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" class="sender-image-toolbar__icon-btn" title="Last page" disabled="@(!CanGoNext)" @onclick="GoToLastAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M16 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M8 7L13 12L8 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
<div class="sender-image-toolbar__group sender-image-toolbar__group--zoom" role="group" aria-label="Zoom controls">
<button type="button" class="sender-image-toolbar__icon-btn" title="Zoom out" disabled="@(!CanZoomOut)" @onclick="ZoomOutAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 12H18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
<div class="sender-image-toolbar__value-box" aria-hidden="true">
<span>@ZoomPercentDisplay%</span>
</div>
<input type="range"
class="sender-image-toolbar__zoom-slider"
min="@MinZoomPercent"
max="@MaxZoomPercent"
step="@ZoomStepPercent"
value="@ZoomPercentDisplay"
@oninput="OnZoomSliderInputAsync"
aria-label="Zoom slider" />
<button type="button" class="sender-image-toolbar__icon-btn" title="Zoom in" disabled="@(!CanZoomIn)" @onclick="ZoomInAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M6 12H18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
<div class="sender-image-toolbar__group sender-image-toolbar__group--actions" role="group" aria-label="Document actions">
<button type="button"
class="sender-image-toolbar__icon-btn @(IsPrinting ? "sender-image-toolbar__icon-btn--busy" : string.Empty)"
title="@(IsPrinting ? "Preparing print..." : "Print")"
disabled="@(!CanPrintPdf || IsPrinting || IsDownloading)"
@onclick="PrintPdfAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M7 9V4H17V9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<rect x="5" y="9" width="14" height="8" rx="2" stroke="currentColor" stroke-width="2" />
<path d="M7 14H17V20H7V14Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
</svg>
</button>
<button type="button"
class="sender-image-toolbar__icon-btn @(IsDownloading ? "sender-image-toolbar__icon-btn--busy" : string.Empty)"
title="@(IsDownloading ? "Preparing download..." : "Download PDF")"
disabled="@(!CanDownloadPdf || IsDownloading || IsPrinting)"
@onclick="DownloadPdfAsync">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 4V14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<path d="M8 10L12 14L16 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M5 18H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
@code {
[Parameter]
public int CurrentPage { get; set; }
[Parameter]
public int TotalPages { get; set; }
[Parameter]
public int ZoomPercent { get; set; } = 112;
[Parameter]
public int MinZoomPercent { get; set; } = 50;
[Parameter]
public int MaxZoomPercent { get; set; } = 300;
[Parameter]
public int ZoomStepPercent { get; set; } = 5;
[Parameter]
public EventCallback GoFirstPage { get; set; }
[Parameter]
public EventCallback GoPreviousPage { get; set; }
[Parameter]
public EventCallback GoNextPage { get; set; }
[Parameter]
public EventCallback GoLastPage { get; set; }
[Parameter]
public EventCallback<int> GoToPage { get; set; }
[Parameter]
public EventCallback ZoomOut { get; set; }
[Parameter]
public EventCallback ZoomIn { get; set; }
[Parameter]
public EventCallback<int> SetZoomPercent { get; set; }
[Parameter]
public bool CanDownloadPdf { get; set; }
[Parameter]
public bool CanPrintPdf { get; set; }
[Parameter]
public bool IsDownloading { get; set; }
[Parameter]
public bool IsPrinting { get; set; }
[Parameter]
public EventCallback DownloadPdf { get; set; }
[Parameter]
public EventCallback PrintPdf { get; set; }
private int CurrentPageDisplay => CurrentPage > 0 ? CurrentPage : 0;
private int TotalPagesDisplay => TotalPages > 0 ? TotalPages : 0;
private int ZoomPercentDisplay => ZoomPercent > 0 ? ZoomPercent : 100;
private bool CanGoPrevious => CurrentPageDisplay > 1;
private bool CanGoNext => TotalPagesDisplay > 0 && CurrentPageDisplay < TotalPagesDisplay;
private bool CanZoomOut => ZoomPercentDisplay > MinZoomPercent;
private bool CanZoomIn => ZoomPercentDisplay < MaxZoomPercent;
private async Task GoToFirstAsync()
{
if (GoFirstPage.HasDelegate)
await GoFirstPage.InvokeAsync();
}
private async Task GoToPreviousAsync()
{
if (GoPreviousPage.HasDelegate)
await GoPreviousPage.InvokeAsync();
}
private async Task GoToNextAsync()
{
if (GoNextPage.HasDelegate)
await GoNextPage.InvokeAsync();
}
private async Task GoToLastAsync()
{
if (GoLastPage.HasDelegate)
await GoLastPage.InvokeAsync();
}
private async Task OnPageInputChangedAsync(ChangeEventArgs args)
{
if (!GoToPage.HasDelegate)
return;
if (!int.TryParse(args.Value?.ToString(), out var page))
return;
page = Math.Max(1, Math.Min(page, Math.Max(1, TotalPagesDisplay)));
await GoToPage.InvokeAsync(page);
}
private async Task OnPageSelectChangedAsync(ChangeEventArgs args)
{
if (!GoToPage.HasDelegate)
return;
if (!int.TryParse(args.Value?.ToString(), out var page))
return;
page = Math.Max(1, Math.Min(page, Math.Max(1, TotalPagesDisplay)));
await GoToPage.InvokeAsync(page);
}
private async Task ZoomOutAsync()
{
if (ZoomOut.HasDelegate)
await ZoomOut.InvokeAsync();
}
private async Task ZoomInAsync()
{
if (ZoomIn.HasDelegate)
await ZoomIn.InvokeAsync();
}
private async Task OnZoomSliderInputAsync(ChangeEventArgs args)
{
if (!SetZoomPercent.HasDelegate)
return;
if (!int.TryParse(args.Value?.ToString(), out var zoomPercent))
return;
zoomPercent = Math.Max(MinZoomPercent, Math.Min(zoomPercent, MaxZoomPercent));
await SetZoomPercent.InvokeAsync(zoomPercent);
}
private async Task DownloadPdfAsync()
{
if (DownloadPdf.HasDelegate)
await DownloadPdf.InvokeAsync();
}
private async Task PrintPdfAsync()
{
if (PrintPdf.HasDelegate)
await PrintPdf.InvokeAsync();
}
}

View File

@@ -0,0 +1,22 @@
namespace EnvelopeGenerator.Server.Client.Components.Sender;
public sealed record CreateEnvelopeWizardResult(
string Title,
string Message,
int? EnvelopeTypeId,
string Language,
bool UseAccessCode,
bool TFAEnabled,
int CertificationType,
int FinalEmailToCreator,
int FinalEmailToReceivers,
bool SendReminderEmails,
int FirstReminderDays,
int ReminderIntervalDays,
int ExpiresWhenDays,
int ExpiresWarningWhenDays,
IReadOnlyList<CreateEnvelopeWizardReceiver> Receivers,
byte[]? DocumentBytes,
string? DocumentFileName);
public sealed record CreateEnvelopeWizardReceiver(string FullName, string EmailAddress);

View File

@@ -0,0 +1,104 @@
@using Microsoft.AspNetCore.Components.Forms
<div class="sender-pdf-dropzone @DropZoneClass">
<div class="sender-pdf-dropzone__content">
<div class="sender-pdf-dropzone__title">@CurrentActionText</div>
@if (!string.IsNullOrWhiteSpace(DragDropHintText))
{
<div class="sender-pdf-dropzone__hint">@DragDropHintText</div>
}
</div>
<InputFile OnChange="OnFileSelectedAsync"
accept="@Accept"
disabled="@Disabled"
@ondragenter="OnDragEnter"
@ondragleave="OnDragLeave"
@ondragover="OnDragOver"
@ondrop="OnDrop"
class="sender-pdf-dropzone__input" />
</div>
@code {
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public bool HasDocument { get; set; }
[Parameter]
public string Accept { get; set; } = ".pdf";
[Parameter]
public string UploadText { get; set; } = "PDF hochladen";
[Parameter]
public string ReplaceText { get; set; } = "PDF ersetzen";
[Parameter]
public string DragDropHintText { get; set; } = "Datei hierhin ziehen oder klicken";
[Parameter]
public EventCallback<InputFileChangeEventArgs> FileSelected { get; set; }
private int _dragDepth;
private bool _isDragActive;
private string CurrentActionText => HasDocument ? ReplaceText : UploadText;
private string DropZoneClass
{
get
{
if (Disabled)
return "sender-pdf-dropzone--disabled";
return _isDragActive ? "sender-pdf-dropzone--drag-active" : string.Empty;
}
}
private void OnDragEnter(DragEventArgs _)
{
if (Disabled)
return;
_dragDepth++;
_isDragActive = true;
}
private void OnDragLeave(DragEventArgs _)
{
if (Disabled)
return;
_dragDepth = Math.Max(0, _dragDepth - 1);
if (_dragDepth == 0)
_isDragActive = false;
}
private void OnDragOver(DragEventArgs _)
{
if (Disabled)
return;
_isDragActive = true;
}
private void OnDrop(DragEventArgs _)
{
_dragDepth = 0;
_isDragActive = false;
}
private async Task OnFileSelectedAsync(InputFileChangeEventArgs args)
{
_dragDepth = 0;
_isDragActive = false;
if (Disabled)
return;
if (FileSelected.HasDelegate)
await FileSelected.InvokeAsync(args);
}
}

View File

@@ -0,0 +1,25 @@
namespace EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter;
public readonly record struct ReceiverFilterCriteria
{
public readonly string FullName;
public readonly string EmailAddress;
public ReceiverFilterCriteria(string? fullName = "", string? emailAddress = "")
{
FullName = Normalize(fullName);
EmailAddress = Normalize(emailAddress);
}
public readonly bool Matches(ReceiverFilterCriteria criteria)
=> FullName.Contains(criteria.FullName, StringComparison.Ordinal)
&& EmailAddress.Contains(criteria.EmailAddress, StringComparison.Ordinal);
private static string Normalize(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return string.Empty;
return string.Concat(text.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
}
}

View File

@@ -0,0 +1,26 @@
using EnvelopeGenerator.Application.Common.Dto.Receiver;
namespace EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter;
public class ReceiverSuggestionFilterHandler(IEnumerable<RecentReceiverSuggestionDto> suggestions)
{
private readonly IReadOnlyList<Entry> _entries = suggestions
.Where(x => !string.IsNullOrWhiteSpace(x.FullName) && !string.IsNullOrWhiteSpace(x.EmailAddress))
.Select(x => new Entry(new ReceiverFilterCriteria(x.FullName, x.EmailAddress), x))
.ToList();
private readonly record struct Entry(ReceiverFilterCriteria Criteria, RecentReceiverSuggestionDto Suggestion);
public IEnumerable<RecentReceiverSuggestionDto> Filter(ReceiverFilterCriteria criteria)
{
return _entries
.Where(x => x.Criteria.Matches(criteria))
.Select(x => x.Suggestion);
}
public IEnumerable<RecentReceiverSuggestionDto> Filter(string fullName, string emailAddress)
{
var criteria = new ReceiverFilterCriteria(fullName, emailAddress);
return Filter(criteria);
}
}

View File

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

View File

@@ -162,7 +162,7 @@
var result = await AuthService.LoginSenderAsync(Username.Trim(), Password.Trim());
if (result == SenderLoginResult.Success) {
Navigation.NavigateTo("/sender", forceLoad: true);
Navigation.NavigateTo("/sender", forceLoad: false);
return;
}

View File

@@ -1,25 +1,26 @@
@page "/sender"
@rendermode InteractiveServer
@rendermode InteractiveWebAssembly
@using System.Text.Json
@using EnvelopeGenerator.Domain.Constants
@using EnvelopeGenerator.Server.Client.Models
@using DevExpress.Blazor
@using DevExpress.Blazor.PdfViewer
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Common.Dto
@using EnvelopeGenerator.Application.Common.Dto.History
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Resources
@using EnvelopeGenerator.Server.Services
@using EnvelopeGenerator.Server.Client.Components.Sender
@using Microsoft.Extensions.Localization
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.DocumentService DocumentService
@inject EnvelopeGenerator.Server.Client.Services.PdfRenderService PdfRenderService
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeTypeService EnvelopeTypeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
@inject IEnvelopeAuthService EnvelopeAuthService
@inject ILogger<EnvelopeSenderPage> Logger
@inject EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService CreateEnvelopeBootstrapStorageService
@inject ILogger<SenderPage> Logger
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
@@ -262,7 +263,7 @@
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null)
{
var statusInfo = GetStatusInfo(envelope.Status);
var statusInfo = GetStatusInfo(GetEffectiveEnvelopeStatus(envelope));
<div class="status-badge status-badge--@statusInfo.CssClass">
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
@statusInfo.Label
@@ -314,12 +315,20 @@
{
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
@{
var isRejected = IsReceiverRejected(envelope, receiver);
var isSigned = IsReceiverSigned(envelope, receiver);
var accessCode = receiver.AccessCode;
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
}
<span class="receiver-badge receiver-badge--@(isSigned ? "signed" : "unsigned")" style="min-width: 100px;">
@if (isSigned)
<span class="receiver-badge receiver-badge--@(isRejected ? "unsigned" : (isSigned ? "signed" : "unsigned"))" style="min-width: 100px;">
@if (isRejected)
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
<span>@GetStatusLabel(EnvelopeStatus.DocumentRejected)</span>
}
else if (isSigned)
{
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
@@ -408,6 +417,11 @@
</div>
<CreateEnvelopeWizardPopup Visible="@_createWizardVisible"
VisibleChanged="OnCreateWizardVisibleChangedAsync"
EnvelopeTypes="@_envelopeTypes"
StartEditorRequested="StartEditorFromWizardAsync" />
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
<DxPopup @bind-Visible="@_previewVisible"
HeaderText="@($"{L("Preview", "Vorschau")} — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
@@ -459,10 +473,15 @@
else if (_previewPdfBytes is not null && _previewViewerMounted)
{
<div class="sender-preview-viewer-wrap">
<DxPdfViewer DocumentContent="@_previewPdfBytes"
ZoomLevel="-2"
IsSinglePagePreview="true"
CssClass="sender-preview-pdf-viewer" />
<SenderPdfImageEditor IsPlacementMode="false"
PageCount="@_previewPageCount"
Pages="@_previewPagePreviews"
PdfDataUrl="@PreviewPdfDataUrl"
PdfFileName="@_previewFileName"
Fields="@_previewFieldOverlays"
EditorClick="OnPreviewEditorClickAsync"
RemoveField="OnPreviewRemoveFieldAsync"
MoveField="OnPreviewMoveFieldAsync" />
</div>
}
</BodyContentTemplate>
@@ -536,7 +555,15 @@
private bool _previewLoading = false;
private bool _previewViewerMounted = false;
private byte[]? _previewPdfBytes = null;
private int _previewPageCount = 0;
private List<PdfPageImageDto> _previewPagePreviews = [];
private string _previewFileName = "document.pdf";
private string? _previewErrorMessage = null;
private readonly IReadOnlyList<SenderPdfFieldOverlay> _previewFieldOverlays = [];
private string? PreviewPdfDataUrl => _previewPdfBytes is { Length: > 0 }
? $"data:application/pdf;base64,{Convert.ToBase64String(_previewPdfBytes)}"
: null;
// Phase 6e (step 1): UI state only. Export logic is added in step 2.
private string _selectedExportFormat = "xlsx";
@@ -548,6 +575,9 @@
private string _deleteReason = string.Empty;
private string? _deleteErrorMessage;
private bool _createWizardVisible = false;
private IReadOnlyList<EnvelopeTypeDto> _envelopeTypes = [];
// Stable keys used to store/restore each tab's layout in UserPreferences.GridLayouts
private const string GridKeyActive = "sender.active-envelopes";
private const string GridKeyCompleted = "sender.completed-envelopes";
@@ -577,6 +607,16 @@
_appLogo = logos.App;
_companyLogo = logos.Company;
try
{
_envelopeTypes = (await EnvelopeTypeService.GetAsync()).ToList();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to load envelope types for create wizard.");
_envelopeTypes = [];
}
await LoadEnvelopesAsync();
}
@@ -590,10 +630,11 @@
{
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
// Split into active and completed based on status
// Split into active and completed based on effective status
// (history-aware: rejected envelopes should not stay in active tab).
var envelopes = _allEnvelopes.ToList();
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
_activeEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsCompleted()).ToList();
}
catch (Exception ex)
{
@@ -673,7 +714,40 @@
void CreateEnvelope()
{
Navigation.NavigateTo("/sender/editor");
_createWizardVisible = true;
}
Task OnCreateWizardVisibleChangedAsync(bool visible)
{
_createWizardVisible = visible;
return Task.CompletedTask;
}
async Task StartEditorFromWizardAsync(CreateEnvelopeWizardResult result)
{
var payload = new CreateEnvelopeBootstrapPayload(
Title: result.Title,
Message: result.Message,
EnvelopeTypeId: result.EnvelopeTypeId,
Language: result.Language,
UseAccessCode: result.UseAccessCode,
TFAEnabled: result.TFAEnabled,
CertificationType: result.CertificationType,
FinalEmailToCreator: result.FinalEmailToCreator,
FinalEmailToReceivers: result.FinalEmailToReceivers,
SendReminderEmails: result.SendReminderEmails,
FirstReminderDays: result.FirstReminderDays,
ReminderIntervalDays: result.ReminderIntervalDays,
ExpiresWhenDays: result.ExpiresWhenDays,
ExpiresWarningWhenDays: result.ExpiresWarningWhenDays,
Receivers: result.Receivers
.Select(x => new CreateEnvelopeBootstrapReceiver(x.FullName, x.EmailAddress))
.ToList(),
DocumentBytes: result.DocumentBytes,
DocumentFileName: result.DocumentFileName);
var token = await CreateEnvelopeBootstrapStorageService.StoreAsync(payload);
Navigation.NavigateTo($"/sender/editor?bootstrapToken={token}");
}
void EditEnvelope()
@@ -740,18 +814,18 @@
await InvokeAsync(StateHasChanged);
UserPreferencesService.InvalidateCache();
await AuthService.LogoutSenderAsync();
Navigation.NavigateTo("/sender/login", forceLoad: true);
Navigation.NavigateTo("/sender/login", forceLoad: false);
}
bool IsEnvelopeSent(EnvelopeDto envelope)
{
var status = (EnvelopeStatus)envelope.Status;
var status = GetEffectiveEnvelopeStatus(envelope);
return status >= EnvelopeStatus.EnvelopeQueued;
}
bool IsEnvelopeEditable(EnvelopeDto envelope)
{
var status = (EnvelopeStatus)envelope.Status;
var status = GetEffectiveEnvelopeStatus(envelope);
return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved;
}
@@ -800,7 +874,7 @@
IReadOnlyList<StatusFilterItem> GetStatusFilterItems(IEnumerable<EnvelopeDto>? source)
{
return (source ?? Enumerable.Empty<EnvelopeDto>())
.Select(x => x.Status)
.Select(GetEffectiveEnvelopeStatus)
.Distinct()
.OrderBy(x => x)
.Select(x => new StatusFilterItem(x, GetStatusLabel(x)))
@@ -818,7 +892,7 @@
bool IsReceiverSigned(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
{
var status = (EnvelopeStatus)envelope.Status;
var status = GetEffectiveEnvelopeStatus(envelope);
if (status is EnvelopeStatus.EnvelopeCompletelySigned or EnvelopeStatus.MessageCompletionSent)
return true;
@@ -841,6 +915,43 @@
return false;
}
bool IsReceiverRejected(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
{
var status = GetEffectiveEnvelopeStatus(envelope);
var histories = envelope.Histories?.Where(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected).ToList() ?? [];
if (histories.Count == 0)
return false;
if (histories.Any(h => h.Receiver?.Id == receiver.ReceiverId))
return true;
var receiverEmail = receiver.Receiver?.EmailAddress;
if (!string.IsNullOrWhiteSpace(receiverEmail) &&
histories.Any(h => string.Equals(h.UserReference, receiverEmail, StringComparison.OrdinalIgnoreCase)))
return true;
if (!string.IsNullOrWhiteSpace(receiver.Name) &&
histories.Any(h => string.Equals(h.UserReference, receiver.Name, StringComparison.OrdinalIgnoreCase)))
return true;
return status == EnvelopeStatus.EnvelopeRejected && (envelope.EnvelopeReceivers?.Count() ?? 0) == 1;
}
EnvelopeStatus GetEffectiveEnvelopeStatus(EnvelopeDto envelope)
{
var rawStatus = envelope.Status;
if (rawStatus is EnvelopeStatus.EnvelopeRejected or EnvelopeStatus.EnvelopeWithdrawn)
return rawStatus;
var hasRejectedHistory = (envelope.Histories ?? Enumerable.Empty<HistoryDto>())
.Any(h => h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected);
return hasRejectedHistory ? EnvelopeStatus.EnvelopeRejected : rawStatus;
}
DateTime GetHistoryDate(HistoryDto history)
{
return history.ActionDate ?? history.AddedWhen;
@@ -916,6 +1027,9 @@
// Unmount the viewer first — let the previous instance fully dispose
_previewViewerMounted = false;
_previewPdfBytes = null;
_previewPageCount = 0;
_previewPagePreviews = [];
_previewFileName = $"envelope-{envelope.Id}.pdf";
_previewErrorMessage = null;
_previewLoading = true;
_previewVisible = true;
@@ -923,19 +1037,35 @@
try
{
var user = EnvelopeAuthService.GetCurrentUser();
if (user?.Identity is null || !user.Identity.IsAuthenticated)
_previewPdfBytes = await DocumentService.GetSenderDocumentBytesByEnvelopeIdAsync(envelope.Id);
if (_previewPdfBytes is null || _previewPdfBytes.Length == 0)
{
_previewErrorMessage = L("NotAuthenticatedPleaseLoginAgain", "Nicht authentifiziert. Bitte melden Sie sich erneut an.");
_previewErrorMessage = L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden");
return;
}
_previewPdfBytes = await ReceiverPageDataService.GetDocumentOfSenderAsync(user, envelope.Id);
var previewPdfBytes = _previewPdfBytes;
_previewPagePreviews = [];
try
{
_previewPagePreviews = (await PdfRenderService.RenderPagesAsync(previewPdfBytes))
.OrderBy(p => p.PageNumber)
.ToList();
}
catch (Exception renderEx)
{
Logger.LogWarning(renderEx, "Preview: page image rendering failed for envelope {EnvelopeId}. Falling back to native PDF preview.", envelope.Id);
}
_previewPageCount = _previewPagePreviews.Count > 0
? _previewPagePreviews.Count
: EstimatePdfPageCount(previewPdfBytes) ?? 1;
// PDF ready — mount the viewer now
_previewViewerMounted = true;
}
catch (DigitalData.Core.Exceptions.NotFoundException)
catch (HttpRequestException ex) when ((int?)ex.StatusCode == 404)
{
_previewErrorMessage = string.Format(
L("Sender_PreviewDocumentNotFound", "Das Dokument für Umschlag #{0} wurde nicht gefunden oder Sie haben keinen Zugriff darauf."),
@@ -965,12 +1095,60 @@
{
await Task.Yield(); // wait one render cycle for the viewer to be removed from the DOM
_previewPdfBytes = null;
_previewPageCount = 0;
_previewPagePreviews = [];
_previewFileName = "document.pdf";
_previewErrorMessage = null;
_previewLoading = false;
await InvokeAsync(StateHasChanged);
});
}
Task OnPreviewEditorClickAsync(Microsoft.AspNetCore.Components.Web.MouseEventArgs _)
{
return Task.CompletedTask;
}
Task OnPreviewRemoveFieldAsync(int _)
{
return Task.CompletedTask;
}
Task OnPreviewMoveFieldAsync(SenderPdfFieldMoveRequest _)
{
return Task.CompletedTask;
}
static int? EstimatePdfPageCount(byte[]? documentBytes)
{
if (documentBytes is null || documentBytes.Length == 0)
return null;
try
{
var text = System.Text.Encoding.ASCII.GetString(documentBytes);
const string marker = "/Type /Page";
var count = 0;
var index = 0;
while (true)
{
index = text.IndexOf(marker, index, StringComparison.Ordinal);
if (index < 0)
break;
count++;
index += marker.Length;
}
return count > 0 ? count : null;
}
catch
{
return null;
}
}
// ── Grid Layout Persistence ───────────────────────────────────────────────
// LayoutAutoLoading fires when the grid initializes — restore saved layout.
// LayoutAutoSaving fires when the user changes column order/width/sort — persist it.
@@ -983,13 +1161,18 @@
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(CurrentGridKey, out var json))
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
{
var layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
SanitizeLayoutFilters(layout);
e.Layout = layout;
}
}
async Task OnGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
try
{
SanitizeLayoutFilters(e.Layout);
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(CurrentGridKey, json);
}
@@ -999,5 +1182,55 @@
}
}
// Keep column/order personalization, but never persist/restore data filters.
// Tab counters are based on tab datasets; persisted grid filters can hide rows and
// make the grid appear empty or incomplete.
static void SanitizeLayoutFilters(GridPersistentLayout? layout)
{
if (layout is null)
return;
ClearWritableProperty(layout, "FilterCriteria");
ClearWritableProperty(layout, "SearchText");
if (layout.Columns is not System.Collections.IEnumerable columns)
return;
foreach (var column in columns)
{
if (column is null)
continue;
ClearWritableProperty(column, "FilterCriteria");
ClearWritableProperty(column, "FilterValues");
ClearWritableProperty(column, "FilterValue");
ClearWritableProperty(column, "SearchText");
}
}
static void ClearWritableProperty(object target, string propertyName)
{
var prop = target.GetType()
.GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)
.FirstOrDefault(p => p.Name == propertyName && p.GetIndexParameters().Length == 0);
if (prop is null || !prop.CanWrite)
return;
var propertyType = prop.PropertyType;
object? value;
if (!propertyType.IsValueType || Nullable.GetUnderlyingType(propertyType) is not null)
{
value = null;
}
else
{
value = Activator.CreateInstance(propertyType);
}
prop.SetValue(target, value);
}
private sealed record StatusFilterItem(EnvelopeStatus Value, string Text);
}

View File

@@ -41,7 +41,11 @@ builder.Services.AddScoped<SignatureService>();
builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddSingleton<AppVersionService>();
builder.Services.AddScoped<EnvelopeService>();
builder.Services.AddScoped<EnvelopeTypeService>();
builder.Services.AddScoped<LocalizationService>();
builder.Services.AddScoped<ReceiverService>();
builder.Services.AddScoped<PdfRenderService>();
builder.Services.AddScoped<CreateEnvelopeBootstrapStorageService>();
// UI Preferences & Theme
builder.Services.AddScoped<UserPreferencesService>();

View File

@@ -26,4 +26,24 @@ public class AnnotationService(IHttpClientFactory httpClientFactory)
var result = await response.Content.ReadFromJsonAsync<List<AnnotationDto>>(_jsonOptions, cancel);
return result ?? [];
}
public async Task RejectAsync(string envelopeKey, string? reason, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.PostAsJsonAsync(
$"/api/Annotation/reject/{Uri.EscapeDataString(envelopeKey)}",
reason,
_jsonOptions,
cancel);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancel);
throw new HttpRequestException(
$"Failed to reject envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}",
null,
response.StatusCode);
}
}
}

View File

@@ -0,0 +1,64 @@
using System.Text.Json;
using Microsoft.JSInterop;
namespace EnvelopeGenerator.Server.Client.Services;
public sealed class CreateEnvelopeBootstrapStorageService(IJSRuntime jsRuntime)
{
private const string StorageKeyPrefix = "sender.createEnvelope.bootstrap.";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<string> StoreAsync(CreateEnvelopeBootstrapPayload payload)
{
var token = Guid.NewGuid().ToString("N");
var key = BuildStorageKey(token);
var json = JsonSerializer.Serialize(payload, JsonOptions);
await jsRuntime.InvokeVoidAsync("sessionStorage.setItem", key, json);
return token;
}
public async Task<CreateEnvelopeBootstrapPayload?> ConsumeAsync(string? token)
{
if (string.IsNullOrWhiteSpace(token))
return null;
var key = BuildStorageKey(token);
var raw = await jsRuntime.InvokeAsync<string?>("sessionStorage.getItem", key);
await jsRuntime.InvokeVoidAsync("sessionStorage.removeItem", key);
if (string.IsNullOrWhiteSpace(raw))
return null;
try
{
return JsonSerializer.Deserialize<CreateEnvelopeBootstrapPayload>(raw, JsonOptions);
}
catch
{
return null;
}
}
private static string BuildStorageKey(string token) => $"{StorageKeyPrefix}{token}";
}
public sealed record CreateEnvelopeBootstrapPayload(
string Title,
string Message,
int? EnvelopeTypeId,
string Language,
bool UseAccessCode,
bool TFAEnabled,
int CertificationType,
int FinalEmailToCreator,
int FinalEmailToReceivers,
bool SendReminderEmails,
int FirstReminderDays,
int ReminderIntervalDays,
int ExpiresWhenDays,
int ExpiresWarningWhenDays,
IReadOnlyList<CreateEnvelopeBootstrapReceiver> Receivers,
byte[]? DocumentBytes,
string? DocumentFileName);
public sealed record CreateEnvelopeBootstrapReceiver(string FullName, string EmailAddress);

View File

@@ -1,10 +1,23 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Server.Client.Services;
public class DocumentService(IHttpClientFactory httpClientFactory)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private static HttpRequestException CreateHttpException(string messagePrefix, HttpStatusCode statusCode, string? reasonPhrase)
{
var statusCodeInt = (int)statusCode;
return new HttpRequestException(
$"{messagePrefix}. Status: {statusCodeInt} ({reasonPhrase ?? "Unknown error"})",
null,
statusCode);
}
/// <summary>
/// Fetches the PDF bytes for the given envelope key from the API.
@@ -17,16 +30,75 @@ public class DocumentService(IHttpClientFactory httpClientFactory)
var response = await http.GetAsync($"/api/Document/{Uri.EscapeDataString(envelopeKey)}", cancel);
if (!response.IsSuccessStatusCode)
{
var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
throw new HttpRequestException(
$"Failed to load document. Status: {statusCode} ({reasonPhrase})",
null,
response.StatusCode);
}
throw CreateHttpException("Failed to load document", response.StatusCode, response.ReasonPhrase);
var bytes = await response.Content.ReadAsByteArrayAsync(cancel);
return bytes;
}
/// <summary>
/// Fetches sender document details (including signature elements) by envelope id.
/// Uses GET /api/Document/details?EnvelopeId={envelopeId}.
/// </summary>
public async Task<DocumentDto?> GetSenderDocumentDetailsByEnvelopeIdAsync(int envelopeId, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var detailsResponse = await http.GetAsync($"/api/Document/details?EnvelopeId={envelopeId}", cancel);
if (detailsResponse.StatusCode == HttpStatusCode.NotFound)
{
var fallbackBytes = await TryGetSenderDocumentBytesByEnvelopeIdAsync(http, envelopeId, cancel);
if (fallbackBytes is null)
return null;
return new DocumentDto(fallbackBytes, new DocumentDetailsDto
{
EnvelopeId = envelopeId,
AddedWhen = DateTime.UtcNow,
Elements = []
});
}
if (!detailsResponse.IsSuccessStatusCode)
throw CreateHttpException("Failed to load sender document details", detailsResponse.StatusCode, detailsResponse.ReasonPhrase);
var details = await detailsResponse.Content.ReadFromJsonAsync<DocumentDetailsDto>(_jsonOptions, cancel)
?? throw new HttpRequestException("Failed to deserialize sender document details response.");
var byteData = await TryGetSenderDocumentBytesByEnvelopeIdAsync(http, envelopeId, cancel);
if (byteData is null)
byteData = [];
return new DocumentDto(byteData, details);
}
/// <summary>
/// Fetches sender document bytes by envelope id.
/// Uses GET /api/Document?EnvelopeId={envelopeId}.
/// </summary>
public async Task<byte[]?> GetSenderDocumentBytesByEnvelopeIdAsync(int envelopeId, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.GetAsync($"/api/Document?EnvelopeId={envelopeId}", cancel);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
if (!response.IsSuccessStatusCode)
throw CreateHttpException("Failed to load sender document bytes", response.StatusCode, response.ReasonPhrase);
return await response.Content.ReadAsByteArrayAsync(cancel);
}
private static async Task<byte[]?> TryGetSenderDocumentBytesByEnvelopeIdAsync(HttpClient http, int envelopeId, CancellationToken cancel)
{
var bytesResponse = await http.GetAsync($"/api/Document?EnvelopeId={envelopeId}", cancel);
if (bytesResponse.StatusCode == HttpStatusCode.NotFound)
return null;
if (!bytesResponse.IsSuccessStatusCode)
throw CreateHttpException("Failed to load sender document bytes", bytesResponse.StatusCode, bytesResponse.ReasonPhrase);
return await bytesResponse.Content.ReadAsByteArrayAsync(cancel);
}
}

View File

@@ -101,26 +101,7 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
CancellationToken cancel = default)
{
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
var payload = new
{
EnvelopeId = request.EnvelopeId,
Title = request.Title,
Message = request.Message,
TFAEnabled = request.TFAEnabled,
Send = request.Send,
Document = request.Document is null
? null
: new { DataAsBase64 = request.Document.DataAsBase64 },
Receivers = request.Receivers.Select(r => new
{
EmailAddress = r.EmailAddress,
Name = r.Name,
PhoneNumber = r.PhoneNumber,
Fields = r.Fields.Select(f => new { X = f.X, Y = f.Y, Page = f.Page }).ToList()
}).ToList()
};
var response = await httpClient.PostAsJsonAsync("/api/Envelope", payload, _jsonOptions, cancel);
var response = await httpClient.PostAsJsonAsync("/api/Envelope", request, _jsonOptions, cancel);
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<EnvelopeDto>(_jsonOptions, cancel);

View File

@@ -0,0 +1,22 @@
using EnvelopeGenerator.Application.Common.Dto;
using System.Net.Http.Json;
using System.Text.Json;
namespace EnvelopeGenerator.Server.Client.Services;
public class EnvelopeTypeService(IHttpClientFactory clientFactory)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public async Task<IReadOnlyList<EnvelopeTypeDto>> GetAsync(CancellationToken cancel = default)
{
var http = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.GetAsync("/api/EnvelopeType", cancel);
if (!response.IsSuccessStatusCode)
return [];
var data = await response.Content.ReadFromJsonAsync<List<EnvelopeTypeDto>>(JsonOptions, cancel);
return data ?? [];
}
}

View File

@@ -0,0 +1,32 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Server.Client.Services;
public class PdfRenderService(IHttpClientFactory httpClientFactory)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
public async Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsync(byte[] pdfBytes, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
using var response = await http.PostAsJsonAsync(
"/api/PdfRender/pages",
new PdfRenderRequestDto { PdfBytes = pdfBytes },
_jsonOptions,
cancel);
if (!response.IsSuccessStatusCode)
{
var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
throw new HttpRequestException(
$"Failed to render PDF pages. Status: {statusCode} ({reasonPhrase})",
null,
response.StatusCode);
}
return await response.Content.ReadFromJsonAsync<List<PdfPageImageDto>>(_jsonOptions, cancel) ?? [];
}
}

View File

@@ -0,0 +1,47 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
namespace EnvelopeGenerator.Server.Client.Services;
public class ReceiverService(IHttpClientFactory httpClientFactory)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Fetches receiver email suggestions from sender-authorized API.
/// Calls GET /api/Receiver?EmailAddressSearch={term}&onlyEmailAddress=true.
/// </summary>
public async Task<IReadOnlyList<string>> SearchReceiverEmailsAsync(string searchTerm, CancellationToken cancel = default)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return [];
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var url = $"/api/Receiver?EmailAddressSearch={Uri.EscapeDataString(searchTerm)}&onlyEmailAddress=true";
var response = await http.GetAsync(url, cancel);
if (!response.IsSuccessStatusCode)
return [];
var emails = await response.Content.ReadFromJsonAsync<List<string>>(JsonOptions, cancel);
return emails ?? [];
}
/// <summary>
/// Fetches recent receiver suggestions for the authenticated sender.
/// Calls GET /api/EnvelopeReceiver/recent-suggestions?take={take}.
/// </summary>
public async Task<IReadOnlyList<RecentReceiverSuggestionDto>> GetRecentReceiverSuggestionsAsync(int take = 20, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var url = $"/api/EnvelopeReceiver/recent-suggestions?take={take}";
var response = await http.GetAsync(url, cancel);
if (!response.IsSuccessStatusCode)
return [];
var suggestions = await response.Content.ReadFromJsonAsync<List<RecentReceiverSuggestionDto>>(JsonOptions, cancel);
return suggestions ?? [];
}
}

View File

@@ -11,6 +11,7 @@
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Server.Client.Models
@using EnvelopeGenerator.Server.Client.Options
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
@using DevExpress.Blazor
@using DevExpress.Blazor.PdfViewer
@using DevExpress.Blazor.Reporting

View File

@@ -22,6 +22,7 @@
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject AnnotationService AnnotationService
@inject AppVersionService AppVersion
@inject IMemoryCache MemoryCache
@inject ILogger<ReceiverPage> Logger
@@ -132,32 +133,6 @@
<span class="pdf-toolbar__btn-text">Unterschreiben</span>
</button>
}
@if (!(_envelopeReceiver?.Envelope?.ReadOnly ?? false))
{
<button class="pdf-toolbar__btn"
@onclick="OpenSharePopup"
title="Dokument teilen"
style="flex-shrink: 0;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.5 1a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2zm-11 5a1.5 1.5 0 1 0 1.415 2H4a1.5 1.5 0 0 0-1.5-2zm11 5a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2z"/>
<path d="M2.854 7.646a.5.5 0 0 1 .0-.707l9-5a.5.5 0 1 1 .486.874l-9 5a.5.5 0 0 1-.486-.167zm0 .708a.5.5 0 0 1 .486-.167l9 5a.5.5 0 0 1-.486.874l-9-5a.5.5 0 0 1 0-.707z"/>
</svg>
<span class="pdf-toolbar__btn-text">Teilen</span>
</button>
}
<button class="pdf-toolbar__btn"
@onclick="LogoutAsync"
disabled="@_isLoggingOut"
title="Abmelden"
style="flex-shrink: 0;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 15a1 1 0 0 0 1-1v-2a.5.5 0 0 1 1 0v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v2a.5.5 0 0 1-1 0V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h7z" />
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L14.293 7.5H6.5a.5.5 0 0 0 0 1h7.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z" />
</svg>
<span class="pdf-toolbar__btn-text">@(_isLoggingOut ? "Abmelden..." : "Abmelden")</span>
</button>
</div>
</div>
@@ -375,57 +350,6 @@
</FooterContentTemplate>
</DxPopup>
<DxPopup @bind-Visible="_sharePopupVisible"
HeaderText="Dokument teilen"
Width="520px"
MaxWidth="95vw"
ShowFooter="true"
CloseOnOutsideClick="true">
<BodyContentTemplate>
<div class="mb-3">
<label class="form-label" for="rp-share-mail" style="font-size: 0.9rem; font-weight: 500;">E-Mail</label>
<input id="rp-share-mail"
class="form-control"
placeholder="user@mail.com"
value="@_shareReceiverMail"
@oninput="args => _shareReceiverMail = args.Value?.ToString() ?? string.Empty" />
</div>
<div class="mb-1">
<label class="form-label" for="rp-share-date" style="font-size: 0.9rem; font-weight: 500;">Gultig bis</label>
<input id="rp-share-date"
class="form-control"
type="date"
min="@DateTime.Today.AddDays(1).ToString("yyyy-MM-dd")"
max="@DateTime.Today.AddDays(90).ToString("yyyy-MM-dd")"
value="@_shareDateValid.ToString("yyyy-MM-dd")"
@oninput="OnShareDateChanged" />
</div>
@if (!string.IsNullOrWhiteSpace(_shareMessage))
{
<div class="mt-3 alert @(_shareMessageIsError ? "alert-danger" : "alert-success") mb-0" role="alert">
@_shareMessage
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<div class="d-flex justify-content-end gap-2 w-100">
<button class="btn btn-outline-secondary"
@onclick="CloseSharePopup"
disabled="@_isSharing">
Schliessen
</button>
<button class="btn btn-primary"
@onclick="ShareEnvelopeAsync"
disabled="@_isSharing"
style="background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%); border: none;">
@(_isSharing ? "Senden..." : "Senden")
</button>
</div>
</FooterContentTemplate>
</DxPopup>
@code {
// ----- Constants -----
const string SignatureTabDraw = "draw";
@@ -480,6 +404,12 @@
string? _shareMessage;
bool _shareMessageIsError = false;
// ----- Reject popup state -----
bool _rejectPopupVisible = false;
bool _isRejectSubmitting = false;
string _rejectReason = string.Empty;
string? _rejectValidationMessage;
async Task LogoutAsync()
{
if (_isLoggingOut || string.IsNullOrWhiteSpace(EnvelopeKey))
@@ -552,23 +482,12 @@
try
{
var shared = await AuthService.ShareEnvelopeReadOnlyAsync(EnvelopeKey!, email, _shareDateValid.Date);
if (shared)
{
_shareMessage = "Dokument wurde erfolgreich als Nur-Lesen freigegeben.";
_shareMessage = "Freigabe ist in dieser Ansicht derzeit nicht verfugbar.";
_shareMessageIsError = false;
_shareReceiverMail = string.Empty;
_shareDateValid = DateTime.Today.AddDays(7);
}
else
{
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
_shareMessageIsError = true;
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to share envelope in read-only mode for {EnvelopeKey}", EnvelopeKey);
Logger.LogError(ex, "Failed to share envelope {EnvelopeKey}", EnvelopeKey);
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
_shareMessageIsError = true;
}
@@ -627,6 +546,11 @@
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
if (_envelopeReceiver is null)
Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey);
else if (IsRejected(_envelopeReceiver))
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/rejected", forceLoad: true);
return;
}
// Build initial report (no signature image yet)
_report = BuildReport(_pdfBytes, _signatures, capturedSignature: null);
@@ -672,6 +596,16 @@
}
// ----- Report builder -----
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
{
var status = envelopeReceiver.Envelope?.Status;
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
return true;
return envelopeReceiver.Envelope?.Histories?.Any(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
}
/// <summary>
/// Builds an XtraReport wrapping the PDF bytes.
/// If a signature is captured and there are signature fields, the signature image is
@@ -905,6 +839,56 @@
forceLoad: true);
}
void OpenRejectPopup()
{
_rejectValidationMessage = null;
_rejectPopupVisible = true;
}
void CloseRejectPopup()
{
if (_isRejectSubmitting)
return;
_rejectPopupVisible = false;
}
async Task SubmitRejectAsync()
{
if (_isRejectSubmitting)
return;
_isRejectSubmitting = true;
_rejectValidationMessage = null;
await InvokeAsync(StateHasChanged);
try
{
var reason = string.IsNullOrWhiteSpace(_rejectReason)
? null
: _rejectReason.Trim();
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
_rejectValidationMessage = "Envelope-Schluessel fehlt.";
_isRejectSubmitting = false;
await InvokeAsync(StateHasChanged);
return;
}
await AnnotationService.RejectAsync(EnvelopeKey, reason);
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to reject envelope {EnvelopeKey}", EnvelopeKey);
_rejectValidationMessage = "Die Ablehnung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.";
_isRejectSubmitting = false;
await InvokeAsync(StateHasChanged);
}
}
async Task<string?> GetActiveSignatureDataUrlAsync()
{
if (_activeSignatureTab == SignatureTabDraw)

View File

@@ -0,0 +1,105 @@
@page "/envelope/{EnvelopeKey}/rejected"
@rendermode InteractiveServer
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Constants
@using EnvelopeGenerator.Domain.Interfaces
@using Microsoft.Extensions.Localization
@inject NavigationManager Navigation
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject IStringLocalizer<Resource> UiLocalizer
<div class="envelope-viewer-layout" style="justify-content: center; align-items: center; padding: 1.5rem;">
<div style="width: min(920px, 100%); background: rgba(255,255,255,0.96); border-radius: 20px; box-shadow: 0 18px 48px rgba(15,23,42,0.22); overflow: hidden;">
<div style="padding: 1.2rem 1.4rem; border-bottom: 1px solid rgba(15,23,42,0.08); background: linear-gradient(135deg, rgba(239,68,68,0.12) 0%, rgba(220,38,38,0.08) 100%);">
<div style="font-size: 1rem; font-weight: 700; color: #991b1b;">@L("DocRejected", "Dokument abgelehnt")</div>
</div>
<div style="padding: 2rem 1.5rem;">
<div style="display: flex; justify-content: center; margin-bottom: 1rem;">
<div style="width: 90px; height: 90px; border-radius: 999px; background: #fee2e2; display: inline-flex; align-items: center; justify-content: center; color: #b91c1c;">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
</div>
</div>
<h2 style="text-align: center; font-size: 1.4rem; color: #7f1d1d; margin-bottom: 0.75rem;">
@(_isReadAndConfirm
? L("RejectionInfo1Confirmation", "Dieses Dokument wurde als nicht bestaetigt markiert.")
: L("RejectionInfo1", "Dieses Dokument wurde abgelehnt."))
</h2>
<p style="text-align: center; margin: 0 auto; max-width: 760px; color: #374151; line-height: 1.6;">
@((MarkupString)_messageLine2)
</p>
<div style="margin-top: 1.4rem; text-align: center;">
<a href="/" class="btn btn-outline-secondary" style="font-weight: 600;">Portal Startseite</a>
</div>
</div>
</div>
</div>
@code {
[Parameter] public string? EnvelopeKey { get; set; }
bool _isReadAndConfirm;
string _messageLine2 = string.Empty;
protected override async Task OnInitializedAsync()
{
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
Navigation.NavigateTo("/");
return;
}
var user = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
if (user is null)
{
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
return;
}
var envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
if (envelopeReceiver is null)
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return;
}
if (!IsRejected(envelopeReceiver))
{
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return;
}
_isReadAndConfirm = envelopeReceiver.Envelope?.IsReadAndConfirm() ?? false;
var senderName = envelopeReceiver.Envelope?.User is null
? string.Empty
: $"{envelopeReceiver.Envelope.User.Prename} {envelopeReceiver.Envelope.User.Name}".Trim();
var senderEmail = envelopeReceiver.Envelope?.User?.Email ?? string.Empty;
var envelopeTitle = envelopeReceiver.Envelope?.Title ?? string.Empty;
var template = L("RejectionInfo2", "Das Dokument \"{2}\" von {0} ({1}) wurde abgelehnt.");
_messageLine2 = string.Format(template, senderName, senderEmail, envelopeTitle);
}
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
{
var status = envelopeReceiver.Envelope?.Status;
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
return true;
return envelopeReceiver.Envelope?.Histories?.Any(h =>
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
}
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -1,114 +0,0 @@
@page "/envelope/DxPdfViewer"
@rendermode InteractiveServer
@using System.IO
@using DevExpress.Blazor
@using System.Reflection
@using DevExpress.Blazor.PdfViewer
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<style>
.custom-drop-zone {
padding: 0 !important;
border-style: dashed;
border-width: 2px !important;
height: 230px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(183, 183, 183, 0.1);
}
.custom-drop-zone.custom-drop-zone-hover {
border-style: solid;
}
.custom-drop-zone svg {
width: 42px;
height: 42px;
}
.custom-drop-zone > *:not(#overviewDemoSelectButton) {
pointer-events: none;
}
.pdf-viewer {
height: 800px !important;
min-height: 800px !important;
}
.pdf-viewer .dxbrv-surface-wrapper,
.pdf-viewer .dxbrv-document-surface {
height: 100% !important;
min-height: 750px !important;
}
.pdf-viewer .dxbrv-report-preview-content {
width: auto !important;
height: auto !important;
min-width: 200px !important;
min-height: 200px !important;
}
</style>
<div id="overviewDemoDropZone" class="card custom-drop-zone rounded-3 w-100 m-0">
<span class="drop-file-icon mb-3"></span>
<span class="drop-file-label">Drag and Drop File Here</span><span class="m-1">or</span>
<DxButton Id="overviewDemoSelectButton"
CssClass="m-1"
RenderStyle="ButtonRenderStyle.Primary"
Text="Select File" />
</div>
<DxFileInput @ref="fileInput"
AcceptedFileTypes="@ALLOWED_FILE_TYPES"
AllowedFileExtensions="@ALLOWED_FILE_TYPES"
CssClass="w-100"
ExternalDropZoneCssSelector="#overviewDemoDropZone"
ExternalDropZoneDragOverCssClass="custom-drop-zone-hover"
ExternalSelectButtonCssSelector="#overviewDemoSelectButton"
FilesUploading="OnFilesUploading"
MaxFileSize="2000000">
</DxFileInput>
@if (DocumentContent != null && DocumentContent.Length > 0)
{
<div class="alert alert-success mt-3">
PDF loaded: @DocumentContent.Length bytes
</div>
<DxPdfViewer CssClass="w-100 pdf-viewer" DocumentContent="@DocumentContent" />
}
else
{
<div class="alert alert-info mt-3">
Please upload a PDF file to view it.
</div>
}
@code {
readonly List<string> ALLOWED_FILE_TYPES = new List<string> { ".pdf" };
DxFileInput fileInput;
byte[] DocumentContent { get; set; }
protected override void OnInitialized()
{
Assembly assembly = Assembly.GetExecutingAssembly();
Stream stream = assembly.GetManifestResourceStream("EnvelopeGenerator.Server.Resources.Invoice.pdf");
if (stream != null)
{
using (stream)
using (var binaryReader = new BinaryReader(stream))
DocumentContent = binaryReader.ReadBytes((int)stream.Length);
}
}
protected async Task OnFilesUploading(FilesUploadingEventArgs args)
{
using (MemoryStream stream = new MemoryStream())
{
IFileInputSelectedFile file = args.Files[0];
await file.OpenReadStream(file.Size).CopyToAsync(stream);
DocumentContent = stream.ToArray();
await InvokeAsync(StateHasChanged);
}
}
}

View File

@@ -1,50 +0,0 @@
@page "/envelope/{EnvelopeKey}/DxReportViewer"
@rendermode InteractiveServer
@using XtraReport = DevExpress.XtraReports.UI.XtraReport
@using DevExpress.Blazor.Reporting
@using Microsoft.Extensions.Options
@using EnvelopeGenerator.Server.Client.Options
@using EnvelopeGenerator.Server.Client.Services
@inject InMemoryReportStorageWebExtension ReportStorage
@inject DocumentService DocumentService
@inject IOptions<ApiOptions> AppOptions
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Reporting.Viewer/css/dx-blazor-reporting-components.bs5.css" rel="stylesheet" />
@if (_report is not null) {
<DxReportViewer Report="_report" RootCssClasses="w-100 h-100" Zoom="1.3" />
}
@code {
[Parameter] public string EnvelopeKey { get; init; } = null!;
XtraReport? _report = null;
protected override async Task OnInitializedAsync()
{
_report = await CreateReport();
}
async Task<XtraReport> CreateReport()
{
if (AppOptions.Value.UsePredefinedReports)
{
return Client.PredefinedReports.ReportsFactory.GetReport("LargeDatasetReport");
}
else
{
var pdfBytes = await DocumentService.GetDocumentAsync(EnvelopeKey);
if (pdfBytes is null || pdfBytes.Length == 0)
throw new InvalidOperationException($"No PDF bytes found for EnvelopeKey: {EnvelopeKey}");
var report = new XtraReport();
var detail = new DevExpress.XtraReports.UI.DetailBand();
report.Bands.Add(detail);
detail.Controls.Add(new DevExpress.XtraReports.UI.XRPdfContent { Source = pdfBytes, GenerateOwnPages = true });
return report;
}
}
}

View File

@@ -1,124 +0,0 @@
@page "/envelope/Embed"
@rendermode InteractiveServer
@using System.IO
@using DevExpress.Blazor
@using System.Reflection
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<style>
.custom-drop-zone {
padding: 0 !important;
border-style: dashed;
border-width: 2px !important;
height: 230px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(183, 183, 183, 0.1);
}
.custom-drop-zone.custom-drop-zone-hover {
border-style: solid;
}
.custom-drop-zone svg {
width: 42px;
height: 42px;
}
.custom-drop-zone > *:not(#overviewDemoSelectButton) {
pointer-events: none;
}
.pdf-viewer {
height: 800px !important;
min-height: 800px !important;
}
.pdf-viewer .dxbrv-surface-wrapper,
.pdf-viewer .dxbrv-document-surface {
height: 100% !important;
min-height: 750px !important;
}
.pdf-viewer .dxbrv-report-preview-content {
width: auto !important;
height: auto !important;
min-width: 200px !important;
min-height: 200px !important;
}
</style>
<div id="overviewDemoDropZone" class="card custom-drop-zone rounded-3 w-100 m-0">
<span class="drop-file-icon mb-3"></span>
<span class="drop-file-label">Drag and Drop File Here</span><span class="m-1">or</span>
<DxButton Id="overviewDemoSelectButton"
CssClass="m-1"
RenderStyle="ButtonRenderStyle.Primary"
Text="Select File" />
</div>
<DxFileInput @ref="fileInput"
AcceptedFileTypes="@ALLOWED_FILE_TYPES"
AllowedFileExtensions="@ALLOWED_FILE_TYPES"
CssClass="w-100"
ExternalDropZoneCssSelector="#overviewDemoDropZone"
ExternalDropZoneDragOverCssClass="custom-drop-zone-hover"
ExternalSelectButtonCssSelector="#overviewDemoSelectButton"
FilesUploading="OnFilesUploading"
MaxFileSize="2000000">
</DxFileInput>
@if (DocumentContent != null && DocumentContent.Length > 0)
{
<div class="alert alert-success mt-3">
PDF loaded: @DocumentContent.Length bytes
</div>
<embed src="@GetPdfDataUrl()" type="application/pdf" class="w-100 pdf-viewer" />
}
else
{
<div class="alert alert-info mt-3">
Please upload a PDF file to view it.
</div>
}
@code {
readonly List<string> ALLOWED_FILE_TYPES = new List<string> { ".pdf" };
DxFileInput fileInput;
byte[] DocumentContent { get; set; }
protected override void OnInitialized()
{
Assembly assembly = Assembly.GetExecutingAssembly();
Stream stream = assembly.GetManifestResourceStream("EnvelopeGenerator.Server.Resources.Invoice.pdf");
if (stream != null)
{
using (stream)
using (var binaryReader = new BinaryReader(stream))
DocumentContent = binaryReader.ReadBytes((int)stream.Length);
}
}
protected async Task OnFilesUploading(FilesUploadingEventArgs args)
{
using (MemoryStream stream = new MemoryStream())
{
IFileInputSelectedFile file = args.Files[0];
await file.OpenReadStream(file.Size).CopyToAsync(stream);
DocumentContent = stream.ToArray();
await InvokeAsync(StateHasChanged);
}
}
private string GetPdfDataUrl()
{
if (DocumentContent == null || DocumentContent.Length == 0)
return string.Empty;
string base64 = Convert.ToBase64String(DocumentContent);
return $"data:application/pdf;base64,{base64}#toolbar=0&navpanes=0&scrollbar=1";
}
}

View File

@@ -100,11 +100,12 @@ public class AnnotationController : ControllerBase
/// <summary>
/// Rejects the document for the current receiver.
/// </summary>
/// <param name="envelopeKey">Envelope key route parameter.</param>
/// <param name="reason">Optional rejection reason.</param>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("reject")]
[HttpPost("reject/{envelopeKey}")]
[Obsolete("Use MediatR")]
public async Task<IActionResult> Reject([FromBody] string? reason = null)
public async Task<IActionResult> Reject([FromRoute] string envelopeKey, [FromBody] string? reason = null)
{
var signature = User.ReceiverSignature();
var uuid = User.EnvelopeUuid();

View File

@@ -31,17 +31,30 @@ public class DocumentController(IMediator mediator, IAuthorizationService authSe
/// <param name="cancel">Cancellation token.</param>
[HttpGet]
[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)
{
if (query is null)
return BadRequest("Missing document query.");
var senderDoc = await mediator.Send(query, cancel);
return senderDoc.ByteData is byte[] senderDocByte
return senderDoc?.ByteData is byte[] senderDocByte
? File(senderDocByte, "application/octet-stream")
: NotFound("Document is empty.");
}
/// <summary>
/// Returns sender document metadata and element details (without binary payload) for editor loading.
/// </summary>
/// <param name="query">Document query with EnvelopeId.</param>
/// <param name="cancel">Cancellation token.</param>
[HttpGet("details")]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public async Task<IActionResult> GetDocumentDetails(CancellationToken cancel, [FromQuery] ReadDocumentDetailsQuery query)
{
var senderDoc = await mediator.Send(query with { UserId = User.GetId() }, cancel);
if (senderDoc is null)
return NotFound("Document is empty.");
return Ok(senderDoc);
}
/// <summary>
/// Gets the document for the specified envelope key.
/// </summary>

View File

@@ -112,6 +112,20 @@ public class EnvelopeReceiverController : ControllerBase
return name is null ? NotFound() : Ok(name);
}
/// <summary>
/// Returns recent receiver suggestions for the authenticated sender.
/// </summary>
/// <param name="query">Suggestion query options (take 3..5 is applied in handler).</param>
/// <param name="cancel">Cancellation token.</param>
/// <returns>List of recent distinct receiver suggestions.</returns>
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpGet("recent-suggestions")]
public async Task<IActionResult> GetRecentSuggestions([FromQuery] ReadRecentReceiverSuggestionsQuery query, CancellationToken cancel)
{
var result = await _mediator.Send(query.ForSender(User.GetUsername()), cancel);
return Ok(result);
}
/// <summary>
/// Datenübertragungsobjekt mit Informationen zu Umschlägen, Empfängern und Unterschriften.
/// </summary>

View File

@@ -0,0 +1,18 @@
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Server.Controllers;
[ApiController]
[Route("api/[controller]")]
public class HealthController(IPkcs11SelfTestStatusProvider pkcs11StatusProvider) : ControllerBase
{
[AllowAnonymous]
[HttpGet("crypt")]
public IActionResult GetCryptHealth()
{
var status = pkcs11StatusProvider.GetStatus();
return Ok(status);
}
}

View File

@@ -0,0 +1,25 @@
using DigitalData.Auth.Claims;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Server.Controllers;
[ApiController]
[Route("api/[controller]")]
public class PdfRenderController(IPdfPageImageRenderer renderer) : ControllerBase
{
// TODO(perf): Replace JSON/base64 transport for PdfBytes with application/octet-stream (or multipart) and stream-based request parsing to reduce payload size and allocation pressure for large PDFs.
[HttpPost("pages")]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public async Task<IActionResult> RenderPages([FromBody] PdfRenderRequestDto? request, CancellationToken cancel)
{
if (request?.PdfBytes is not { Length: > 0 } bytes)
return BadRequest("Missing PDF bytes.");
IReadOnlyList<PdfPageImageDto> pages = await renderer.RenderPagesAsImagesAsync(bytes, cancel);
return Ok(pages);
}
}

View File

@@ -0,0 +1,78 @@
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Enums;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Server.Controllers;
[ApiController]
[Route("api/[controller]")]
public class PdfSignatureController(IPdfDigitalSignatureService signatureService) : ControllerBase
{
public sealed class SignPdfForm
{
public IFormFile? File { get; set; }
public string? UUID { get; set; }
public string? Name { get; set; }
public string? Reason { get; set; }
public string? Location { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
}
public sealed class CheckPdfSignatureForm
{
public IFormFile? File { get; set; }
}
[AllowAnonymous]
[HttpPost("sign")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> Sign([FromForm] SignPdfForm request, CancellationToken cancel)
{
var file = request.File;
if (file is null || file.Length == 0)
return BadRequest("PDF file is required.");
if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
return BadRequest("Only PDF files are supported.");
var signOptions = new PdfSignOptionsDto
{
UUID = request.UUID,
Name = request.Name,
Reason = request.Reason,
Location = request.Location,
Email = request.Email,
Phone = request.Phone,
CertificationLevel = PdfSignatureCertificationLevel.FormFillingAndAnnotations
};
await using var input = file.OpenReadStream();
await using var output = new MemoryStream();
await signatureService.SignPdfAsync(input, output, signOptions, cancel);
output.Position = 0;
var fileName = Path.GetFileNameWithoutExtension(file.FileName);
return File(output, "application/pdf", $"{fileName}-signed.pdf");
}
[AllowAnonymous]
[HttpPost("is-signed")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> IsSigned([FromForm] CheckPdfSignatureForm request, CancellationToken cancel)
{
var file = request.File;
if (file is null || file.Length == 0)
return BadRequest("PDF file is required.");
if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
return BadRequest("Only PDF files are supported.");
await using var input = file.OpenReadStream();
var result = await signatureService.CheckPdfSignatureAsync(input, cancel);
return Ok(result);
}
}

View File

@@ -10,9 +10,9 @@
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>EnvelopeGenerator.Server</Product>
<Version>1.4.0-beta</Version>
<FileVersion>1.4.0.0</FileVersion>
<AssemblyVersion>1.4.0.0</AssemblyVersion>
<Version>1.5.0-beta</Version>
<FileVersion>1.5.0.0</FileVersion>
<AssemblyVersion>1.5.0.0</AssemblyVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
@@ -21,6 +21,8 @@
<ProjectReference Include="..\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure.Crypt\EnvelopeGenerator.Infrastructure.Crypt.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
<ProjectReference Include="..\..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />
</ItemGroup>

View File

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

View File

@@ -26,6 +26,9 @@ using NLog;
using DigitalData.Auth.Claims;
using EnvelopeGenerator.Server;
using DigitalData.EmailProfilerDispatcher;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Infrastructure.Doc;
using EnvelopeGenerator.Infrastructure.Crypt.DependencyInjection;
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
logger.Info("EnvelopeGenerator.Server logging initialized!");
@@ -36,6 +39,7 @@ try
// Load YARP configuration from yarp.json
builder.Configuration.AddJsonFile("yarp.json", optional: true, reloadOnChange: true);
builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true);
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace);
@@ -341,6 +345,10 @@ try
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.LocalizationService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ReceiverService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfRenderService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.EnvelopeTypeService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService>();
// Business Services (Server specific)
builder.Services.AddScoped<DocumentService>();
@@ -349,6 +357,8 @@ try
builder.Services.AddScoped<EnvelopeReceiverService>();
builder.Services.AddScoped<SignatureService>();
builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddDocInfrastructure();
builder.Services.AddCryptInfrastructure(config);
builder.Services.AddScoped<EnvelopeGenerator.Application.Common.Interfaces.Services.IEnvelopeMailService, EnvelopeGenerator.Application.Services.EnvelopeMailService>();
builder.Services.AddSingleton<AppVersionService>();

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