Compare commits

...

66 Commits

Author SHA1 Message Date
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
940d129c11 Add read-only sharing and viewing functionality
Introduced the ability to share envelopes in read-only mode and view them via a dedicated page. Added `ShareEnvelopeReadOnlyAsync` in `AuthService.cs` to handle sharing logic. Updated `ReceiverPage.razor` with a "Share" button, popup UI, and validation for sharing inputs. Created `ReceiverReadOnlyPage.razor` for read-only document viewing with error handling and lifecycle management.

Added `EnvelopeKeyRedirectController.cs` to support legacy link redirection. Enhanced `ReadOnlyController.cs` to support envelope sharing with improved logging and `IMediator` integration. Improved state management, validation, and error handling across components. Performed general code cleanup and UI enhancements for better user experience.
2026-09-28 14:42:52 +02:00
44bbdbb835 Add logout functionality and signed status check
Introduced a logout feature to the `ReceiverPage` component:
- Added a logout button to the toolbar with `_isLoggingOut` state.
- Implemented `LogoutAsync` method to handle logout logic, including navigation and error handling.
- Injected `IMediator` and `ILogger<ReceiverPage>` for logging and MediatR functionality.
- Added a check in `OnInitializedAsync` to redirect users to `/envelope/signed-result` if the envelope is already signed.
- Updated namespaces to include MediatR and custom extensions.

These changes improve user experience and enhance the component's functionality.
2026-09-28 14:11:06 +02:00
223017006b Update launch URLs in launchSettings.json
Updated the `applicationUrl` property in the "https (Blazor UI)"
profile of `launchSettings.json`. Changed the HTTPS port from
8089 to 8088 and the HTTP port from 5132 to 5131. This ensures
the development server uses the new port configuration and
avoids potential conflicts with other services.
2026-09-28 13:59:44 +02:00
ad9250c7b1 Merge branch 'master' of http://git.dd:3000/AppStd/EnvelopeGenerator
# Conflicts:
#	EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverSignedPage.razor
#	EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json
2026-09-28 13:53:40 +02:00
5cc47ca9c7 Add signed result page and update navigation flow
Introduced a new `/envelope/signed-result` page to confirm
successful document signing with localized messages and
a visually appealing UI. Updated `ReceiverSignedPage.razor`
to redirect users to the new page after signing.

Enhanced styles in `app.css` for the signed result page,
including responsive layout, theme-specific designs, and
typography improvements. Added support for localization
using `IStringLocalizer` with fallback strings in German.

Improves user experience by providing clear feedback and
a polished confirmation page after signing.
2026-09-28 13:46:48 +02:00
156988403a Add envelope receiver access code request feature
Introduced a feature to allow envelope receivers to request an
access code via email on the login page.

- Added `OnInitializedAsync` in `LoginReceiverPage.razor` to
  trigger access code requests using `AuthService`.
- Implemented `RequestEnvelopeReceiverAccessCodeAsync` in
  `AuthService.cs` to handle API calls for access code requests.
- Added a new endpoint in `AuthController` to process access
  code requests, including validation, history recording, and
  email dispatching.
- Registered `IEnvelopeMailService` and `AddDispatcher` in
  `Program.cs` to support email dispatch and database operations.
- Refactored `AuthController` constructor for readability and
  added necessary `using` directives.
- Improved logging and error handling for better traceability
  and robustness.

These changes ensure a seamless and secure process for envelope
receivers to request access codes, enhancing the user experience.
2026-09-28 13:35:32 +02:00
380a5f4c0c Add signature submission feature and pipeline updates
Introduced a new feature for submitting signatures, including
backend and frontend changes. Added `SignatureSubmitDto` and
`SubmitAsync` in `SignatureService` to handle signature data
submission. Implemented a new `Submit` endpoint in
`SignatureController` to validate and process signature payloads.

Updated pipeline behaviors to include `SaveSignatureBehavior`
and adjusted the execution order. Enhanced query logic in
`EnvelopeReceiverResolutionBehavior` and `SaveSignatureBehavior`
to align with the updated data model.

Refactored `EGDbContext` to improve trigger handling and logging.
Updated `ReceiverSignedPage.razor` to integrate `SignatureService`
and handle submission errors gracefully. Adjusted application
URLs in `launchSettings.json` and added new database triggers
in `appsettings.json`.
2026-09-28 12:21:44 +02:00
0f53d0509e remove depr API 2026-09-28 09:51:51 +02:00
76 changed files with 5298 additions and 1649 deletions

View File

@@ -236,6 +236,21 @@ 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.
## 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,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

@@ -49,7 +49,7 @@ public sealed record Signature
public string? Position
{
get => _position;
init => _position = string.IsNullOrWhiteSpace(value) ? value : null;
init => _position = string.IsNullOrWhiteSpace(value) ? null : value;
}
/// <summary>

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

@@ -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,23 +62,31 @@ 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>();
// 1. AnnotationBehavior - Saves annotations (executes second)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SaveSignatureBehavior>();
// 2. AnnotationBehavior - Saves annotations for legacy web flow
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, AnnotationBehavior>();
// 2. DocStatusBehavior - Creates document status (executes third)
// 3. DocStatusBehavior - Creates document status
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>();
// 3. HistoryBehavior - Records history (executes fourth)
// 4. HistoryBehavior - Records history
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>();
// 4. SendSignedMailBehavior - Sends notification email (executes LAST, only if all previous succeed)
// 5. SendSignedMailBehavior - Sends notification email (executes LAST)
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
});
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
return services;
}
}

View File

@@ -43,7 +43,11 @@ public class EnvelopeReceiverResolutionBehavior : IPipelineBehavior<SigningComma
// If EnvelopeReceiver is not provided, query it from database
if (request.EnvelopeReceiver is null)
{
var er = await _erRepo.Query.Where(request, notnull: true).SingleOrDefaultAsync(cancellationToken)
var er = await _erRepo.Query
.Where(request, notnull: true)
.Include(x => x.Envelope)
.Include(x => x.Receiver)
.SingleOrDefaultAsync(cancellationToken)
?? throw new NotFoundException("EnvelopeReceiver not found");
request.SetEnvelopeReceiver(_mapper.Map<EnvelopeReceiverDto>(er));

View File

@@ -50,8 +50,8 @@ public class SaveSignatureBehavior : IPipelineBehavior<SigningCommand, Unit>
throw new BadRequestException($"Signatures are required for saving signature behavior.");
var elements = await _elementRepo
.Where(e => e.Document.EnvelopeId == request.Envelope.Id)
.Where(e => e.ReceiverId == request.Receiver.Id)
.Where(e => e.Document.EnvelopeId == request.EnvelopeReceiver.EnvelopeId)
.Where(e => e.ReceiverId == request.EnvelopeReceiver.ReceiverId)
.ToListAsync(cancel);
foreach (var element in elements)

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

@@ -19,6 +19,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

@@ -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

@@ -735,7 +735,9 @@
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Fehler beim Laden der Datei: {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Bitte unterzeichnen Sie das beigefügte Dokument.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Kein Dokument geladen</value></data>
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Keine Seiten zur Anzeige verfügbar.</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Noch keine Empfänger hinzugefügt.</value></data>
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Seite</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Signaturfeld platzieren</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Alle Signaturfelder entfernen</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Bitte wählen Sie eine PDF-Datei aus.</value></data>

View File

@@ -735,7 +735,9 @@
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Error loading file: {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Please sign the attached document.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>No document loaded</value></data>
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>No pages to display.</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>No receivers added yet.</value></data>
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Place signature field</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Remove all signature fields</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Please select a PDF file.</value></data>

View File

@@ -735,7 +735,9 @@
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Erreur lors du chargement du fichier : {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Veuillez signer le document ci-joint.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Aucun document chargé</value></data>
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Aucune page à afficher.</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Aucun destinataire ajouté pour le moment.</value></data>
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Placer un champ de signature</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Supprimer tous les champs de signature</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Veuillez sélectionner un fichier PDF.</value></data>

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

@@ -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

@@ -204,16 +204,24 @@ public abstract class EGDbContextBase : DbContext
#region Trigger
// Configure entities to handle database triggers
void AddTrigger<T>() where T : class => _triggers
.Where(t => t.Key == typeof(T).Name)
void AddTrigger<T>() where T : class
{
var entityName = typeof(T).Name;
var configuredTriggerNames = _triggers
.Where(t => t.Key == entityName)
.SelectMany(t => t.Value)
.ForEach(tName =>
.Distinct()
.ToList();
foreach (var triggerName in configuredTriggerNames)
{
#if NET
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(tName));
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(triggerName));
#endif
_logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", tName, typeof(T).Name);
});
_logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", triggerName, entityName);
}
}
// TODO: call add trigger methods with attributes and reflection
AddTrigger<Config>();

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

@@ -80,7 +80,7 @@
_selected = option;
_isOpen = false;
await LocalizationService.SetLanguageAsync(option.Code);
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
Navigation.NavigateTo(Navigation.Uri, forceLoad: false);
}
private sealed record LanguageOption(string Code, string FlagClass, string ShortName);

View File

@@ -0,0 +1,10 @@
namespace EnvelopeGenerator.Server.Client.Models;
public sealed record SignatureSubmitDto
{
public required int Id { get; init; }
public required string DataUrl { get; init; }
public required string FullName { get; init; }
public string? Position { get; init; }
public required string Place { get; init; }
}

View File

@@ -1,24 +1,22 @@
@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 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.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 NavigationManager Navigation
@inject IJSRuntime JSRuntime
@@ -262,7 +260,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 +312,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" />
@@ -459,10 +465,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 +547,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";
@@ -590,10 +609,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)
{
@@ -740,18 +760,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 +820,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 +838,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 +861,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 +973,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 +983,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 +1041,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 +1107,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 +1128,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

@@ -0,0 +1,29 @@
@page "/envelope/signed-result"
@rendermode InteractiveWebAssembly
@using EnvelopeGenerator.Application.Resources
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<Resource> UiLocalizer
<div class="receiver-signed-result-page">
<div class="receiver-signed-result-card">
<div class="receiver-signed-result-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" fill="currentColor" viewBox="0 0 16 16">
<path d="M2.5 8a5.5 5.5 0 0 1 8.25-4.764.5.5 0 0 0 .5-.866A6.5 6.5 0 1 0 14.5 8a.5.5 0 0 0-1 0 5.5 5.5 0 1 1-11 0z" />
<path d="M15.354 3.354a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l7-7z" />
</svg>
</div>
<h1 class="receiver-signed-result-title">@L("DocumentSuccessfullySigned", "Dokument erfolgreich unterzeichnet")</h1>
<p class="receiver-signed-result-message">
@L("DocumentSignedConfirmationMessage", "Vielen Dank. Ihre Unterschrift wurde erfolgreich gespeichert und an die beteiligten Parteien übermittelt.")
</p>
</div>
</div>
@code {
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -144,6 +144,14 @@
bool IsLoading;
EnvelopeLoginResult? LoginResult;
protected override async Task OnInitializedAsync()
{
if (string.IsNullOrWhiteSpace(EnvelopeKey))
return;
_ = await AuthService.RequestEnvelopeReceiverAccessCodeAsync(EnvelopeKey);
}
async Task OnKeyDownAsync(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e)
{
if (e.Key == "Enter")
@@ -162,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

@@ -41,7 +41,10 @@ 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>();
// 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

@@ -33,6 +33,20 @@ public class AuthService(IHttpClientFactory httpClientFactory)
return response.StatusCode == HttpStatusCode.OK;
}
/// <summary>
/// Triggers access code e-mail request flow for the receiver login page.
/// Calls POST /api/Auth/envelope-receiver/{key}/request-access-code.
/// </summary>
public async Task<bool> RequestEnvelopeReceiverAccessCodeAsync(string envelopeKey, CancellationToken cancel = default)
{
using var http = CreateDefaultClient();
var response = await http.PostAsync(
$"/api/Auth/envelope-receiver/{Uri.EscapeDataString(envelopeKey)}/request-access-code",
null,
cancel);
return response.IsSuccessStatusCode;
}
/// <summary>
/// Submits the access code for the given envelope key.
/// Calls POST /api/Auth/envelope-receiver/{key} with multipart/form-data.
@@ -72,6 +86,18 @@ public class AuthService(IHttpClientFactory httpClientFactory)
return response.IsSuccessStatusCode;
}
/// <summary>
/// Shares the current envelope in read-only mode for the specified receiver and expiration date.
/// Calls POST /api/readonly/{envelopeKey}.
/// </summary>
public async Task<bool> ShareEnvelopeReadOnlyAsync(string envelopeKey, string receiverMail, DateTime dateValid, CancellationToken cancel = default)
{
using var http = CreateDefaultClient();
var payload = new { receiverMail, dateValid };
var response = await http.PostAsJsonAsync($"/api/readonly/{Uri.EscapeDataString(envelopeKey)}", payload, cancel);
return response.IsSuccessStatusCode;
}
/// <summary>
/// Removes the per-envelope receiver cookie for the given envelope key.
/// Calls POST /api/auth/logout/envelope/{envelopeKey}.

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,29 @@
using System.Net.Http.Json;
using System.Text.Json;
namespace EnvelopeGenerator.Server.Client.Services;
public class ReceiverService(IHttpClientFactory httpClientFactory)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Fetches receiver email suggestions from sender-authorized API.
/// Calls GET /api/Receiver?EmailAddressSearch={term}&onlyEmailAddress=true.
/// </summary>
public async Task<IReadOnlyList<string>> SearchReceiverEmailsAsync(string searchTerm, CancellationToken cancel = default)
{
if (string.IsNullOrWhiteSpace(searchTerm))
return [];
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var url = $"/api/Receiver?EmailAddressSearch={Uri.EscapeDataString(searchTerm)}&onlyEmailAddress=true";
var response = await http.GetAsync(url, cancel);
if (!response.IsSuccessStatusCode)
return [];
var emails = await response.Content.ReadFromJsonAsync<List<string>>(JsonOptions, cancel);
return emails ?? [];
}
}

View File

@@ -1,6 +1,7 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Server.Client.Models;
namespace EnvelopeGenerator.Server.Client.Services;
@@ -21,4 +22,32 @@ public class SignatureService(IHttpClientFactory httpClientFactory)
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
return result ?? [];
}
public async Task SubmitAsync(string envelopeKey, IEnumerable<SignatureSubmitDto> signatures, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var payload = signatures
.Select(signature => new Signature
{
Id = signature.Id,
DataUrl = signature.DataUrl,
FullName = signature.FullName,
Position = signature.Position,
Place = signature.Place
})
.ToList();
var response = await http.PostAsJsonAsync(
$"/api/Signature/submit/{Uri.EscapeDataString(envelopeKey)}",
payload,
cancel);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(cancel);
throw new HttpRequestException(
$"Failed to submit signature for envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}");
}
}
}

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

@@ -1,4 +1,4 @@
@page "/sender/editor"
@page "/sender/editor_legacy"
@rendermode InteractiveServer
@using DevExpress.Blazor.PdfViewer
@using DevExpress.Blazor.Reporting.Models
@@ -543,7 +543,7 @@
var editQuery = EnvelopeId.HasValue && EnvelopeId.Value > 0
? $"&envelopeId={EnvelopeId.Value}"
: string.Empty;
NavigationManager.NavigateTo($"/sender/editor?esid={sid}{editQuery}", forceLoad: false);
NavigationManager.NavigateTo($"/sender/editor_legacy?esid={sid}{editQuery}", forceLoad: false);
}
}
@@ -837,7 +837,7 @@
if (previousEnvelopeId != result.Id)
{
var sid = string.IsNullOrWhiteSpace(Esid) ? Guid.NewGuid().ToString("N") : Esid;
NavigationManager.NavigateTo($"/sender/editor?esid={sid}&envelopeId={result.Id}", forceLoad: false);
NavigationManager.NavigateTo($"/sender/editor_legacy?esid={sid}&envelopeId={result.Id}", forceLoad: false);
}
NavigationManager.NavigateTo("/sender", forceLoad: false);

View File

@@ -5,20 +5,28 @@
@using EnvelopeGenerator.Server.Client.Models
@using EnvelopeGenerator.Server.Client.Models.Constants
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Common.Extensions
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
@using EnvelopeGenerator.Application.EnvelopeReceivers.Queries
@using EnvelopeGenerator.Domain.Constants
@using Microsoft.JSInterop
@using DevExpress.Blazor
@using System.Drawing
@using System.Security.Claims
@using System.ComponentModel.DataAnnotations
@using Microsoft.Extensions.Caching.Memory
@using MediatR
@using EnvelopeGenerator.Server.Extensions
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@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
@inject IMediator Mediator
@implements IDisposable
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
@@ -386,6 +394,109 @@
string _signerFullName = string.Empty;
string _signerPosition = string.Empty;
string _signaturePlace = string.Empty;
bool _isLoggingOut = false;
// ----- Share popup state -----
bool _sharePopupVisible = false;
bool _isSharing = false;
string _shareReceiverMail = string.Empty;
DateTime _shareDateValid = DateTime.Today.AddDays(7);
string? _shareMessage;
bool _shareMessageIsError = false;
// ----- Reject popup state -----
bool _rejectPopupVisible = false;
bool _isRejectSubmitting = false;
string _rejectReason = string.Empty;
string? _rejectValidationMessage;
async Task LogoutAsync()
{
if (_isLoggingOut || string.IsNullOrWhiteSpace(EnvelopeKey))
return;
_isLoggingOut = true;
await InvokeAsync(StateHasChanged);
try
{
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Receiver logout failed for {EnvelopeKey}", EnvelopeKey);
}
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
}
void OpenSharePopup()
{
_shareReceiverMail = string.Empty;
_shareDateValid = DateTime.Today.AddDays(7);
_shareMessage = null;
_shareMessageIsError = false;
_sharePopupVisible = true;
}
void CloseSharePopup()
{
if (_isSharing)
return;
_sharePopupVisible = false;
}
void OnShareDateChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args)
{
if (DateTime.TryParse(args.Value?.ToString(), out var parsedDate))
_shareDateValid = parsedDate.Date;
}
async Task ShareEnvelopeAsync()
{
if (_isSharing)
return;
var email = _shareReceiverMail.Trim();
if (string.IsNullOrWhiteSpace(email) || !new EmailAddressAttribute().IsValid(email))
{
_shareMessage = "Bitte geben Sie eine gultige E-Mail-Adresse ein.";
_shareMessageIsError = true;
return;
}
var minDate = DateTime.Today.AddDays(1);
var maxDate = DateTime.Today.AddDays(90);
if (_shareDateValid.Date < minDate || _shareDateValid.Date > maxDate)
{
_shareMessage = "Bitte wahlen Sie ein Datum zwischen morgen und 90 Tagen.";
_shareMessageIsError = true;
return;
}
_isSharing = true;
_shareMessage = null;
_shareMessageIsError = false;
await InvokeAsync(StateHasChanged);
try
{
_shareMessage = "Freigabe ist in dieser Ansicht derzeit nicht verfugbar.";
_shareMessageIsError = false;
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to share envelope {EnvelopeKey}", EnvelopeKey);
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
_shareMessageIsError = true;
}
finally
{
_isSharing = false;
await InvokeAsync(StateHasChanged);
}
}
// ----- Lifecycle -----
protected override async Task OnInitializedAsync()
@@ -397,6 +508,12 @@
return;
}
if (EnvelopeKey.TryDecode(out var decodedKeys) && decodedKeys.GetEncodeType() == EncodeType.EnvelopeReceiverReadOnly)
{
Navigation.NavigateTo($"/envelope/readonly/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
return;
}
// Authorization — same pattern as EnvelopeReceiverPage
_receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
if (_receiverUser is null)
@@ -405,6 +522,12 @@
return;
}
if (await Mediator.IsSignedAsync(_receiverUser.EnvelopeUuid(), _receiverUser.ReceiverSignature()))
{
Navigation.NavigateTo("/envelope/signed-result", forceLoad: true);
return;
}
try
{
// Load PDF bytes via MediatR (uses authenticated user's claims)
@@ -423,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);
@@ -468,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
@@ -701,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,188 @@
@page "/envelope/readonly/{ReadOnlyKey}"
@rendermode InteractiveServer
@using DevExpress.Blazor.Reporting
@using DevExpress.XtraReports.UI
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly
@using EnvelopeGenerator.Application.Common.Extensions
@using EnvelopeGenerator.Application.Common.Interfaces.Services
@using EnvelopeGenerator.Domain.Constants
@inject EnvelopeGenerator.Server.Client.Services.AppVersionService AppVersion
@inject ILogger<ReceiverReadOnlyPage> Logger
@inject IEnvelopeReceiverReadOnlyService ReadOnlyService
@inject IEnvelopeReceiverService EnvelopeReceiverService
@inject IEnvelopeHistoryService HistoryService
@implements IDisposable
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Reporting.Viewer/css/dx-blazor-reporting-components.bs5.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<div class="envelope-viewer-layout">
<div class="envelope-action-bar">
<div class="envelope-action-bar__inner" style="padding: 0.5rem 1.25rem;">
<div style="display:flex; align-items:center; justify-content:space-between; gap:1rem;">
<div style="font-weight:600; color:#1f2937;">Dokumentansicht (Nur Lesen)</div>
@if (_readOnly is not null)
{
<div style="font-size:0.8rem; color:#6b7280;">Gultig bis: @_readOnly.DateValid.ToString("dd.MM.yyyy")</div>
}
</div>
</div>
</div>
<div class="envelope-content" style="padding:0; overflow:hidden;">
@if (_isLoading)
{
<div class="d-flex justify-content-center align-items-center h-100">
<div class="text-center">
<div class="spinner-border text-white mb-3" style="width:3.5rem; height:3.5rem;" role="status"></div>
<p class="text-white fw-semibold">Dokument wird geladen...</p>
</div>
</div>
}
else if (!string.IsNullOrWhiteSpace(_errorMessage))
{
<div class="error-container">
<div class="alert alert-danger shadow-lg mb-0">
<h5 class="mb-2">Dokument nicht verfugbar</h5>
<p class="mb-0">@_errorMessage</p>
</div>
</div>
}
else if (_isExpired)
{
<div class="error-container">
<div class="alert alert-warning shadow-lg mb-0">
<h5 class="mb-2">Freigabe abgelaufen</h5>
<p class="mb-0">Der Zeitraum fur diese Nur-Lesen-Freigabe ist abgelaufen.</p>
</div>
</div>
}
else if (_report is not null)
{
<DxReportViewer @ref="_reportViewer"
Report="_report"
RootCssClasses="w-100 h-100" />
}
</div>
</div>
@code {
[Parameter] public string? ReadOnlyKey { get; set; }
bool _isLoading = true;
bool _isExpired = false;
string? _errorMessage;
EnvelopeReceiverReadOnlyDto? _readOnly;
DxReportViewer? _reportViewer;
XtraReport? _report;
protected override async Task OnInitializedAsync()
{
if (string.IsNullOrWhiteSpace(ReadOnlyKey))
{
_errorMessage = "Freigabe-Schlussel fehlt.";
_isLoading = false;
return;
}
if (!ReadOnlyKey.TryDecode(out var decodedKeys) || decodedKeys.GetEncodeType() != EncodeType.EnvelopeReceiverReadOnly)
{
_errorMessage = "Ungultiger Freigabe-Link.";
_isLoading = false;
return;
}
try
{
var readOnlyId = decodedKeys.ParseReadOnlyId();
var readOnlyRes = await ReadOnlyService.ReadByIdAsync(readOnlyId);
if (readOnlyRes.IsFailed)
{
_errorMessage = "Freigabe konnte nicht geladen werden.";
_isLoading = false;
return;
}
_readOnly = readOnlyRes.Data;
if (DateTime.Now > _readOnly.DateValid)
{
_isExpired = true;
_isLoading = false;
return;
}
if (_readOnly.Envelope is null || _readOnly.Receiver is null)
{
_errorMessage = "Freigabe ist unvollstandig.";
_isLoading = false;
return;
}
var erRes = await EnvelopeReceiverService.ReadByUuidSignatureAsync(_readOnly.Envelope.Uuid, _readOnly.Receiver.Signature);
if (erRes.IsFailed)
{
_errorMessage = "Dokument wurde nicht gefunden.";
_isLoading = false;
return;
}
var envelopeReceiver = erRes.Data;
var documentBytes = envelopeReceiver.Envelope?.Documents?.FirstOrDefault()?.ByteData;
if (documentBytes is not { Length: > 0 })
{
_errorMessage = "Dokumentdaten konnten nicht geladen werden.";
_isLoading = false;
return;
}
_report = BuildReport(documentBytes);
try
{
var historyResult = await HistoryService.RecordAsync((int)_readOnly.EnvelopeId, _readOnly.AddedWho, EnvelopeStatus.EnvelopeViewed);
if (historyResult.IsFailed)
Logger.LogWarning("Failed to record EnvelopeViewed history for read-only key {ReadOnlyKey}", ReadOnlyKey);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to record read-only history for {ReadOnlyKey}", ReadOnlyKey);
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Unexpected error in read-only page for {ReadOnlyKey}", ReadOnlyKey);
_errorMessage = "Beim Laden ist ein unerwarteter Fehler aufgetreten.";
}
_isLoading = false;
}
static XtraReport BuildReport(byte[] pdfBytes)
{
var report = new XtraReport
{
PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4,
Landscape = false,
Margins = new System.Drawing.Printing.Margins(0, 0, 0, 0)
};
var detail = new DetailBand { HeightF = 0f };
report.Bands.Add(detail);
detail.Controls.Add(new XRPdfContent
{
Source = pdfBytes,
GenerateOwnPages = true
});
return report;
}
public void Dispose()
{
_report?.Dispose();
}
}

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

@@ -14,6 +14,7 @@
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
@inject SignatureService SignatureService
@inject AppVersionService AppVersion
@inject IMemoryCache MemoryCache
@inject ILogger<ReceiverSignedPage> Logger
@@ -125,6 +126,12 @@
</p>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(_submitErrorMessage))
{
<div class="alert alert-danger mb-0" role="alert" style="font-size: 0.85rem;">
@_submitErrorMessage
</div>
}
</BodyContentTemplate>
<FooterContentTemplate>
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
@@ -164,17 +171,48 @@
// ----- Submit / logout state -----
bool _isLoggingOut = false;
bool _submitConfirmVisible = false;
string? _submitErrorMessage;
void OpenSubmitConfirmPopup() => _submitConfirmVisible = true;
async Task SubmitAndLogoutAsync()
{
if (_isLoggingOut) return;
if (string.IsNullOrWhiteSpace(EnvelopeKey) || _sig is null)
return;
_isLoggingOut = true;
_submitConfirmVisible = false;
_submitErrorMessage = null;
await InvokeAsync(StateHasChanged);
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey!);
Navigation.NavigateTo("/", forceLoad: true);
try
{
if (_signatures.Count == 0)
throw new InvalidOperationException("No signature fields found for receiver.");
var payload = _signatures.Select(field => new SignatureSubmitDto
{
Id = field.Id,
DataUrl = _sig.DataUrl,
FullName = _sig.FullName,
Position = string.IsNullOrWhiteSpace(_sig.Position) ? null : _sig.Position,
Place = _sig.Place
}).ToList();
await SignatureService.SubmitAsync(EnvelopeKey, payload);
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
Navigation.NavigateTo("/envelope/signed-result", forceLoad: true);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to finalize signature for {EnvelopeKey}", EnvelopeKey);
_submitErrorMessage = "Die Unterschrift konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.";
_isLoggingOut = false;
await InvokeAsync(StateHasChanged);
}
}
protected override async Task OnInitializedAsync()

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

@@ -1,7 +1,10 @@
using DigitalData.Auth.Claims;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Server.Controllers.Interfaces;
using EnvelopeGenerator.Server.Models;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
@@ -15,7 +18,9 @@ namespace EnvelopeGenerator.Server.Controllers;
/// </summary>
[Route("api/[controller]")]
[ApiController]
public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions, IAuthorizationService authService) : ControllerBase, IAuthController
public partial class AuthController(
IOptions<AuthTokenKeys> authTokenKeyOptions,
IAuthorizationService authService) : ControllerBase, IAuthController
{
private readonly AuthTokenKeys authTokenKeys = authTokenKeyOptions.Value;
@@ -82,6 +87,64 @@ public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions,
[HttpGet("check/envelope/{envelopeKey}")]
public IActionResult CheckEnvelopeReceiver([FromRoute] string envelopeKey) => Ok();
/// <summary>
/// Sends the receiver access code e-mail on the first envelope login-page visit.
/// The operation is idempotent: if the code was already requested, it returns 200 without re-sending.
/// </summary>
/// <param name="key">Envelope receiver key.</param>
/// <response code="200">Access code is already available or requested successfully.</response>
/// <response code="404">Envelope receiver key was not found.</response>
/// <response code="500">Access code could not be persisted or e-mail could not be sent.</response>
[AllowAnonymous]
[HttpPost("envelope-receiver/{key}/request-access-code")]
public async Task<IActionResult> RequestEnvelopeReceiverAccessCode(
[FromRoute] string key,
[FromServices] IMediator mediator,
[FromServices] IEnvelopeHistoryService historyService,
[FromServices] IEnvelopeMailService mailService,
[FromServices] ILogger<AuthController> logger,
CancellationToken cancel)
{
var envelopeReceiver = (await mediator.Send(new ReadEnvelopeReceiverQuery { Key = key }, cancel)).SingleOrDefault();
if (envelopeReceiver is null || envelopeReceiver.Envelope is null || envelopeReceiver.Receiver is null)
return NotFound();
if (envelopeReceiver.Envelope.UseAccessCode != true)
return Ok();
var userReference = envelopeReceiver.Receiver.EmailAddress;
if (await historyService.IsSigned(envelopeReceiver.EnvelopeId, userReference))
return Ok();
var accessCodeAlreadyRequested = await historyService.AccessCodeAlreadyRequested(envelopeReceiver.EnvelopeId, userReference);
if (accessCodeAlreadyRequested)
return Ok();
var historyResult = await historyService.RecordAsync(envelopeReceiver.EnvelopeId, userReference, EnvelopeStatus.AccessCodeRequested);
if (historyResult.IsFailed)
{
logger.LogWarning(
"Failed to record AccessCodeRequested history for envelope {EnvelopeId} and receiver {ReceiverEmail}.",
envelopeReceiver.EnvelopeId,
userReference);
return StatusCode(StatusCodes.Status500InternalServerError);
}
var mailResult = await mailService.SendAccessCodeAsync(envelopeReceiver);
if (mailResult.IsFailed)
{
logger.LogWarning(
"Failed to send access code mail for envelope {EnvelopeId} and receiver {ReceiverEmail}.",
envelopeReceiver.EnvelopeId,
userReference);
return StatusCode(StatusCodes.Status500InternalServerError);
}
return Ok();
}
/// <summary>
/// Removes the per-envelope receiver cookie for the given envelope key.
/// </summary>

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

@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
/// Redirects legacy envelope key links to Blazor routes.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
[Route("EnvelopeKey")]
public class EnvelopeKeyRedirectController : Controller
{
/// <summary>
/// Redirects /EnvelopeKey/{*path} links to /envelope/{path}.
/// </summary>
[HttpGet("{*path}")]
public IActionResult RedirectToEnvelopePath([FromRoute] string path)
=> Redirect($"/envelope/{path}");
}

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

@@ -1,8 +1,10 @@
using DigitalData.Core.Abstraction.Application.DTO;
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Server.Extensions;
using MediatR;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
@@ -33,24 +35,36 @@ public class ReadOnlyController : ControllerBase
}
/// <summary>
/// Creates a new read-only receiver for the current envelope.
/// Creates a new read-only receiver for an envelope.
/// </summary>
/// <param name="envelopeKey">Envelope key (uuid+signature encoded).</param>
/// <param name="createDto">Creation payload.</param>
[HttpPost]
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("{envelopeKey}")]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
[Obsolete("Use MediatR")]
public async Task<IActionResult> CreateAsync([FromBody] EnvelopeReceiverReadOnlyCreateDto createDto)
public async Task<IActionResult> CreateAsync(
[FromRoute] string envelopeKey,
[FromBody] EnvelopeReceiverReadOnlyCreateDto createDto,
[FromServices] IMediator mediator,
CancellationToken cancel)
{
var authReceiverMail = User.ReceiverMail();
if (authReceiverMail is null)
var envelopeReceiver = await mediator.Send(new ReadEnvelopeReceiverQuery { Key = envelopeKey }, cancel);
var authEnvelopeReceiver = envelopeReceiver.FirstOrDefault();
if (authEnvelopeReceiver?.Envelope is null)
return NotFound();
var addedWho = User.FindFirstValue(ClaimTypes.Email)
?? authEnvelopeReceiver.Receiver?.EmailAddress;
if (string.IsNullOrWhiteSpace(addedWho))
{
_logger.LogError("EmailAddress claim is not found in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
return Unauthorized();
_logger.LogError("Receiver e-mail is missing in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
return StatusCode(StatusCodes.Status500InternalServerError);
}
var envelopeId = User.EnvelopeId();
var envelopeId = authEnvelopeReceiver.EnvelopeId;
createDto.AddedWho = authReceiverMail;
createDto.AddedWho = addedWho;
createDto.EnvelopeId = envelopeId;
var creationRes = await _readOnlyService.CreateAsync(createDto: createDto);

View File

@@ -1,7 +1,10 @@
using EnvelopeGenerator.Server.Extensions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.DocReceiverElements.Commands;
using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Histories.Queries;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
using Microsoft.AspNetCore.Authorization;
@@ -18,13 +21,15 @@ namespace EnvelopeGenerator.Server.Controllers;
public class SignatureController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<SignatureController> _logger;
/// <summary>
/// Initializes a new instance of <see cref="SignatureController"/>.
/// </summary>
public SignatureController(IMediator mediator)
public SignatureController(IMediator mediator, ILogger<SignatureController> logger)
{
_mediator = mediator;
_logger = logger;
}
//TODO: update to use signature query
@@ -54,4 +59,46 @@ public class SignatureController : ControllerBase
else
return Ok(rcvSignatures);
}
/// <summary>
/// Finalizes the receiver signing flow and persists signature data to TBSIG_DOCUMENT_RECEIVER_ELEMENT.
/// Also triggers status/history/mail side effects through SigningCommand pipeline.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("submit/{envelopeKey}")]
public async Task<IActionResult> Submit(
[FromRoute] string envelopeKey,
[FromBody] IEnumerable<Signature>? signatures,
CancellationToken cancel)
{
if (signatures is null)
return BadRequest("Signature payload is required.");
var uuid = User.EnvelopeUuid();
var receiverSignature = User.ReceiverSignature();
if (await _mediator.IsSignedAsync(uuid, receiverSignature, cancel))
return Problem(statusCode: StatusCodes.Status409Conflict);
if (await _mediator.AnyHistoryAsync(uuid, new[] { EnvelopeStatus.EnvelopeRejected, EnvelopeStatus.DocumentRejected }, cancel))
return Problem(statusCode: StatusCodes.Status423Locked);
try
{
await _mediator.Send(new SigningCommand
{
Envelope = new() { Uuid = uuid },
Receiver = new() { Signature = receiverSignature },
ReceiverAppType = ReceiverAppType.ReceiverUI,
Signatures = signatures.ToList()
}, cancel);
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to submit signatures for envelope {EnvelopeKey}", envelopeKey);
throw;
}
}
}

View File

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

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

@@ -25,6 +25,9 @@ using NLog.Web;
using NLog;
using DigitalData.Auth.Claims;
using EnvelopeGenerator.Server;
using DigitalData.EmailProfilerDispatcher;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Infrastructure.Doc;
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
logger.Info("EnvelopeGenerator.Server logging initialized!");
@@ -323,6 +326,8 @@ try
.AddEnvelopeGeneratorServices(config);
#pragma warning restore CS0618
builder.Services.AddDispatcher<EGDbContext>();
// User Preferences (theme, dark mode, grid layouts — stored in TBDD_CACHE)
// Registration handled by Infrastructure DependencyInjection (AddEnvelopeGeneratorInfrastructureServices)
@@ -338,6 +343,9 @@ 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>();
// Business Services (Server specific)
builder.Services.AddScoped<DocumentService>();
@@ -346,6 +354,8 @@ try
builder.Services.AddScoped<EnvelopeReceiverService>();
builder.Services.AddScoped<SignatureService>();
builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddDocInfrastructure();
builder.Services.AddScoped<EnvelopeGenerator.Application.Common.Interfaces.Services.IEnvelopeMailService, EnvelopeGenerator.Application.Services.EnvelopeMailService>();
builder.Services.AddSingleton<AppVersionService>();
// EnvelopeService with HttpClient factory (for SSR scenarios)

View File

@@ -57,6 +57,14 @@ public class EnvelopeReceiverPageDataService(
return await mediator.Send(new ReadDocumentQuery(EnvelopeId: envelopeId, UserId: user.SenderId()), cancellationToken);
}
/// <summary>
/// Loads document metadata/elements for the authenticated sender without binary payload.
/// </summary>
public async Task<DocumentDetailsDto?> GetDocumentMetadataOfSenderAsync(ClaimsPrincipal user, int envelopeId, CancellationToken cancellationToken = default)
{
return await mediator.Send(new ReadDocumentDetailsQuery(EnvelopeId: envelopeId, UserId: user.SenderId()), cancellationToken);
}
/// <summary>
/// Loads the current receiver's signature placeholders.
/// </summary>

View File

@@ -185,9 +185,10 @@
"TimeLimit": "00:30:00"
},
"DbTriggerParams": {
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ],
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"EnvelopeHistory": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
"Receiver": [],

View File

@@ -345,6 +345,83 @@ article {
font-weight: 500;
}
/* Signed result page */
.receiver-signed-result-page {
min-height: calc(100vh - 36px);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
background: linear-gradient(160deg, #1e2e3e 0%, #2c3e50 40%, #3498db 100%);
}
.receiver-signed-result-card {
width: min(680px, 100%);
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 18px;
box-shadow: 0 18px 44px rgba(15, 23, 42, 0.24);
padding: 2rem 1.5rem;
text-align: center;
}
.receiver-signed-result-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 92px;
height: 92px;
border-radius: 50%;
color: #0f8f58;
background: linear-gradient(135deg, #dcfce7 0%, #bbf7d0 100%);
border: 1px solid #86efac;
margin-bottom: 1rem;
}
.receiver-signed-result-title {
margin: 0;
color: #1f2937;
font-size: 1.7rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.receiver-signed-result-message {
margin: 0.85rem auto 0;
max-width: 58ch;
color: #4b5563;
font-size: 1rem;
line-height: 1.62;
}
[data-sf-theme="purple"] .receiver-signed-result-page {
background: linear-gradient(160deg, #1a0533 0%, #3b0764 45%, #6b21a8 100%);
}
[data-sf-theme="blazing-dark"] .receiver-signed-result-page {
background: linear-gradient(160deg, #0a0f1e 0%, #111827 50%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .receiver-signed-result-card {
background: rgba(15, 20, 40, 0.94);
border-color: rgba(129, 140, 248, 0.28);
box-shadow: 0 20px 46px rgba(0, 0, 0, 0.45);
}
[data-sf-theme="blazing-dark"] .receiver-signed-result-title {
color: rgba(255, 255, 255, 0.92);
}
[data-sf-theme="blazing-dark"] .receiver-signed-result-message {
color: rgba(255, 255, 255, 0.68);
}
[data-sf-theme="blazing-dark"] .receiver-signed-result-icon {
color: #86efac;
background: rgba(16, 185, 129, 0.16);
border-color: rgba(110, 231, 183, 0.35);
}
/* ── Logos ───────────────────────────────────────────────────────────────── */
/* Heights are controlled by inline style="height: Xpx" from appsettings.json */

View File

@@ -56,6 +56,351 @@
height: 100%;
}
.sender-image-editor {
overflow: auto;
padding: 1rem;
}
.sender-image-editor-shell {
position: relative;
width: 100%;
height: 100%;
}
.sender-image-editor__toolbar-host {
position: sticky;
top: 0;
z-index: 40;
height: 0;
pointer-events: none;
}
.sender-image-toolbar {
pointer-events: auto;
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 2rem);
max-width: 1040px;
margin: 0;
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr) auto;
gap: 0.6rem;
align-items: center;
background: transparent;
}
.sender-image-toolbar__group {
min-height: 3.35rem;
background: #ffffff;
border: 1px solid #d6deef;
border-radius: 0.55rem;
padding: 0.38rem 0.5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.28rem;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.09);
}
.sender-image-toolbar__group--nav {
justify-content: flex-start;
}
.sender-image-toolbar__group--zoom {
justify-content: center;
gap: 0.4rem;
}
.sender-image-toolbar__group--actions {
justify-content: center;
gap: 0.4rem;
padding-inline: 0.6rem;
}
.sender-image-toolbar__icon-btn {
width: 2.25rem;
height: 2.25rem;
border: 0;
border-radius: 0.45rem;
background: transparent;
color: #1f2a44;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
}
.sender-image-toolbar__icon-btn:hover:not(:disabled) {
background: #eef3ff;
color: #0f3f99;
}
.sender-image-toolbar__icon-btn:active:not(:disabled) {
transform: translateY(1px);
}
.sender-image-toolbar__icon-btn:focus-visible {
outline: 2px solid #3468d6;
outline-offset: 1px;
}
.sender-image-toolbar__icon-btn:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.sender-image-toolbar__icon-btn--caret {
width: 1.95rem;
}
.sender-image-toolbar__icon-btn svg {
width: 19px;
height: 19px;
}
.sender-image-toolbar__icon-btn--busy svg {
animation: sender-toolbar-spin 0.95s linear infinite;
}
.sender-image-toolbar__select-wrap {
position: relative;
width: 1.95rem;
height: 2.25rem;
}
.sender-image-toolbar__page-select {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
border: 0;
}
.sender-image-toolbar__page-select:disabled {
cursor: not-allowed;
}
.sender-image-toolbar__value-box {
min-width: 7.8rem;
height: 2.25rem;
border: 1px solid #d5dceb;
border-radius: 0.45rem;
background: #f8fafc;
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
padding: 0 0.65rem;
color: #233252;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.01em;
}
.sender-image-toolbar__value-box:focus-within {
border-color: #7ea2ee;
box-shadow: 0 0 0 2px rgba(126, 162, 238, 0.2);
}
.sender-image-toolbar__page-input {
width: 2.3rem;
border: 0;
outline: none;
background: transparent;
color: inherit;
font-size: inherit;
font-weight: inherit;
text-align: right;
-moz-appearance: textfield;
}
.sender-image-toolbar__page-input::-webkit-outer-spin-button,
.sender-image-toolbar__page-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.sender-image-toolbar__page-total {
color: #5b6c8f;
font-size: 0.68rem;
font-weight: 600;
}
.sender-image-toolbar__zoom-slider {
-webkit-appearance: none;
appearance: none;
width: clamp(130px, 20vw, 190px);
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, #d7e2ff 0%, #84a9ff 100%);
outline: none;
cursor: pointer;
}
.sender-image-toolbar__zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 15px;
height: 15px;
border-radius: 50%;
background: #2f5fc9;
box-shadow: 0 1px 4px rgba(47, 95, 201, 0.35);
}
.sender-image-toolbar__zoom-slider::-moz-range-thumb {
width: 15px;
height: 15px;
border: 0;
border-radius: 50%;
background: #2f5fc9;
box-shadow: 0 1px 4px rgba(47, 95, 201, 0.35);
}
.sender-image-toolbar__zoom-slider:focus-visible {
outline: 2px solid #3468d6;
outline-offset: 2px;
}
@keyframes sender-toolbar-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.sender-image-editor__pages {
max-width: 1040px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
padding-top: 5.15rem;
}
.sender-image-editor__native-preview {
max-width: 1040px;
margin: 5.15rem auto 1rem;
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
}
.sender-image-editor__native-frame {
width: 100%;
min-height: 70vh;
border: 0;
background: #f8fafc;
}
.sender-image-editor__page-block {
background: #e2e8f0;
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 0.75rem;
}
.sender-image-editor__page-canvas {
position: relative;
width: 100%;
aspect-ratio: 595 / 842;
background: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
}
.sender-image-editor__page-image {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
}
.sender-image-editor__page-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
font-weight: 600;
font-size: 0.95rem;
letter-spacing: 0.01em;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
}
.sender-image-editor__field {
position: absolute;
border: 2px solid;
border-radius: 8px;
font-size: 0.72rem;
font-weight: 700;
color: #0f172a;
padding: 0.2rem 0.3rem;
text-align: left;
line-height: 1.1;
cursor: move;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.35rem;
overflow: hidden;
}
.sender-image-editor__field:active {
cursor: move;
}
.sender-image-editor__field-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sender-image-editor__field-remove {
border: none;
background: rgba(15, 23, 42, 0.78);
color: #ffffff;
width: 1.1rem;
height: 1.1rem;
min-width: 1.1rem;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
line-height: 1;
margin-left: auto;
flex-shrink: 0;
}
.sender-image-editor__field-remove:hover {
background: rgba(220, 38, 38, 0.9);
}
.sender-image-editor__field:hover {
filter: brightness(0.96);
}
.sender-image-editor__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
color: #64748b;
font-weight: 600;
}
.sender-editor-pdf-viewer {
width: 100%;
height: 100%;
@@ -609,6 +954,29 @@ body.resizing {
cursor: not-allowed;
}
.pdf-toolbar__btn--reject {
display: flex;
align-items: center;
gap: 0.375rem;
min-width: auto;
padding: 0.5rem 0.75rem;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.08) 100%);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #b91c1c;
}
.pdf-toolbar__btn--reject:hover:not(:disabled) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-color: transparent;
color: #ffffff;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
.pdf-toolbar__btn--reject:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pdf-toolbar__btn-text {
font-size: 0.813rem;
font-weight: 600;
@@ -1042,6 +1410,41 @@ body.resizing {
padding: 0.75rem;
}
.sender-image-toolbar {
grid-template-columns: 1fr;
width: calc(100% - 1rem);
top: 0.5rem;
gap: 0.45rem;
}
.sender-image-toolbar__group {
min-height: 3.15rem;
justify-content: center;
flex-wrap: wrap;
}
.sender-image-editor__pages {
padding-top: 11.25rem;
}
.sender-image-editor__native-preview {
margin-top: 11.25rem;
}
.sender-image-toolbar__value-box {
min-width: 6.7rem;
height: 2rem;
font-size: 0.69rem;
}
.sender-image-toolbar__zoom-slider {
width: min(44vw, 220px);
}
.sender-image-toolbar__group--actions {
justify-content: flex-end;
}
.pdf-thumbnails {
width: 180px;
border-radius: 0 0 0 16px;
@@ -1211,6 +1614,18 @@ body.resizing {
color: #a5b4fc;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(248, 113, 113, 0.4);
color: #fca5a5;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.35);
border-color: rgba(248, 113, 113, 0.6);
color: #ffffff;
}
/* Error container */
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
background: rgba(239, 68, 68, 0.12);

View File

@@ -1,5 +1,11 @@
window.envelopeEditor = {
_moveThrottle: {
rafId: 0,
latest: null,
waiters: []
},
/**
* Returns the click position normalised to [0,1] relative to the rendered PDF page
* element inside DxPdfViewer (or DxReportViewer as fallback).
@@ -25,9 +31,10 @@ window.envelopeEditor = {
}
// --- Candidate page elements (ordered by preference) ---
// DxPdfViewer renders individual pages as .dxbl-pdfv-page elements.
// DxReportViewer uses .dxbrv-report-preview-content-img as fallback.
// Sender editor custom renderer uses data-editor-page blocks.
// Legacy Dx viewers remain as fallback selectors.
const pageSelectors = [
'[data-editor-page]',
'.dxbl-pdfv-page',
'.dxbrv-report-preview-page',
'.dxbrv-report-preview-content-img',
@@ -89,7 +96,229 @@ window.envelopeEditor = {
return {
normX: normX,
normY: normY,
pageIndex: targetIndex
pageIndex: targetIndex,
pageNumber: parseInt(targetPage.getAttribute('data-editor-page') || String(targetIndex + 1), 10)
};
},
getClickCoordsOnPdfPageThrottled: function (viewerCssClass, clientX, clientY) {
return new Promise((resolve) => {
const state = window.envelopeEditor._moveThrottle;
state.latest = { viewerCssClass, clientX, clientY };
state.waiters.push(resolve);
if (state.rafId !== 0) {
return;
}
state.rafId = window.requestAnimationFrame(() => {
state.rafId = 0;
const args = state.latest;
state.latest = null;
const result = args
? window.envelopeEditor.getClickCoordsOnPdfPage(args.viewerCssClass, args.clientX, args.clientY)
: null;
const waiters = state.waiters.splice(0, state.waiters.length);
for (const waiter of waiters) {
waiter(result);
}
});
});
},
scrollToPdfPage: function (viewerCssClass, pageNumber) {
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
return;
}
const targetPageNumber = Math.max(1, Number(pageNumber) || 1);
const page = viewer.querySelector('[data-editor-page="' + targetPageNumber + '"]');
if (!page) {
return;
}
const viewerRect = viewer.getBoundingClientRect();
const pageRect = page.getBoundingClientRect();
const targetScrollTop = viewer.scrollTop + (pageRect.top - viewerRect.top) - 8;
viewer.scrollTo({
top: Math.max(0, targetScrollTop),
behavior: 'smooth'
});
},
getVisiblePdfPageNumber: function (viewerCssClass) {
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
return null;
}
const pages = Array.from(viewer.querySelectorAll('[data-editor-page]'));
if (pages.length === 0) {
return null;
}
const viewerRect = viewer.getBoundingClientRect();
let bestPage = null;
let bestVisibleHeight = -1;
for (const page of pages) {
const rect = page.getBoundingClientRect();
const visibleTop = Math.max(rect.top, viewerRect.top);
const visibleBottom = Math.min(rect.bottom, viewerRect.bottom);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
if (visibleHeight > bestVisibleHeight) {
bestVisibleHeight = visibleHeight;
bestPage = page;
}
}
if (!bestPage) {
return null;
}
const raw = bestPage.getAttribute('data-editor-page');
const parsed = Number.parseInt(raw || '0', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
},
scrollPdfViewerByDelta: function (viewerCssClass, deltaY) {
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
return;
}
viewer.scrollTop += Number(deltaY) || 0;
},
ensureZoomOverride: function (viewerCssClass) {
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
return;
}
if (viewer.dataset.zoomOverrideAttached === '1') {
return;
}
viewer.dataset.zoomOverrideAttached = '1';
viewer.addEventListener('wheel', function (event) {
event.preventDefault();
}, { passive: false });
viewer.addEventListener('keydown', function (event) {
if (!event.ctrlKey) {
return;
}
const key = event.key;
if (key === '+' || key === '=' || key === '-' || key === '0') {
event.preventDefault();
}
});
viewer.addEventListener('touchmove', function () {
}, { passive: true });
},
downloadDataUrlAsFile: function (dataUrl, fileName, mimeType) {
if (!dataUrl || typeof dataUrl !== 'string') {
return;
}
const downloadName = fileName || 'document.pdf';
const link = document.createElement('a');
link.href = dataUrl;
link.download = downloadName;
link.rel = 'noopener';
const host = document.body || document.documentElement;
host.appendChild(link);
link.click();
host.removeChild(link);
},
printDataUrlPdf: async function (dataUrl, mimeType) {
if (!dataUrl || typeof dataUrl !== 'string') {
return;
}
let objectUrl = null;
let printSrc = dataUrl;
if (dataUrl.startsWith('data:')) {
try {
const response = await fetch(dataUrl);
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
printSrc = objectUrl;
} catch {
printSrc = dataUrl;
}
}
const frame = document.createElement('iframe');
frame.style.position = 'fixed';
frame.style.right = '0';
frame.style.bottom = '0';
frame.style.width = '0';
frame.style.height = '0';
frame.style.border = '0';
frame.setAttribute('aria-hidden', 'true');
const host = document.body || document.documentElement;
host.appendChild(frame);
let cleaned = false;
const cleanup = function () {
if (cleaned) {
return;
}
cleaned = true;
if (frame.parentNode) {
frame.parentNode.removeChild(frame);
}
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
};
frame.onload = function () {
try {
const frameWindow = frame.contentWindow;
if (!frameWindow) {
cleanup();
return;
}
frameWindow.addEventListener('afterprint', function () {
window.setTimeout(cleanup, 250);
}, { once: true });
window.addEventListener('focus', function () {
window.setTimeout(cleanup, 1500);
}, { once: true });
frameWindow.focus();
frameWindow.print();
} catch {
cleanup();
}
};
frame.src = printSrc;
window.setTimeout(cleanup, 120000);
}
};

View File

@@ -41,6 +41,7 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
ProjectSection(SolutionItems) = preProject
EnvelopeGenerator.Server\publish.bat = EnvelopeGenerator.Server\publish.bat
SENDER_SAVE_EDIT_MIGRATION_PLAN.md = SENDER_SAVE_EDIT_MIGRATION_PLAN.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj", "{4E6C54DA-576D-0955-2564-9EC890BB8279}"
@@ -51,6 +52,8 @@ Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.Service_l
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Service", "EnvelopeGenerator.Service\EnvelopeGenerator.Service.csproj", "{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Infrastructure.Doc", "EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj", "{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -113,6 +116,10 @@ Global
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.Build.0 = Release|Any CPU
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -136,6 +143,7 @@ Global
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
{83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}

View File

@@ -0,0 +1,217 @@
# Sender Editor PDF Component Migration Plan
This plan is prepared to remove the DevExpress PDF viewer dependency from `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderEditorPage.razor` and move to a new WASM-based PDF editor component while preserving the current page design.
## 0) Scope and Non-Negotiable Rules
- **Scope**: Only the PDF editor/viewer area will change; the page action bar, popups, receiver management, and overall visual language must remain intact.
- **Render mode target**: The `/sender/editor` flow will be implemented in `EnvelopeGenerator.Server.Client` and run in WebAssembly.
- **Data compatibility**: The final `CreateEnvelopeCommand` payload shape, field units (`X`, `Y` in inches), receiver-field mapping, and save/send behavior must stay unchanged.
- **To be removed**: `esid` query param + `IMemoryCache`-based editor session persistence will be removed completely.
- **Library constraint**: `pdf.js` and DevExpress PDF viewer will not be used. For PDF rasterization, first verify whether DevExpress provides a suitable capability; if not, use a reliable, popular MIT-licensed alternative.
- **Server-side pattern rule**: All new server-side work must follow the existing architecture; **Clean Architecture layers** and **MediatR pattern** must be preserved.
- **Language rule (code side)**: All code and any code comments must always be in English.
- **Localization rule**: UI text must not be hardcoded; use `IStringLocalizer<Resource>`. For every new key, add German, English, and French translations in `EnvelopeGenerator.Application/Resources/Resource.de-DE.resx`, `EnvelopeGenerator.Application/Resources/Resource.en-US.resx`, and `EnvelopeGenerator.Application/Resources/Resource.fr-FR.resx`.
---
## 1) Current Behavior Inventory (Parity Baseline)
**Goal:** Lock down all behaviors that must not break after migration.
**Tasks**
- List the current editor flow:
- PDF upload,
- receiver add,
- signature field placement,
- field clearing,
- draft save,
- send.
- Clarify current coordinate conversion:
- Viewer click -> PDF points,
- Save payload -> inches (`pt / 72`).
- Capture current validation messages and popup behavior.
- During this inventory phase, continuously add findings into this markdown under the relevant sections so it remains a living document.
**Output**
- A short “parity checklist” is prepared and used throughout development.
---
## 2) Architecture Decisions and Packaging
**Goal:** Define technical boundaries of the new solution.
**Tasks**
- Define the new reusable component:
- Suggested name: `SenderPdfImageEditor`.
- Location: `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/`.
- Define PDF rasterization strategy:
- Convert PDF -> image pages on the server side.
- First confirm whether this conversion is possible with DevExpress.
- If DevExpress does not provide a suitable solution, choose a popular MIT-licensed engine (e.g., `Docnet.Core` / PDFium-based).
- Document license validation and production suitability.
- Draft API contract:
- endpoint(s) returning page images after PDF upload/read,
- page metadata (width/height in pt),
- optional re-raster endpoint for higher zoom levels.
**Output**
- “Component contract + API contract” is finalized.
---
## 3) Copy `/sender/editor` to WASM Page
**Goal:** Run the editor in `Server.Client` using SPA behavior.
**Tasks**
- Create a new page:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/EnvelopeSenderEditorPage.razor`
- Temporary route: `/sender/editor2`
- Keep the existing server-side `/sender/editor` page during development; both pages run in parallel.
- Copy existing toolbar/receiver/popup markup as-is as much as possible.
- Integrate client services (`EnvelopeService`, auth checks, receiver suggestion calls).
**Output**
- WASM page is accessible, old UI layout is preserved, and PDF area works with a placeholder component.
---
## 4) Page-by-Page PDF to Image Conversion Service (With Priority Order)
**Goal:** Convert PDF into page-image list with near-identical visual fidelity.
**Tasks**
- Add new application/service layer:
- PDF bytes input,
- raster output per page (PNG preferred for stable quality/transparency).
- Evaluate client-side (WASM) rasterization feasibility if it is possible and does not hurt performance.
- Priority order:
1. DevExpress feasibility,
2. If not suitable, MIT-licensed alternative,
3. If needed, client-side rasterization option.
- Return metadata:
- page index,
- image URL or base64,
- page width/height (pt),
- native image size in px.
- Performance:
- reasonable initial DPI (e.g., 144),
- lazy/high-res render option for higher zoom levels.
**Output**
- “PDF -> image pages + page metrics” endpoint/service is functional.
---
## 5) New Image-based PDF Viewer Features
**Goal:** Provide viewer UX similar to DevExpress viewer.
**Tasks**
- Implement two display modes in `SenderPdfImageEditor`:
- **Continuous mode**: pages stacked vertically,
- **Single-page mode**: one page with next/prev navigation.
- Add zoom controls:
- zoom in/out,
- fit width,
- fit page.
- Add page navigation:
- page selector (n / total),
- keyboard shortcuts (optional, phase 2).
- Preserve UI consistency:
- keep current sender editor color language and button style.
**Output**
- Image-based viewer is fully functional.
---
## 6) Signature Field Management via DOM Overlay
**Goal:** Manage signature fields in a movable DOM layer instead of burning them into PDF bytes.
**Tasks**
- Place an overlay layer on top of each page image.
- Signature box lifecycle:
- add (by click),
- drag (move),
- remove.
- Normalize all interactions as page-relative coordinates.
- Rendering:
- receiver color,
- receiver name,
- box/badge style close to current placeholder visuals.
- Preserve multi-receiver / multi-field support and receiver-field linkage.
**Output**
- Users can move and delete each signature field independently.
---
## 7) Coordinate and Payload Parity (Critical)
**Goal:** Keep backend data type/unit behavior exactly unchanged.
**Tasks**
- Convert overlay coordinates -> PDF points:
- `xPt = normalizedX * pageWidthPt`
- `yPt = normalizedY * pageHeightPt`
- Keep points -> inches conversion on save/send:
- `X = xPt / 72`, `Y = yPt / 72`.
- Use the same DTO shape in `CreateEnvelopeCommand`.
- Keep existing validation rules unchanged.
**Output**
- Full backend contract parity with the old system.
---
## 8) Remove Session Cache Logic
**Goal:** Fully remove `esid` and memory cache dependency.
**Tasks**
- Remove `SupplyParameterFromQuery(Name = "esid")`.
- Remove `IMemoryCache` injection and `PersistSession` flow.
- Manage editor state using component state + loaded envelope data only.
- Clarify reset behavior after successful save/send.
**Output**
- Session-id-based cache layer is removed completely.
---
## 9) Testing and Acceptance Criteria
**Goal:** Verify migration is complete and safe.
**Critical manual tests**
- PDF upload -> are pages visually accurate?
- Continuous/single-page mode transitions work correctly?
- Overlay boxes keep correct position across zoom levels?
- Add/drag/remove works stably on all pages?
- Draft save and send payload format remains identical?
- In edit mode (existing envelope), fields load and remain editable?
**Definition of done**
- DevExpress PDF viewer is fully removed from the editor.
- Users can edit signature box positions after placing them.
- Backend contract and coordinate units remain unchanged.
---
## 10) Suggested Implementation Sequence (Short Sprint Plan)
1. **WASM page copy + route stabilization**
2. **PDF raster API + renderer integration**
3. **Image viewer (zoom + navigation + modes)**
4. **Overlay signature layer (add/drag/remove)**
5. **Save/Send payload parity verification**
6. **Session/cache removal cleanup**
7. **End-to-end testing and UI polish**
### Implementation Progress Notes
- Localization requirement clarified: all new Sender Editor UI strings must go through `IStringLocalizer<Resource>` with DE/EN/FR resource entries.

View File

@@ -0,0 +1,86 @@
# Sender PDF Toolbar Migration Plan
## Goal
Design and integrate a reusable toolbar component for `SenderPdfImageEditor` that matches the provided visual style and supports:
- page navigation
- zoom in/out (including overriding default mouse-based zoom behavior inside editor)
- PDF download as original bytes (not image)
- print output
Critical rule: existing signature add/remove/drag-drop behavior must remain fully functional.
## Scope and Files
Planned touch points:
1. `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditorToolbar.razor` (new)
2. `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditorToolbar.razor.cs` (optional, if code-behind needed)
3. `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Components/PdfEditor/SenderPdfImageEditor.razor` (compose toolbar + editor integration)
4. `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/SenderEditorPage.razor` (pass PDF bytes/metadata/events to editor)
5. `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css` (toolbar visual styles + responsive)
6. `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/envelope-editor.js` (helper functions: scroll-to-page, wheel handling, download, print if needed)
## Non-Regression Strategy
- Keep all existing signature event paths unchanged (`EditorClick`, `MoveField`, `RemoveField`).
- Introduce toolbar as a separate component with explicit parameters and callbacks.
- Add features incrementally; no behavior switch all at once.
- After each stage: run manual black-box checks focused on signatures and new feature.
- If any regression appears: rollback only the stage-local changes.
## Stage Plan
### Stage 1 - Visual Toolbar Skeleton (all buttons disabled)
- Build standalone `SenderPdfImageEditorToolbar` component.
- Recreate layout from reference image (three grouped blocks: page nav, zoom, print/download).
- Render in `SenderPdfImageEditor` above page canvas area.
- All controls disabled; only visual state and responsive behavior implemented.
- No functional binding yet.
Acceptance:
- Toolbar appears and matches structure/spacing/icons closely.
- Signature add/remove/drag still works exactly as before.
### Stage 2 - Page Navigation
- Add current page/total page model and callbacks.
- Implement first/prev/next/last navigation.
- Implement page number selector UI behavior.
- Navigate by scrolling/focusing target page container (`data-editor-page`) without changing signature logic.
Acceptance:
- Navigation controls move to correct page boundaries.
- Signature interactions continue to work on all pages.
### Stage 3 - Zoom In/Out + Mouse Override
- Introduce zoom state (e.g., 50%-300%, step-based).
- Wire `-` / `+` and percent display.
- Apply zoom in a way that preserves coordinate normalization for signature placement/drag.
- Override wheel behavior inside editor container (`@onwheel:preventDefault` + JS helper where needed).
Acceptance:
- Zoom buttons work consistently.
- Mouse wheel behavior follows custom zoom logic.
- Signature placement and drag coordinates remain correct after zoom changes.
### Stage 4 - Download Original PDF Bytes
- Expose download action from toolbar.
- Download source must be original PDF bytes (same content as uploaded/loaded document).
- Suggested filename from current editor state.
Acceptance:
- Downloaded file opens as valid PDF.
- Not an image export; true PDF bytes.
### Stage 5 - Print Output
- Add print action from toolbar.
- Print original PDF content using browser print flow (hidden iframe/object URL approach).
- Ensure action is isolated and does not mutate editor state.
Acceptance:
- Print dialog opens with correct document.
- Existing editor/signature interactions remain intact before and after print.
## Approval Gates
- Stop after each stage.
- Share what changed + exact black-box checklist.
- Wait for explicit approval before moving to next stage.
## Notes
- [ ] Toolbar visual polish is pending (icon sizing/balance/spacing fine-tuning).