Compare commits

...

73 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Included custom styles and assets, and implemented disposal logic for cleaning up resources.
2026-06-30 23:27:20 +02:00
a5e4f97397 Migrate PDF.js to DxPdfViewer in receiver signing page
This commit introduces a detailed migration plan to replace the
`PDF.js` rendering engine with `DxPdfViewer` in the receiver
signing experience (`EnvelopeReceiverPage.razor`). The migration
preserves the existing signing workflow and behavior while
introducing a new rendering layer.

Key changes:
- Replaced `PDF.js` rendering surface with `DxPdfViewer`.
- Preserved page-level orchestration for authorization, document
  loading, signature handling, and toolbar interactions.
- Introduced a custom overlay adapter for signature placeholders
  and applied signature overlays.
- Centralized page/zoom geometry acquisition for overlay alignment.
- Maintained signature navigation logic and thumbnail sidebar
  behavior.
- Updated `pdf-viewer.js` to separate engine-specific logic and
  adapt it for `DxPdfViewer`.
- Updated styles in `envelope-viewer.css` to support the new viewer.

This migration ensures that all existing workflow behaviors remain
functional, including navigation, zoom, signature placement, and
validation, while transitioning to the new rendering engine.
2026-06-29 11:04:53 +02:00
6ca03a50eb Add documentation for receiver PDF viewer context
Added `RECEIVER_PDF_VIEWER_CONTEXT.md` to the `src` project, documenting the current implementation and behavior of the receiver-side PDF viewing and signing experience in the `EnvelopeGenerator` project.

The document outlines the use of `PDF.js` as the current rendering engine, the planned migration to `DxPdfViewer`, and the functional capabilities that must be preserved. Key features include single-page PDF viewing, navigation, zoom, signature overlays, and metadata validation.

This addition ensures clarity for future development and emphasizes the importance of maintaining existing workflows during the migration or other changes.
2026-06-29 10:56:10 +02:00
96a84ba1a5 Update documentation to reflect current architecture
Revised COPILOT_CONTEXT.md to align with the active
EnvelopeGenerator architecture and workflows. Key updates:
- Updated title and purpose for clarity.
- Replaced migration notice with active app structure details.
- Documented hosting model, including `Program.cs` setup.
- Removed outdated deployment architecture section.
- Reorganized route structure for WebAssembly and server pages.
- Expanded authentication model for sender/receiver flows.
- Added details on server-side data loading and caching.
- Updated receiver PDF viewer and signature workflow sections.
- Clarified coordinate system conversions and usage.
- Marked deprecated projects and legacy files as "Do Not Touch."
- Replaced mistakes history with workspace rules.
- Updated last modified date to 2026-06-29.
2026-06-29 10:23:24 +02:00
ec0ea72890 Migrate authentication to SSR service for EnvelopeReceiver
Migrated the `EnvelopeReceiverPage.razor` component from using a WASM client-side authentication service to a server-side rendering (SSR) authentication service. This resolves issues caused by self-referencing HTTP requests in SSR contexts.

- Added `IEnvelopeAuthService` interface and `EnvelopeAuthService` implementation to validate user authentication and envelope key claims directly via `HttpContext.User`.
- Registered `EnvelopeAuthService` in DI container with a scoped lifetime.
- Updated `EnvelopeReceiverPage.razor` to use `IEnvelopeAuthService` for authentication checks and `IHttpClientFactory` for logout functionality (changes reverted due to merge conflict).
- Improved authentication flow by eliminating HTTP overhead and ensuring compatibility with SSR.
- Remaining tasks include re-applying page changes, testing, and updating documentation.

This migration ensures a cleaner, more reliable authentication mechanism for SSR pages.
2026-06-29 10:21:47 +02:00
7b912387e7 Refactor EnvelopeReceiverPage to server-side logic
Updated the document signing system to use a unified Blazor Auto (Server+WASM hybrid) frontend. Replaced client-side API calls with server-side authentication and data loading via `EnvelopeReceiverAuthorizationService` and `EnvelopeReceiverPageDataService`.

- Updated `/envelope/{key}` route to use MediatR for data loading.
- Integrated PDF.js 3.11.174 for rendering with configurable quality.
- Removed iText7 dependency due to GPL license issues.
- Introduced per-envelope cookies for receiver authentication.
- Cached signatures now loaded from distributed cache.
- Replaced redundant client-side API calls with server-side logic.
- Improved security and performance with server-side authorization.

These changes streamline the workflow, enhance security, and align the system with modern Blazor Server practices.
2026-06-29 09:57:50 +02:00
6c142eba08 Refactor signature processing in EnvelopeReceiverPageDataService
Refactored the logic to filter and map `elements` to `signatures`
before converting them to `UnitOfLength.Point`. Removed the direct
return of `elements` and ensured that only the processed `signatures`
are converted and returned. Added a `ToList()` call to materialize
the `signatures` collection before conversion.
2026-06-29 01:29:33 +02:00
489d2808a1 Refactor EnvelopeReceiverPage for modular data handling
Refactored `EnvelopeReceiverPage.razor` to use new services for receiver authentication and data retrieval. Introduced `EnvelopeReceiverAuthorizationService` for handling JWT-based authorization and `EnvelopeReceiverPageDataService` for centralized data access and caching. Updated dependency injection in `Program.cs` to register these services.

Replaced direct service calls with `PageDataService` methods for document, signature, and receiver data retrieval. Improved logging with `ILogger` and added debug logs for token validation. Enhanced modularity, maintainability, and performance by consolidating logic and reducing coupling between components.
2026-06-29 01:26:43 +02:00
7466fd78f6 Update auth, JSON config, and sender dashboard styles
Modified the `[Authorize]` attribute in `EnvelopeController` to use `AuthScheme.Sender` for authentication. Updated `Program.cs` to configure JSON serialization with `ReferenceHandler.IgnoreCycles` to handle circular references.

Added new CSS styles for the sender dashboard in `sender-page.css`, including layout, action bars, buttons, tabs, badges, and responsive design improvements. Enhanced button states and introduced styles for status indicators and receiver badges.
2026-06-28 22:24:33 +02:00
e34b5ddbbe Update render mode in EnvelopeSenderPage.razor
Replaced the default `InteractiveWebAssembly` render mode with a custom `InteractiveWebAssemblyRenderMode` instance, explicitly setting `prerender` to `false`. This change disables prerendering to adjust the page's rendering behavior, potentially optimizing performance or meeting specific rendering requirements.
2026-06-28 22:05:16 +02:00
b56f906848 Refine authorization and rendering mechanisms
Updated `EnvelopeSenderPage.razor` to replace the `[Authorize]`
attribute with the `@rendermode InteractiveWebAssembly` directive,
indicating a shift in how authorization or rendering is handled.

Modified the `Check` method in `AuthController.cs` to specify
`AuthenticationSchemes = AuthScheme.Sender` in the `[Authorize]`
attribute, enforcing a more specific authentication scheme for
this endpoint.
2026-06-28 21:39:33 +02:00
fe09c5c7ae Update auth-hub primary destination address in yarp.json
Replaced the `Address` value for the `primary` destination in the
`auth-hub` cluster within `yarp.json`. The previous value
(`https://localhost:9090`) was updated to
`http://172.24.12.39:9090`, reflecting a move from a local
development environment to a specific networked environment.
The protocol was also changed from `https` to `http`.
2026-06-28 20:31:52 +02:00
0763d82f6e Refactor services to use IHttpClientFactory
Refactored `DocReceiverElementService` and `EnvelopeService` to use `IHttpClientFactory` instead of directly injecting `HttpClient`, improving flexibility and testability.

Updated constructors to accept `IHttpClientFactory` and replaced direct `HttpClient` usage with named clients (`"EnvelopeGenerator.Server"`). Adjusted methods to use the factory-created clients for HTTP requests.

Added `using Microsoft.Extensions.Options;` in `Program.cs` and registered `EnvelopeService` and `DocReceiverElementService` in the dependency injection container for proper resolution. Clarified their usage in SSR scenarios with comments.

Removed redundant `using` directives and aligned imports with the updated implementation.

These changes enhance maintainability, scalability, and testability by leveraging `IHttpClientFactory` for better HTTP client management and dependency injection.
2026-06-28 20:31:30 +02:00
5a30bc050b Refactor services to remove ApiOptions dependency
Simplified `DocReceiverElementService` and `EnvelopeService` by removing the `ApiOptions` dependency. Updated constructors to eliminate the `IOptions<ApiOptions>` parameter and switched to using relative URLs directly for API requests.

Removed unused `BaseUrl` and `UsePredefinedReports` properties from `ApiOptions`. Cleaned up redundant fields, constructor logic, and unused `using` directives in affected services.

Registered `EnvelopeService` in `Program.cs` with `AddScoped`. These changes improve maintainability, reduce configuration overhead, and make the services more self-contained.
2026-06-28 20:16:34 +02:00
a4b218b9f3 Add new namespaces to EnvelopeService.cs
Included `EnvelopeGenerator.Application.Common.Dto`, `EnvelopeGenerator.Server.Client.Models`, `EnvelopeGenerator.Server.Client.Options`, and `Microsoft.AspNetCore.WebUtilities` to support new functionality or dependencies in the `EnvelopeService.cs` file.
2026-06-25 15:35:36 +02:00
67798b35da Simplify GetDocument authorization logic
Refactor `DocumentController.GetDocument` to exclusively support the "Sender" role by removing logic for the "Receiver" role. Update the `[Authorize]` attribute to enforce the `AuthPolicy.Sender` policy instead of `AuthPolicy.SenderOrReceiver`.

Remove the `AuthPolicy.SenderOrReceiver` policy from `Program.cs` authorization configuration, reflecting the decision to separate role-based access more explicitly. The application now defines distinct policies for "Sender" and "Receiver" roles without combining them.
2026-06-25 15:18:20 +02:00
b5bb2bbaae Refactor sender page and auth service logic
- Added project reference to `EnvelopeGenerator.Application` in the client project.
- Updated imports and injected services in `EnvelopeSenderPage.razor`.
- Improved null handling for `EnvelopeReceivers` and updated email display logic.
- Replaced `CheckSenderAsync` with `CheckSenderAccessAsync` for authorization.
- Refactored `GetStatusInfo` to use `EnvelopeStatus` enum directly.
- Added `CheckSenderAccessAsync` and `LogoutSenderAsync` methods in `AuthService`.
- Simplified `Logout` logic in `AuthController` to remove redundant checks.
2026-06-25 15:17:57 +02:00
85a0736106 Add envelope history tracking and receiver signing status
Added a `Histories` property to `EnvelopeDto` to track envelope
history entries for actions like `DocumentSigned` and
`EnvelopeOpened`. Introduced a computed `Signed` property in
`EnvelopeReceiverDto` to determine if a receiver has signed the
envelope based on the history. Updated `using` directives to
support these changes.
2026-06-25 14:43:09 +02:00
de9c9da176 Add Microsoft.AspNetCore.WebUtilities package
Added a new package reference for `Microsoft.AspNetCore.WebUtilities` (version 8.0.28) to the `EnvelopeGenerator.Server.Client.csproj` file. This package provides utilities for handling web-related functionality, such as query string parsing and encoding, and may support new features or dependencies in the project.
2026-06-25 13:37:21 +02:00
f4571320ce Mark Auth record as obsolete with replacement guidance
The `Auth` record in the `EnvelopeGenerator.Server.Models`
namespace has been marked as `[Obsolete]` with the message
"Use auth DTO" to indicate it is outdated and should be
replaced with a newer implementation.

The `Auth` record includes the following properties:
- `AccessCode`, `SmsCode`, `AuthenticatorCode` (nullable strings)
- `UserSelectSMS` (boolean)

Additionally, it defines computed properties `HasAccessCode`
and `HasSmsCode` to check for the presence of `AccessCode`
and `SmsCode`, respectively.
2026-06-25 13:37:05 +02:00
2abfffdeba Remove @rendermode and fix German special characters
The `@rendermode InteractiveWebAssembly` directive was removed from `IndexPage.razor` to simplify the page setup.

Additionally, the `HomePageDescription` constant was updated to replace improperly encoded characters with their correct Unicode equivalents. This ensures proper rendering of German umlauts and special characters in the text.
2026-06-25 13:22:38 +02:00
bfd1a9d060 Enhance EnvelopeSenderPage with new layout and features
Redesigned `EnvelopeSenderPage.razor` to include a structured
dashboard layout with a sender action bar and dynamic content
area. Added authorization enforcement with the `@attribute`
directive and dependency injection for services like
`EnvelopeService` and `AuthService`.

Introduced a tabbed interface for managing "Active" and
"Completed" envelopes, with a `DxGrid` component for displaying
envelope details. Added support for filtering, searching, and
row selection, along with detailed row templates for receiver
information.

Implemented methods for loading, refreshing, creating, editing,
and deleting envelopes, as well as logging out. Enhanced error
handling and state management, and added console logging for
debugging. Integrated external stylesheets for improved UI.
2026-06-25 13:22:09 +02:00
78ed49a077 Refactor AuthService and add new service classes
Refactored `AuthService` to introduce a reusable `CreateDefaultClient` method, reducing code duplication. Updated all relevant methods in `AuthService` to use this new method.

Added `CultureService` to manage application culture/localization, including support for setting, getting, and initializing culture from `localStorage` or browser settings.

Introduced `DocReceiverElementService` for retrieving document receiver elements (signatures) and `EnvelopeService` for managing envelope data retrieval with optional filters. Both services include error handling and consistent JSON deserialization.

These changes improve code maintainability, reusability, and adhere to the single responsibility principle.
2026-06-25 13:16:57 +02:00
6aa97adf84 Refactor and enhance EnvelopeReceiverPage UI/UX
- Replaced `SignatureService` with `DocReceiverElementService` in DI.
- Refactored `envelope-action-bar` for better readability and added badges for `2FA`, `Access Code`, and `Signature Count`.
- Improved error handling and loading states with clearer messages.
- Enhanced PDF viewer toolbar with better navigation, zoom, and signature controls.
- Added resizable thumbnail sidebar with persistent width settings.
- Refactored signature popup to support draw, text, and image tabs with validation.
- Improved JavaScript interop for PDF rendering and signature handling.
- Introduced DevExpress PDF Viewer as an alternative implementation.
- Consolidated state management and improved code readability.
2026-06-25 13:10:32 +02:00
50 changed files with 6259 additions and 1452 deletions

4
.gitignore vendored
View File

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

View File

@@ -1,542 +1,351 @@
# EnvelopeGenerator — AI Context Reference
# EnvelopeGenerator — Current Workspace Context
## Purpose
Digital document signing system with **unified Blazor Auto (Server+WASM hybrid) frontend** for both Senders and Receivers. Senders create envelopes and place signature fields. Receivers view PDFs, sign documents, export stamped PDFs.
Digital document signing system for senders and receivers.
**Primary Libraries:** DevExpress + PDF.js (PSPDFKit removed)
- Senders authenticate, view envelope lists, and manage envelope workflows.
- Receivers authenticate per envelope, open PDFs, create signatures, and apply them in the viewer.
- The active UI stack is `Blazor Auto` with server-side and WebAssembly render modes.
- Primary UI/PDF libraries are `DevExpress` and `PDF.js`.
---
## Migration Notice
## Active Application Structure
**EnvelopeGenerator.ReceiverUI ? EnvelopeGenerator.WebUI Migration**
### Main Host
**Primary active application:** `EnvelopeGenerator.Server`
The project has been migrated from pure Blazor WebAssembly (`ReceiverUI`) to **Blazor Auto (Server+WASM hybrid)** architecture (`WebUI`) to resolve DevExpress `DxPdfViewer` compatibility issues.
`EnvelopeGenerator.Server` is the current runtime host and contains:
- Blazor server host
- WebAssembly host integration
- API controllers
- authentication/authorization setup
- Swagger/Scalar setup
- YARP reverse proxy configuration
- DevExpress server-side services
- SQL Server distributed cache setup
**Reason:** DevExpress `DxPdfViewer` requires backend server-side rendering services that are NOT available in pure WebAssembly projects.
### Client Project
**Client UI project:** `EnvelopeGenerator.Server.Client`
**New Structure:**
- **WebUI** (Server project): Hosts server-side components, YARP proxy, DevExpress backend services
- **WebUI.Client** (WASM project): Client-side components, business logic, services
This project contains:
- WebAssembly-rendered pages
- client-side services
- client models and options
- sender and receiver login flows
**Migration Details:** See `MIGRATION_CONTEXT.md`
### Other Projects
- `EnvelopeGenerator.Application` — MediatR/CQRS handlers and business logic
- `EnvelopeGenerator.Domain` — domain models, constants, shared abstractions
- `EnvelopeGenerator.Infrastructure` — EF Core and infrastructure services
- `EnvelopeGenerator.PdfEditor` — PDF-related backend utilities
- `EnvelopeGenerator.API` — still exists in the solution, but the current merged app host is `EnvelopeGenerator.Server`
### Legacy / Do Not Touch
- `EnvelopeGenerator.Service`
- `EnvelopeGenerator.Form`
- `EnvelopeGenerator.BBTests`
- `EnvelopeGenerator.CommonServices`
---
## Deployment Architecture
## Current Hosting Model
**Two Presentation Projects (Both Required):**
`EnvelopeGenerator.Server/Program.cs` currently configures:
- `AddRazorComponents()` with both interactive server and interactive WebAssembly components
- `AddControllers()` and `MapControllers()`
- JWT authentication for sender and receiver flows
- cookie authentication
- authorization policies using `AuthScheme.Sender`, `AuthScheme.Receiver`, `AuthPolicy.Sender`, `AuthPolicy.Receiver`
- `AddReverseProxy()` with `yarp.json`
- Swagger / OpenAPI / Scalar
- distributed SQL Server cache
- DevExpress Blazor and DevExpress PDF Viewer server-side services
- request localization middleware
1. **EnvelopeGenerator.API** (ASP.NET Core Web API)
- Runs independently (development & production)
- Backend services for document management, authentication, signature endpoints
- Serves as API endpoint for WebUI
2. **EnvelopeGenerator.WebUI** (Blazor Auto - Server+WASM Hybrid)
- **Server Project (`EnvelopeGenerator.WebUI`):**
- **YARP Reverse Proxy** configured via `yarp.json`
- Proxies `/api/*` requests to `API:8088`
- Hosts server-side components (`@rendermode InteractiveServer`)
- DevExpress server-side services (DxPdfViewer backend)
- **Client Project (`EnvelopeGenerator.WebUI.Client`):**
- Client-side components (`@rendermode InteractiveWebAssembly`)
- Business logic services (AuthService, DocumentService, etc.)
- WASM runtime
**Request Flow:**
```
Client ? WebUI:XXXX (Blazor Auto)
?? Server-side Pages (DxPdfViewer)
?? Client-side Pages (WASM)
?? YARP Proxy: /api/* ? API:8088
```
**Configuration:** `EnvelopeGenerator.WebUI/yarp.json`
This means the active app is a **merged UI + API host**.
---
## WebUI Route Structure
## Reverse Proxy
### Root Route
| Route | File | Location | Render Mode |
**Config file:** `EnvelopeGenerator.Server/EnvelopeGenerator.Server/yarp.json`
Current YARP usage is focused on **AuthHub forwarding**, not a general `/api/* -> EnvelopeGenerator.API` proxy.
Configured routes forward:
- `POST /api/auth` -> AuthHub `/api/auth/sign-flow`
- `POST /api/Auth/envelope-receiver/{key}` -> AuthHub `/api/auth/envelope-receiver/{key}?cookie=true`
---
## Active Routes and Files
### WebAssembly Pages (`EnvelopeGenerator.Server.Client`)
| Route | File | Render Mode | Purpose |
|---|---|---|---|
| `/` | `Index.razor` | `WebUI.Client/Pages/` | `@rendermode InteractiveWebAssembly` |
| `/` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/IndexPage.razor` | WebAssembly | Landing page |
| `/sender/login` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/LoginSenderPage.razor` | WebAssembly | Sender login |
| `/sender` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/EnvelopeSenderPage.razor` | WebAssembly (`prerender: false`) | Sender dashboard |
| `/envelope/login/{EnvelopeKey}` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/LoginReceiverPage.razor` | WebAssembly | Receiver login |
### Sender Routes
| Route | File | Location | Render Mode |
### Server Pages (`EnvelopeGenerator.Server`)
| Route | File | Render Mode | Purpose |
|---|---|---|---|
| `/sender/login` | `LoginSenderPage.razor` | `WebUI.Client/Pages/` | `@rendermode InteractiveWebAssembly` |
| `/sender` | `EnvelopeSenderPage.razor` | `WebUI.Client/Pages/` | `@rendermode InteractiveWebAssembly` |
### Receiver Routes (PDF Viewers)
| Route | File | Location | Render Mode |
|---|---|---|---|
| `/envelope/login/{EnvelopeKey}` | `LoginReceiverPage.razor` | `WebUI.Client/Pages/` | `@rendermode InteractiveWebAssembly` |
| `/envelope/{EnvelopeKey}` | `EnvelopeReceiverPage.razor` | `WebUI/Components/Pages/` | `@rendermode InteractiveServer` |
| `/envelope/DxPdfViewer` | `EnvelopeReceiverPage_DxPdfViewer.razor` | `WebUI/Components/Pages/` | `@rendermode InteractiveServer` |
| `/envelope/{EnvelopeKey}/DxReportViewer` | `EnvelopeReceiverPage_DxReportViewer.razor` | `WebUI/Components/Pages/` | `@rendermode InteractiveServer` |
| `/envelope/Embed` | `EnvelopeReceiverPage_embed.razor` | `WebUI/Components/Pages/` | `@rendermode InteractiveServer` |
**Multi-Envelope Support:** Receivers can login to multiple envelopes simultaneously (per-envelope cookie authentication).
**Render Mode Strategy:**
- **Client-side Pages (WASM):** Login, Sender dashboard, Index (no DevExpress backend required)
- **Server-side Pages (Server):** PDF viewers (DevExpress DxPdfViewer requires backend)
| `/envelope/{EnvelopeKey}` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage.razor` | InteractiveServer | Main receiver PDF viewer and signing page |
| `/envelope/DxPdfViewer` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage_DxPdfViewer.razor` | InteractiveServer | DevExpress PDF Viewer test page |
| `/envelope/{EnvelopeKey}/DxReportViewer` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage_DxReportViewer.razor` | InteractiveServer | DevExpress report-based PDF rendering |
| `/envelope/Embed` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage_embed.razor` | InteractiveServer | Embedded browser PDF view test page |
---
## Architecture Evolution
## Current API Location
### Old Architecture (Deprecated v1)
- **Sender UI:** `EnvelopeGenerator.Web` (Razor Pages + PSPDFKit)
- **Receiver UI:** Separate project
- **Backend:** `EnvelopeGenerator.API`
The active application exposes controllers from:
`EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers`
### Intermediate Architecture (Deprecated v2)
- **Unified Frontend:** `EnvelopeGenerator.ReceiverUI` (Pure Blazor WASM)
- **Backend:** `EnvelopeGenerator.API`
- **Issue:** DevExpress `DxPdfViewer` displayed blank screen (no backend services in WASM)
Current controller set includes:
- `AnnotationController`
- `AuthController`
- `CacheController`
- `ConfigController`
- `DocumentController`
- `EmailTemplateController`
- `EnvelopeController`
- `EnvelopeReceiverController`
- `EnvelopeTypeController`
- `HistoryController`
- `LocalizationController`
- `ReadOnlyController`
- `ReceiverController`
- `SignatureController`
- `TfaRegistrationController`
### Current Architecture (Active)
- **Frontend:** `EnvelopeGenerator.WebUI` (Blazor Auto - Server+WASM Hybrid)
- **WebUI** (Server): Server-side components, YARP proxy, DevExpress backend
- **WebUI.Client** (WASM): Client-side components, services, business logic
- **Backend:** `EnvelopeGenerator.API`
- **Libraries:** DevExpress + PDF.js
- **PSPDFKit:** **REMOVED**
Do not assume API behavior lives only in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly.
---
## Solution Structure
## Authentication Model
| Project | Target | Purpose |
|---|---|---|
| `EnvelopeGenerator.API` | net8.0 | ASP.NET Core Web API. Backend for **both Senders & Receivers**. Auth, PDF serving, signature endpoints. |
| `EnvelopeGenerator.WebUI` | net8.0 | **Blazor Auto Server Project**. YARP proxy, server-side components, DevExpress backend services. |
| `EnvelopeGenerator.WebUI.Client` | net8.0 WASM | **Blazor Auto Client Project**. Client-side components, services, business logic. |
| `EnvelopeGenerator.ReceiverUI` | net8.0 WASM | **DEPRECATED.** Pure Blazor WASM (migrated to WebUI). |
| `EnvelopeGenerator.Web` | net7/8/9 | **DEPRECATED.** Legacy Razor Pages (Sender UI). No longer used. |
| `EnvelopeGenerator.Application` | multi | MediatR CQRS handlers. Business logic. |
| `EnvelopeGenerator.Domain` | multi | Domain models, constants, interfaces. |
| `EnvelopeGenerator.Infrastructure` | multi | EF Core repos, DB context. |
| `EnvelopeGenerator.PdfEditor` | multi | iText7 utilities (NOT used in WebUI). |
| `EnvelopeGenerator.DependencyInjection` | multi | DI registration helpers. |
| **VB.NET projects** (Service/Form/BBTests) | net462 | **Legacy. Do NOT touch.** |
### Sender
Client login page uses `EnvelopeGenerator.Server.Client/Services/AuthService.cs`.
Key sender endpoints:
- `POST /api/auth?cookie=true` login
- `GET /api/auth/check` — current sender access check
- `POST /api/auth/logout` — logout
### Receiver
Receiver authentication is **per envelope**.
Key receiver endpoints used by client services:
- `POST /api/Auth/envelope-receiver/{envelopeKey}` — submit access code
- `GET /api/auth/check/envelope/{envelopeKey}` — check access
- `POST /api/auth/logout/envelope/{envelopeKey}` — logout receiver for one envelope
Receiver cookie resolution in server auth uses an envelope-specific cookie name derived from:
- `AuthTokenSignFLOWReceiver.{envelopeKey}` pattern
### Receiver Server-Side Authorization
`EnvelopeReceiverPage.razor` does **not** rely on its own API access-check call for page authorization.
It uses:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverAuthorizationService.cs`
Behavior:
- tries the current `HttpContext.User`
- if needed, reads the per-envelope receiver cookie directly
- validates the JWT with the receiver auth scheme
- verifies the token subject matches the route envelope key
---
## Localization & Culture Management
## Receiver Page Data Loading
**Current Architecture:** Blazor WebAssembly (client-side culture management)
Main server-side page data service:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverPageDataService.cs`
### Implementation Details
This service loads directly via MediatR and distributed cache:
- document bytes
- receiver envelope data
- signature placeholders
- cached signature data
**Culture Storage:**
- Culture preference stored in browser's `localStorage` (key: `AppCulture`)
- Managed by `CultureService.cs` (ReceiverUI/Services)
- Supported cultures: `de-DE`, `en-US`, `fr-FR`
**Culture Initialization:**
- **Location:** `Program.cs` (lines 53-57)
- Sets `CultureInfo.DefaultThreadCurrentCulture/UICulture` **before** app runs
- **WASM-Safe:** Each user has isolated browser instance
**Language Selector:**
- **Component:** `LanguageSelector.razor` (ReceiverUI/Shared)
- Displays flag icon + language name
- Changes culture via `CultureService.SetCultureAsync()`
- Navigates with `forceLoad: false` (smooth transition, no page reload)
### ⚠️ MIGRATION WARNING: Blazor Server/Auto
**Current approach is WASM-specific and will break in Server/Auto render modes!**
**Why it breaks:**
- `Program.cs:53-57` sets **global** `DefaultThreadCurrentCulture`
- In Server/Auto, one app instance serves **all users**
- User A selects German → User B sees German too (shared state)
- Thread-safety issues and culture conflicts
**Migration Checklist (when moving to Server/Auto):**
1. **Remove global culture initialization** from `Program.cs` (lines 53-57)
- See detailed warning comment in the code
2. **Add RequestLocalizationMiddleware** (Server-side approach):
```csharp
app.UseRequestLocalization(options => {
options.SupportedCultures = new[] { "de-DE", "en-US", "fr-FR" };
options.SupportedUICultures = options.SupportedCultures;
options.RequestCultureProviders.Insert(0, new CookieRequestCultureProvider());
});
```
3. **OR** Use **per-circuit culture** (Blazor Server approach):
- Store culture in circuit-scoped service
- Use `CascadingParameter` to distribute to components
- See: https://learn.microsoft.com/aspnet/core/blazor/globalization-localization
4. **Update `LanguageSelector.razor`:**
- Remove manual `CultureInfo.DefaultThreadCurrentCulture` assignment
- Use middleware/circuit culture provider instead
5. **Update `CultureService.cs`:**
- Integrate with Server-side culture provider
- May need to store in cookies instead of localStorage
**References:**
- Microsoft Docs: [Blazor Globalization/Localization](https://learn.microsoft.com/aspnet/core/blazor/globalization-localization)
- Current implementation: `Program.cs`, `CultureService.cs`, `LanguageSelector.razor`
For signature placeholders, the service:
- reads document receiver elements
- filters them for the authenticated receiver
- converts coordinates to `UnitOfLength.Point` before UI use
---
## Key Files & Routes
## Receiver PDF Viewer
### Client-Side Pages (WebUI.Client)
| File | Route | Purpose |
|---|---|---|
| `WebUI.Client/Pages/Index.razor` | `/` | Application entry point (landing page). |
| `WebUI.Client/Pages/EnvelopeSenderPage.razor` | `/sender` | Sender dashboard (envelope list). |
| `WebUI.Client/Pages/LoginSenderPage.razor` | `/sender/login` | Sender username/password auth. |
| `WebUI.Client/Pages/LoginReceiverPage.razor` | `/envelope/login/{EnvelopeKey}` | Receiver access code auth. |
**Main file:** `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage.razor`
### Server-Side Pages (WebUI)
| File | Route | Purpose |
|---|---|---|
| `WebUI/Components/Pages/EnvelopeReceiverPage.razor` | `/envelope/{key}` | Receiver PDF viewer & signing (PDF.js). |
| `WebUI/Components/Pages/EnvelopeReceiverPage_DxPdfViewer.razor` | `/envelope/DxPdfViewer` | DevExpress PDF Viewer (test page). |
| `WebUI/Components/Pages/EnvelopeReceiverPage_DxReportViewer.razor` | `/envelope/{key}/DxReportViewer` | DevExpress Report Viewer. |
| `WebUI/Components/Pages/EnvelopeReceiverPage_embed.razor` | `/envelope/Embed` | Embedded PDF viewer (iframe). |
Current receiver viewer characteristics:
- route: `/envelope/{EnvelopeKey}`
- render mode: `InteractiveServer`
- PDF rendering: `PDF.js`
- toolbar: page navigation, zoom, thumbnail toggle, signature navigation, signature reset
- signature popup: `DxPopup`
- thumbnail sidebar: resizable and stored in `localStorage`
### Services & Assets
| File | Purpose |
|---|---|
| `WebUI.Client/Services/AuthService.cs` | Receiver + Sender authentication. |
| `WebUI.Client/Services/SignatureCacheService.cs` | Signature caching (Redis/SQL). |
| `WebUI.Client/Services/DocumentService.cs` | PDF document retrieval. |
| `WebUI/wwwroot/js/pdf-viewer.js` | PDF.js wrapper (zoom, pagination, thumbnails). |
| `WebUI/wwwroot/js/receiver-signature.js` | Signature pad (draw/type/image). |
| `WebUI/wwwroot/css/envelope-viewer.css` | EnvelopeViewer styles. |
| `API/Controllers/CacheController.cs` | Signature cache endpoints. |
### JS Assets
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/pdf-viewer.js`
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/receiver-signature.js`
### CSS
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css`
### PDF.js CDN
- `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js`
- `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf_viewer.min.css`
---
## Coordinate System — CRITICAL
## Signature Workflow
**Database Format:** INCHES (GdPicture14 native)
**Origin:** Top-left corner
**Axes:** X right, Y down
Receiver signatures are handled as a **viewer overlay workflow**.
### Conversion Formulas
### Current behavior
1. Server-side authorization validates receiver access.
2. The page loads document bytes, receiver data, signature placeholders, and cached signature state.
3. If no cached signature exists, the signature popup opens automatically.
4. Receiver creates signature using one of three tabs:
- draw
- text
- image
5. Required metadata:
- full name
- place
6. Optional metadata:
- position
7. Clicking a signature placeholder applies the signature as a client-side overlay in the PDF viewer.
| From INCHES to | Formula | Example |
|---|---|---|
| **DevExpress DX** | `x_DX = x_inches * 100` | 1.5" ? 150 DX |
| **PDF Points** | `x_pt = x_inches * 72` | 1.5" ? 108 pt |
| **PDF.js Pixels** | Normalize ? scale | `(x_inches / pageWidth) * canvasWidth * scale` |
### Important note
Although `itext` is referenced by the server project, the current receiver page signing flow is **not PDF stamping-based**. The active receiver UI uses client-side overlay behavior in the viewer.
**A4 Dimensions:**
- Width: 8.27" = 595pt = 827 DX
- Height: 11.69" = 842pt = 1169 DX
### Unit Systems
| System | Unit | Origin | Y-Axis |
|---|---|---|---|
| **Database (GdPicture14)** | Inches | Top-left | Down |
| PDF.js | Pixels | Top-left | Down |
| iText7 PDF | Points (1/72") | **Bottom-left** | **Up** (flip required) |
| ~~PSPDFKit~~ | ~~Points~~ | ~~Top-left~~ | **REMOVED** |
---
## EnvelopeReceiver — PDF.js Viewer & Signing
**Route:** `/envelope/{EnvelopeKey}`
**Tech:** PDF.js 3.11.174 + Blazor Server (`@rendermode InteractiveServer`) + configurable quality
**File:** `WebUI/Components/Pages/EnvelopeReceiverPage.razor`
### Key Features
1. HiDPI/Retina support (4x quality)
2. Configurable quality (`appsettings.json`)
3. Unlimited zoom (50%-300%)
4. Ctrl+Wheel global zoom
5. Resizable thumbnail sidebar (150-400px, localStorage)
6. Responsive (desktop/mobile)
### Configuration
**File:** `WebUI/wwwroot/appsettings.json`
```json
{
"PdfViewer": {
"ThumbnailBaseScale": 0.75,
"ThumbnailEnableHiDPI": true,
"MainCanvasEnableHiDPI": true,
"ZoomStepPercentage": 5
}
}
```
### JavaScript API
**File:** `WebUI/wwwroot/js/pdf-viewer.js`
```javascript
window.pdfViewer = {
initialize(canvasId, pdfDataUrl, dotNetRef),
renderPage(num),
renderSignatureButtons(signatures, pageNum, dotNetRef),
applySignature(signatureId, dataUrl, fullName, position, place),
zoomIn(), zoomOut(), dispose()
}
```
---
## Signature Workflow — EnvelopeReceiver
**IMPORTANT:** iText7 NOT used (GPL license issue). Client-side overlay system only.
### Workflow Steps
1. **Page Load:**
- Check `SignatureCacheService` for cached signature
- If cached ? skip popup, load signature
- If not ? show automatic popup (mandatory)
2. **Signature Popup (DxPopup):**
- **Cannot close** (no X, no ESC, no outside-click)
- **3 Tabs:** Draw (canvas) / Text (font select) / Image (upload)
- **Required:** Full name, Place
- **Optional:** Position
- **Save ?** Store in `_capturedSignature`, cache via API
3. **Signature Buttons:**
- Render purple "Unterschreiben" buttons at signature field positions
- Coordinates: INCHES ? POINTS ? Pixels (scaled)
- File: `pdf-viewer.js` ? `renderSignatureButtons()`
4. **Apply Signature (Click "Unterschreiben"):**
- JS: Remove button, create HTML overlay
- Format: Image + separator + text (Name, Position, Place, Date)
- **NOT stamped on PDF bytes** (visual overlay only)
5. **Re-rendering:**
- Zoom/Page change ? recalculate button positions
- Session state: `_capturedSignature` (lost on refresh)
### Data Model
**File:** `WebUI.Client/Models/SignatureCaptureDto.cs`
### Signature DTO
`EnvelopeGenerator.Server.Client/Models/SignatureCaptureDto.cs`
```csharp
public sealed record SignatureCaptureDto {
public required string DataUrl { get; init; } // base64 PNG
public required string DataUrl { get; init; }
public required string FullName { get; init; }
public string Position { get; init; } = ""; // Optional
public string Position { get; init; } = "";
public required string Place { get; init; }
}
```
---
## Signature Caching
## Signature Cache
**Purpose:** Persist signature across page refreshes (distributed cache: Redis/SQL)
### Active cache model
The current receiver page cache flow is handled directly in the server project through:
- `EnvelopeReceiverPageDataService`
- `IDistributedCache`
- SQL Server distributed cache configuration from `Program.cs`
### API Endpoints
**Controller:** `API/Controllers/CacheController.cs`
### Cache key format
Current server-side key prefix:
- `envelope-generator.receiver-ui.signature:{receiverSignature}`
- `POST /api/Cache/SignatureCapture/{envelopeKey}` — Save
- `GET /api/Cache/SignatureCapture/{envelopeKey}` — Load
- `DELETE /api/Cache/SignatureCapture/{envelopeKey}` — Delete
This is different from an envelope-key-only cache convention.
**Cache Key Format:**
```
signature:91751687-8ae6-4777-bf5f-b8846085e62e:{envelopeKey}
```
### Config
`EnvelopeGenerator.Server/EnvelopeGenerator.Server/Options/CacheOptions.cs`
- section name: `Cache`
- option: `SignatureCacheExpiration`
**Configuration:** `appsettings.json`
```json
{
"Cache": {
"SignatureCacheExpiration": null // or "02:00:00" for 2h
}
}
```
### Service
**File:** `WebUI.Client/Services/SignatureCacheService.cs`
```csharp
public class SignatureCacheService {
Task SaveSignatureAsync(string envelopeKey, SignatureCaptureDto signature);
Task<SignatureCaptureDto?> GetSignatureAsync(string envelopeKey);
Task DeleteSignatureAsync(string envelopeKey);
}
```
**Error Handling:** Fire-and-forget saves, graceful degradation on load failure.
### Related controller
A cache API controller also exists in:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/CacheController.cs`
---
## Sender Login
## Sender Dashboard
**Route:** `/sender/login`
**File:** `WebUI.Client/Pages/LoginSenderPage.razor`
**Tech:** Bootstrap 5 + DevExpress Blazing Berry theme
**Main file:** `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/EnvelopeSenderPage.razor`
### AuthService Extension
**File:** `WebUI.Client/Services/AuthService.cs`
Current behavior:
- checks sender access through `AuthService.CheckSenderAccessAsync()`
- redirects to `/sender/login` when unauthorized
- loads envelope list through client `EnvelopeService`
- separates envelopes into active/completed tabs
- uses `DevExpress DxGrid`
```csharp
public enum SenderLoginResult { Success, InvalidCredentials, Error }
public async Task<SenderLoginResult> LoginSenderAsync(string username, string password) {
var response = await http.PostAsJsonAsync(
$"{_api.BaseUrl}/api/auth?cookie=true",
new { username, password });
return response.StatusCode switch {
HttpStatusCode.OK => SenderLoginResult.Success,
HttpStatusCode.Unauthorized => SenderLoginResult.InvalidCredentials,
_ => SenderLoginResult.Error
};
}
```
### API Integration
**Endpoint:** `POST /api/auth?cookie=true`
**Request:**
```json
{ "username": "TekH", "password": "***" }
```
**Response:**
- `200 OK` ? Cookie set, redirect to `/sender`
- `401 Unauthorized` ? Show error: "Ungültige Anmeldedaten"
- Other ? Show error: "Serverfehler"
**Cookie:** HTTP-only, Secure (HTTPS), SameSite=Strict
### UI Flow
1. User enters username + password
2. Click "Anmelden" or press Enter
3. Call `AuthService.LoginSenderAsync()`
4. Success ? `Navigation.NavigateTo("/sender", forceLoad: true)`
5. Error ? Display alert
The sender page is active, but create/edit/delete actions are still marked with TODO behavior in the UI page.
---
## Receiver Login
## Localization
**Route:** `/envelope/login/{EnvelopeKey}`
**File:** `WebUI.Client/Pages/LoginReceiverPage.razor`
Current server host localization setup in `Program.cs`:
- supported cultures: `de-DE`, `en-US`
- request localization middleware is enabled
- `QueryStringRequestCultureProvider` is added
- cookie-based localization services are registered via `AddCookieBasedLocalizer()`
**Multi-Envelope Support:** Cookies are stored per-envelope (e.g., `AuthTokenSignFLOWReceiver.{envelopeKey}`), allowing simultaneous authentication for multiple envelopes in the same browser session.
### AuthService Method
```csharp
public enum EnvelopeLoginResult { Success, InvalidCode, NotFound, Error }
public async Task<EnvelopeLoginResult> LoginEnvelopeReceiverAsync(string key, string accessCode) {
var form = new MultipartFormDataContent();
form.Add(new StringContent(accessCode), "AccessCode");
var response = await http.PostAsync(
$"{_api.BaseUrl}/api/Auth/envelope-receiver/{Uri.EscapeDataString(key)}", form);
return response.StatusCode switch {
HttpStatusCode.OK => EnvelopeLoginResult.Success,
HttpStatusCode.Unauthorized => EnvelopeLoginResult.InvalidCode,
HttpStatusCode.NotFound => EnvelopeLoginResult.NotFound,
_ => EnvelopeLoginResult.Error
};
}
```
**Success:** Redirect to `/envelope/{key}`
Do not assume the old ReceiverUI-only `localStorage` culture approach is the current source of truth for the active host.
---
## NuGet Packages (WebUI.Client)
## Coordinate System
| Package | Version | Purpose |
|---|---|---|
| `DevExpress.Blazor.*` | 25.2.3 | UI components (grids, popups, etc.) |
| `SkiaSharp.*` | 3.119.1 | WASM rendering |
| ~~`itext`~~ | ~~8.0.5~~ | **NOT USED** (GPL license) |
### Source data
Database signature coordinates are still based on:
- **unit:** inches
- **origin:** top-left
- **axes:** X right, Y down
**External CDN:**
- PDF.js 3.11.174: `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js`
### Relevant conversions
- inches -> PDF points: `x_pt = x_inches * 72`
- inches -> DevExpress DX units: `x_dx = x_inches * 100`
### Current receiver page behavior
The server page data service converts signature placeholders to **points** before sending them into the viewer workflow.
### Unit systems to keep in mind
| System | Unit | Origin | Y-axis |
|---|---|---|---|
| Database | Inches | Top-left | Down |
| PDF.js display | Pixels | Top-left | Down |
| PDF points | Points | Depends on PDF model | Depends on consumer |
| DevExpress DX | 1/100 inch style coordinates | Top-left-oriented usage in this app | Down-oriented usage |
---
## Mistakes History — Do NOT Repeat
## Key Services and Files
| Mistake | Why Wrong |
|---|---|
| Using iText7 in EnvelopeReceiver | GPL license issue. Use overlay system instead. |
| Using PSPDFKit | Removed from architecture. Use PDF.js + DevExpress. |
| Hardcoded quality values in PDF.js | Use `appsettings.json` for configurability. |
| Complex toolbar layouts | User wants simplicity. Keep horizontal layout. |
| Over-designed UI (gradients/badges) | User prefers simple text labels. |
| Ignoring "revert" instructions | Revert HTML structure, not just CSS. |
| `BottomMarginBand` for signatures | Repeats on every page. Use DetailBand. |
| `imageY = (page-1) * 1169 + ann.Y` | Inflates DetailBand. Calculate per-page. |
### Client services
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/AuthService.cs`
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/EnvelopeService.cs`
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/DocumentService.cs`
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/SignatureCacheService.cs`
### Server services
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeAuthService.cs`
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/IEnvelopeAuthService.cs`
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverAuthorizationService.cs`
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverPageDataService.cs`
### Server config and host files
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Program.cs`
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/yarp.json`
---
## Development Notes
## Working Rules for This Workspace
### Deprecated Projects
**DO NOT USE:**
- `EnvelopeGenerator.ReceiverUI` (Pure Blazor WASM) — Migrated to WebUI (DevExpress compatibility issue)
- `EnvelopeGenerator.Web` (Razor Pages) — Replaced by unified WebUI
- PSPDFKit — Removed, use PDF.js + DevExpress instead
### Legacy Projects (VB.NET)
**DO NOT TOUCH:** `EnvelopeGenerator.Service`, `EnvelopeGenerator.Form`, `EnvelopeGenerator.BBTests`
### Signature Coordinate Evidence
**File:** `EnvelopeGenerator.Form/frmFieldEditor.vb` (VB.NET)
```vb
Private Const SIGNATURE_WIDTH As Single = 1.77 ' inches
Private Const SIGNATURE_HEIGHT As Single = 1.96 ' inches
Sub LoadAnnotation(pElement As Signature, ...)
oAnnotation.Left = CSng(pElement.X) ' Direct INCHES assignment
oAnnotation.Top = CSng(pElement.Y)
End Sub
```
Proves database uses INCHES natively.
- Treat `EnvelopeGenerator.Server` as the active main application host.
- Treat `EnvelopeGenerator.Server.Client` as the active client UI project.
- Prefer current `Server` / `Server.Client` paths over old `WebUI` / `ReceiverUI` references.
- Do not use `EnvelopeGenerator.Web` or `EnvelopeGenerator.ReceiverUI` as the primary implementation target unless explicitly asked.
- Do not modify the legacy VB.NET projects unless explicitly requested.
- For receiver PDF/signature work, prefer the current `PDF.js`-based flow in `EnvelopeReceiverPage.razor`.
- For DevExpress PDF viewer issues, remember server-side services are registered in `EnvelopeGenerator.Server`.
---
## Quick Reference
### When working with coordinates:
1. **Database ? UI:** INCHES × 72 = PDF Points
2. **UI ? Display:** Points × scale = Pixels
3. **iText7 stamping:** Flip Y-axis (top-down ? bottom-up)
### When adding features:
1. Check `Mistakes History` first
2. Prefer simplicity over complexity
3. Use `appsettings.json` for configuration
4. Keep consistent with existing design (Bootstrap 5 + Blazing Berry)
5. **Unified frontend:** WebUI serves both Senders and Receivers
6. **Render mode:** Client-side (WASM) for login/dashboard, Server-side for PDF viewers
### When debugging:
1. **Coordinates:** Always check unit system (inches/points/pixels)
2. **Authentication:** Check cookie name/domain/SameSite
3. **Cache:** Check Redis/SQL connection + key format
4. **Frontend confusion:** Only use WebUI (ReceiverUI/Web are deprecated)
5. **Blank DxPdfViewer:** Ensure page has `@rendermode InteractiveServer`
---
**Last Updated:** 2025-01-27 (ReceiverUI ? WebUI migration complete)
**Last Updated:** 2026-06-29

View File

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

View File

@@ -1,6 +1,7 @@
using DigitalData.EmailProfilerDispatcher.Abstraction.Attributes;
using DigitalData.UserManager.Application.DTOs.User;
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
using EnvelopeGenerator.Application.Common.Dto.History;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Interfaces;
@@ -129,4 +130,9 @@ public record EnvelopeDto : IEnvelope
///
/// </summary>
public IEnumerable<EnvelopeReceiverDto>? EnvelopeReceivers { get; set; }
/// <summary>
/// Envelope history entries tracking actions like DocumentSigned, EnvelopeOpened, etc.
/// </summary>
public IEnumerable<HistoryDto>? Histories { get; set; }
}

View File

@@ -1,5 +1,6 @@
using DigitalData.EmailProfilerDispatcher.Abstraction.Attributes;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
@@ -73,4 +74,13 @@ public record EnvelopeReceiverDto
///
/// </summary>
public bool HasPhoneNumber { get; init; }
/// <summary>
/// Indicates whether this receiver has signed the envelope.
/// Checks if there is a DocumentSigned history entry for this receiver in the envelope's history.
/// </summary>
public bool Signed => Envelope?.Histories?.Any(h =>
h.Receiver?.Id == ReceiverId &&
h.Status == EnvelopeStatus.DocumentSigned
) ?? false;
}

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@
<PackageReference Include="DevExpress.Blazor.Reporting.Viewer" Version="25.2.3" />
<PackageReference Include="DevExpress.Drawing.Skia" Version="25.2.3" />
<PackageReference Include="HarfBuzzSharp.NativeAssets.WebAssembly" Version="8.3.1.2" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="8.0.28" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" Version="3.119.1" />
<PackageReference Include="SkiaSharp.Views.Blazor" Version="3.119.1" />
@@ -28,6 +29,10 @@
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.11" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="PredefinedReports\Report.cs">
<SubType>XtraReport</SubType>

View File

@@ -4,7 +4,5 @@ public class ApiOptions
{
public const string SectionName = "Api";
public string BaseUrl { get; set; } = string.Empty;
public bool UsePredefinedReports { get; set; } = false;
}

View File

@@ -1,8 +1,449 @@
@page "/sender"
@rendermode InteractiveWebAssembly
@page "/sender"
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
@using System.Text.Json
@using EnvelopeGenerator.Domain.Constants
@using EnvelopeGenerator.Server.Client.Models
@using DevExpress.Blazor
@using EnvelopeGenerator.Server.Client.Services
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
@using EnvelopeGenerator.Application.Common.Dto
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
<h3>EnvelopeSender</h3>
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/sender-page.css")" rel="stylesheet" />
<div class="sender-dashboard-layout">
<div class="sender-action-bar">
<div class="sender-action-bar__inner">
<div class="sender-title-section">
<div class="sender-logo">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
</svg>
</div>
<div class="sender-title">Umschlag-Übersicht</div>
</div>
<div class="sender-toolbar">
<button class="sender-btn sender-btn--primary" @onclick="CreateEnvelope" title="Neuen Umschlag erstellen">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
</svg>
Neuer Umschlag
</button>
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || IsEnvelopeSent(_selectedEnvelope))" title="Ausgewählten Umschlag bearbeiten">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
</svg>
Bearbeiten
</button>
<button class="sender-btn sender-btn--danger" @onclick="DeleteEnvelope" disabled="@(_selectedEnvelope == null)" title="Ausgewählten Umschlag löschen">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
Löschen
</button>
<button class="sender-btn" @onclick="RefreshEnvelopes" disabled="@_isLoading" title="Aktualisieren">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
@if (_isLoading) {
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
}
</button>
<button class="sender-btn sender-btn--logout" @onclick="LogoutAsync" disabled="@_isLoggingOut" title="Abmelden">
@if (_isLoggingOut) {
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
} else {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z"/>
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
</svg>
}
</button>
</div>
</div>
</div>
<div class="sender-content">
@if (_isLoading && _allEnvelopes == null) {
<div class="d-flex justify-content-center align-items-center h-100">
<div class="text-center">
<div class="spinner-border text-white mb-3" style="width: 3.5rem; height: 3.5rem;" role="status">
<span class="visually-hidden">Lädt...</span>
</div>
<p class="text-white fw-semibold">Umschläge werden geladen...</p>
</div>
</div>
} else if (_errorMessage != null) {
<div class="error-container">
<div class="alert alert-danger shadow-lg">
<div class="d-flex align-items-start">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="me-3 flex-shrink-0" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
</svg>
<div>
<h5 class="mb-2">Fehler beim Laden der Umschläge</h5>
<p class="mb-0">@_errorMessage</p>
</div>
</div>
</div>
</div>
} else {
<div class="sender-grid-container">
<div class="sender-tabs">
<button class="sender-tab @(_activeTab == "active" ? "sender-tab--active" : "")" @onclick='() => _activeTab = "active"'>
<span>Aktive Umschläge</span>
@if (_activeEnvelopes != null) {
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_activeEnvelopes.Count())</span>
}
</button>
<button class="sender-tab @(_activeTab == "completed" ? "sender-tab--active" : "")" @onclick='() => _activeTab = "completed"'>
<span>Abgeschlossene Umschläge</span>
@if (_completedEnvelopes != null) {
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_completedEnvelopes.Count())</span>
}
</button>
</div>
<div class="sender-grid-wrapper">
@if (_activeTab == "active") {
<DxGrid Data="@_activeEnvelopes"
@ref="_gridActive"
ShowFilterRow="true"
ShowSearchBox="true"
AllowColumnReorder="true"
AllowSort=true
ColumnResizeMode="GridColumnResizeMode.ColumnsContainer"
PageSize="20"
PagerVisible="true"
SelectionMode="GridSelectionMode.Single"
SelectedDataItem="@_selectedEnvelope"
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
CustomizeElement="OnCustomizeElement">
<Columns>
<DxGridDataColumn FieldName="Id" Caption="ID"
SortIndex="0"
SortOrder="GridColumnSortOrder.Descending">
<CellDisplayTemplate Context="cellContext">
@((cellContext.DataItem as EnvelopeDto)?.Id)
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="Title" Caption="Titel">
<CellDisplayTemplate Context="cellContext">
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="Status" Caption="Status">
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null) {
var statusInfo = GetStatusInfo(envelope.Status);
<div class="status-badge status-badge--@statusInfo.CssClass">
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
@statusInfo.Label
</div>
}
}
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger">
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null) {
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
var signed = receivers.Count(r => r.Signed);
var total = receivers.Count;
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="font-size: 0.875rem; color: #6b7280;">
@signed / @total unterschrieben
</span>
@if (total > 0) {
<div style="flex: 1; min-width: 60px; max-width: 120px; height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden;">
<div style="height: 100%; background: linear-gradient(90deg, #81c784 0%, #66bb6a 100%); width: @((signed * 100.0 / total).ToString("F0"))%;"></div>
</div>
}
</div>
}
}
</CellDisplayTemplate>
</DxGridDataColumn>
</Columns>
<DetailRowTemplate Context="detailContext">
<div style="padding: 1rem; background: #f9fafb;">
<h6 style="font-weight: 600; color: #374151; margin-bottom: 0.75rem;">Empfänger</h6>
@{
var envelope = detailContext.DataItem as EnvelopeDto;
if (envelope?.EnvelopeReceivers?.Any() == true) {
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
@foreach (var receiver in envelope.EnvelopeReceivers) {
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
<span class="receiver-badge receiver-badge--@(receiver.Signed ? "signed" : "unsigned")" style="min-width: 100px;">
@if (receiver.Signed) {
<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"/>
</svg>
<span>Unterschrieben</span>
} else {
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg>
<span>Ausstehend</span>
}
</span>
<div style="flex: 1; font-size: 0.875rem;">
<strong style="color: #1f2937;">@receiver.Name</strong>
<span style="color: #6b7280; margin-left: 0.5rem;">@receiver.Receiver?.EmailAddress</span>
</div>
</div>
}
</div>
} else {
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">Keine Empfänger</p>
}
}
</div>
</DetailRowTemplate>
</DxGrid>
} else {
<DxGrid Data="@_completedEnvelopes"
@ref="_gridCompleted"
ShowFilterRow="true"
ShowSearchBox="true"
PageSize="20"
PagerVisible="true"
SelectionMode="GridSelectionMode.Single"
SelectedDataItem="@_selectedEnvelope"
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
CustomizeElement="OnCustomizeElement">
<Columns>
<DxGridDataColumn FieldName="Id" Caption="ID"
SortIndex="0"
SortOrder="GridColumnSortOrder.Descending">
<CellDisplayTemplate Context="cellContext">
@((cellContext.DataItem as EnvelopeDto)?.Id)
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="Title" Caption="Titel">
<CellDisplayTemplate Context="cellContext">
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="Status" Caption="Status">
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null) {
var statusInfo = GetStatusInfo(envelope.Status);
<div class="status-badge status-badge--@statusInfo.CssClass">
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
@statusInfo.Label
</div>
}
}
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger">
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null) {
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
var signed = receivers.Count(r => r.Signed);
var total = receivers.Count;
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span style="font-size: 0.875rem; color: #6b7280;">
@signed / @total unterschrieben
</span>
@if (total > 0) {
<div style="flex: 1; min-width: 60px; max-width: 120px; height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden;">
<div style="height: 100%; background: linear-gradient(90deg, #81c784 0%, #66bb6a 100%); width: @((signed * 100.0 / total).ToString("F0"))%;"></div>
</div>
}
</div>
}
}
</CellDisplayTemplate>
</DxGridDataColumn>
</Columns>
<DetailRowTemplate Context="detailContext">
<div style="padding: 1rem; background: #f9fafb;">
<h6 style="font-weight: 600; color: #374151; margin-bottom: 0.75rem;">Empfänger</h6>
@{
var envelope = detailContext.DataItem as EnvelopeDto;
if (envelope?.EnvelopeReceivers?.Any() == true) {
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
@foreach (var receiver in envelope.EnvelopeReceivers) {
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
<span class="receiver-badge receiver-badge--@(receiver.Signed ? "signed" : "unsigned")" style="min-width: 100px;">
@if (receiver.Signed) {
<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"/>
</svg>
<span>Unterschrieben</span>
} else {
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
</svg>
<span>Ausstehend</span>
}
</span>
<div style="flex: 1; font-size: 0.875rem;">
<strong style="color: #1f2937;">@receiver.Name</strong>
<span style="color: #6b7280; margin-left: 0.5rem;">@receiver.Receiver?.EmailAddress</span>
</div>
</div>
}
</div>
} else {
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">Keine Empfänger</p>
}
}
</div>
</DetailRowTemplate>
</DxGrid>
}
</div>
</div>
}
</div>
</div>
@code {
private IEnumerable<EnvelopeDto>? _allEnvelopes;
private IEnumerable<EnvelopeDto>? _activeEnvelopes;
private IEnumerable<EnvelopeDto>? _completedEnvelopes;
private EnvelopeDto? _selectedEnvelope;
private string _activeTab = "active";
private bool _isLoading = true;
private bool _isLoggingOut = false;
private string? _errorMessage;
private DxGrid? _gridActive;
private DxGrid? _gridCompleted;
}
protected override async Task OnInitializedAsync()
{
var hasAccess = await AuthService.CheckSenderAccessAsync();
if (!hasAccess)
{
Navigation.NavigateTo($"/sender/login");
return;
}
await LoadEnvelopesAsync();
}
async Task LoadEnvelopesAsync()
{
_isLoading = true;
_errorMessage = null;
await InvokeAsync(StateHasChanged);
try
{
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
// Split into active and completed based on status
var envelopes = _allEnvelopes.ToList();
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
await JSRuntime.InvokeVoidAsync("console.log", $"Loaded {_activeEnvelopes.Count()} active and {_completedEnvelopes.Count()} completed envelopes");
}
catch (Exception ex)
{
_errorMessage = ex.Message;
await JSRuntime.InvokeVoidAsync("console.error", "Fehler beim Laden der Umschläge:", ex.ToString());
}
finally
{
_isLoading = false;
await InvokeAsync(StateHasChanged);
}
}
async Task RefreshEnvelopes()
{
await LoadEnvelopesAsync();
}
void CreateEnvelope()
{
Navigation.NavigateTo("/sender/editor");
}
void EditEnvelope()
{
if (_selectedEnvelope == null) return;
// TODO: Navigate to envelope editor
JSRuntime.InvokeVoidAsync("console.log", $"Edit envelope {_selectedEnvelope.Id} clicked - not yet implemented");
}
void DeleteEnvelope()
{
if (_selectedEnvelope == null) return;
// TODO: Show delete confirmation dialog
JSRuntime.InvokeVoidAsync("console.log", $"Delete envelope {_selectedEnvelope.Id} clicked - not yet implemented");
}
async Task LogoutAsync()
{
_isLoggingOut = true;
await InvokeAsync(StateHasChanged);
await AuthService.LogoutSenderAsync();
Navigation.NavigateTo("/sender/login", forceLoad: true);
}
bool IsEnvelopeSent(EnvelopeDto envelope)
{
var status = (EnvelopeStatus)envelope.Status;
return status >= EnvelopeStatus.EnvelopeQueued;
}
(string Label, string CssClass, string DotColor) GetStatusInfo(EnvelopeStatus status)
{
return status switch
{
EnvelopeStatus.EnvelopePartlySigned => ("Teilweise unterschrieben", "partly-signed", "green"),
EnvelopeStatus.EnvelopeQueued => ("In Warteschlange", "queued", "orange"),
EnvelopeStatus.EnvelopeSent => ("Gesendet", "sent", "orange"),
EnvelopeStatus.EnvelopeCompletelySigned => ("Vollständig unterschrieben", "completed", "green"),
EnvelopeStatus.EnvelopeDeleted => ("Gelöscht", "deleted", "red"),
EnvelopeStatus.EnvelopeRejected => ("Abgelehnt", "rejected", "red"),
EnvelopeStatus.EnvelopeWithdrawn => ("Zurückgezogen", "withdrawn", "red"),
EnvelopeStatus.EnvelopeCreated => ("Erstellt", "created", "blue"),
EnvelopeStatus.EnvelopeSaved => ("Gespeichert", "saved", "blue"),
_ => ("Unbekannt", "unknown", "blue")
};
}
void OnCustomizeElement(GridCustomizeElementEventArgs e)
{
// Future: Add custom row coloring based on status if needed
}
void OnSelectedEnvelopeChanged(object envelope)
{
_selectedEnvelope = envelope as EnvelopeDto;
}
}

View File

@@ -1,6 +1,7 @@
@page "/"
@rendermode InteractiveWebAssembly
@inject IJSRuntime JS
@inject NavigationManager Navigation
@rendermode InteractiveWebAssembly
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
@@ -26,7 +27,18 @@
<span id="home-description"></span>
</p>
<div class="mt-4 pt-3 border-top">
<div class="mt-4 pt-3 border-top d-flex flex-column align-items-center gap-4">
<button class="home-btn-primary btn px-4 py-2 d-flex align-items-center gap-2" @onclick='() => Navigation.NavigateTo("/sender")'>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
</svg>
Zur Umschlag-Übersicht
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
</svg>
</button>
<div class="d-flex flex-wrap justify-content-center gap-3">
<div class="home-feature-badge">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
@@ -48,6 +60,7 @@
PDF-Export
</div>
</div>
</div>
</div>
@@ -59,8 +72,8 @@
@code {
private const string HomePageDescription =
"Das digitale Unterschriftenportal ist eine Plattform, die entwickelt wurde, um Ihre Dokumente sicher zu unterschreiben und zu verwalten. " +
"Mit seiner benutzerfreundlichen Oberfläche können Sie Ihre Dokumente schnell hochladen, die Unterschriftsprozesse verfolgen und Ihre digitalen Unterschriftenanwendungen einfach durchführen. " +
"Dieses Portal beschleunigt Ihren Arbeitsablauf mit rechtlich gültigen Unterschriften und erhöht gleichzeitig die Sicherheit Ihrer Dokumente.";
"Mit seiner benutzerfreundlichen Oberfläche können Sie Ihre Dokumente schnell hochladen, die Unterschriftsprozesse verfolgen und Ihre digitalen Unterschriftenanwendungen einfach durchführen. " +
"Dieses Portal beschleunigt Ihren Arbeitsablauf mit rechtlich gültigen Unterschriften und erhöht gleichzeitig die Sicherheit Ihrer Dokumente.";
protected override async Task OnAfterRenderAsync(bool firstRender)
{

View File

@@ -32,6 +32,7 @@ builder.Services.AddScoped<EnvelopeReceiverService>();
builder.Services.AddScoped<SignatureService>();
builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddSingleton<AppVersionService>();
builder.Services.AddScoped<EnvelopeService>();
// DevExpress WASM
builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer();

View File

@@ -1,5 +1,4 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
namespace EnvelopeGenerator.Server.Client.Services;
@@ -10,6 +9,7 @@ public enum SenderLoginResult { Success, InvalidCredentials, Error }
public class AuthService(IHttpClientFactory httpClientFactory)
{
private HttpClient CreateDefaultClient() => httpClientFactory.CreateClient("EnvelopeGenerator.Server");
/// <summary>
/// Checks whether the current user holds a valid receiver token for the given envelope key.
@@ -17,11 +17,22 @@ public class AuthService(IHttpClientFactory httpClientFactory)
/// </summary>
public async Task<bool> CheckEnvelopeAccessAsync(string envelopeKey, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
using var http = CreateDefaultClient();
var response = await http.GetAsync($"/api/auth/check/envelope/{Uri.EscapeDataString(envelopeKey)}", cancel);
return response.StatusCode == HttpStatusCode.OK;
}
/// <summary>
/// Checks whether the current user holds a valid receiver token for the given envelope key.
/// Calls GET /api/auth/check/envelope/{envelopeKey}.
/// </summary>
public async Task<bool> CheckSenderAccessAsync(CancellationToken cancel = default)
{
using var http = CreateDefaultClient();
var response = await http.GetAsync($"/api/auth/check", cancel);
return response.StatusCode == HttpStatusCode.OK;
}
/// <summary>
/// Submits the access code for the given envelope key.
/// Calls POST /api/Auth/envelope-receiver/{key} with multipart/form-data.
@@ -29,9 +40,11 @@ public class AuthService(IHttpClientFactory httpClientFactory)
/// </summary>
public async Task<EnvelopeLoginResult> LoginEnvelopeReceiverAsync(string envelopeKey, string accessCode, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var form = new MultipartFormDataContent();
form.Add(new StringContent(accessCode), "AccessCode");
using var http = CreateDefaultClient();
var form = new MultipartFormDataContent
{
{ new StringContent(accessCode), "AccessCode" }
};
var response = await http.PostAsync(
$"/api/Auth/envelope-receiver/{Uri.EscapeDataString(envelopeKey)}",
@@ -52,13 +65,26 @@ public class AuthService(IHttpClientFactory httpClientFactory)
/// </summary>
public async Task<bool> LogoutEnvelopeReceiverAsync(string envelopeKey, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
using var http = CreateDefaultClient();
var response = await http.PostAsync(
$"/api/auth/logout/envelope/{Uri.EscapeDataString(envelopeKey)}",
null, cancel);
return response.IsSuccessStatusCode;
}
/// <summary>
/// Removes the per-envelope receiver cookie for the given envelope key.
/// Calls POST /api/auth/logout/envelope/{envelopeKey}.
/// </summary>
public async Task<bool> LogoutSenderAsync(CancellationToken cancel = default)
{
using var http = CreateDefaultClient();
var response = await http.PostAsync(
$"/api/auth/logout",
null, cancel);
return response.IsSuccessStatusCode;
}
/// <summary>
/// Authenticates a sender user with username and password.
/// Calls POST /api/auth?cookie=true with JSON body.
@@ -66,7 +92,7 @@ public class AuthService(IHttpClientFactory httpClientFactory)
/// </summary>
public async Task<SenderLoginResult> LoginSenderAsync(string username, string password, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
using var http = CreateDefaultClient();
var requestBody = new { username, password };
var response = await http.PostAsJsonAsync(

View File

@@ -0,0 +1,74 @@
using System.Globalization;
using Microsoft.JSInterop;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Service for managing application culture/localization.
/// </summary>
public class CultureService
{
private readonly IJSRuntime _jsRuntime;
private const string CULTURE_KEY = "AppCulture";
public CultureService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
/// <summary>
/// Gets the list of supported cultures.
/// </summary>
public static CultureInfo[] SupportedCultures { get; } = new[]
{
new CultureInfo("de-DE"),
new CultureInfo("en-US"),
new CultureInfo("fr-FR")
};
/// <summary>
/// Sets the application culture and stores it in localStorage.
/// </summary>
public async Task SetCultureAsync(string culture)
{
if (!SupportedCultures.Any(c => c.Name == culture))
throw new ArgumentException($"Culture '{culture}' is not supported.", nameof(culture));
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", CULTURE_KEY, culture);
}
/// <summary>
/// Gets the stored culture from localStorage.
/// </summary>
public async Task<string?> GetCultureAsync()
{
try
{
return await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", CULTURE_KEY);
}
catch
{
return null;
}
}
/// <summary>
/// Initializes the culture from localStorage or browser settings.
/// </summary>
public async Task<CultureInfo> InitializeCultureAsync()
{
var storedCulture = await GetCultureAsync();
if (!string.IsNullOrEmpty(storedCulture) &&
SupportedCultures.Any(c => c.Name == storedCulture))
{
return new CultureInfo(storedCulture);
}
// Fallback to browser culture or default
var browserCulture = CultureInfo.CurrentCulture.Name;
var matchedCulture = SupportedCultures.FirstOrDefault(c => c.Name == browserCulture);
return matchedCulture ?? SupportedCultures[0]; // Default to German
}
}

View File

@@ -0,0 +1,24 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Server.Client.Models;
namespace EnvelopeGenerator.Server.Client.Services;
public class DocReceiverElementService(IHttpClientFactory clientFactory)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
public async Task<IReadOnlyList<SignatureDto>> GetAsync(string envelopeKey, CancellationToken cancel = default)
{
var url = $"/api/DocReceiverElement/{Uri.EscapeDataString(envelopeKey)}";
var http = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.GetAsync(url, cancel);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"Failed to retrieve signatures for envelope {envelopeKey}: {response.StatusCode} {response.ReasonPhrase}");
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
return result ?? [];
}
}

View File

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

View File

@@ -0,0 +1,64 @@
using EnvelopeGenerator.Application.Common.Dto;
using Microsoft.AspNetCore.WebUtilities;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Retrieves <see cref="EnvelopeDto"/>s from the API.
/// </summary>
public class EnvelopeService(IHttpClientFactory clientFactory)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
/// <summary>
/// Fetches envelopes from the API with optional filters.
/// </summary>
/// <exception cref="HttpRequestException">Thrown when the API request fails.</exception>
public async Task<IEnumerable<EnvelopeDto>?> GetAsync(
int? id = null,
string? uuid = null,
bool? onlyActive = null,
bool? onlyCompleted = null,
CancellationToken cancel = default)
{
var baseUrl = $"/api/Envelope";
var queryParams = new Dictionary<string, string?>();
if (id.HasValue)
{
queryParams["Id"] = id.Value.ToString();
}
if (!string.IsNullOrEmpty(uuid))
{
queryParams["Uuid"] = uuid;
}
if (onlyActive.HasValue)
{
queryParams["OnlyActive"] = onlyActive.Value.ToString();
}
if (onlyCompleted.HasValue)
{
queryParams["OnlyCompleted"] = onlyCompleted.Value.ToString();
}
var url = QueryHelpers.AddQueryString(baseUrl, queryParams);
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await httpClient.GetAsync(url, cancel);
if (!response.IsSuccessStatusCode)
{
var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
throw new HttpRequestException(
$"Failed to load envelopes. Status: {statusCode} ({reasonPhrase})",
null,
response.StatusCode);
}
return await response.Content.ReadFromJsonAsync<IEnumerable<EnvelopeDto>>(_jsonOptions, cancel);
}
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
@page "/envelope/DxPdfViewer"
@rendermode InteractiveServer
@using System.IO
@using DevExpress.Blazor
@using System.Reflection
@using DevExpress.Blazor.PdfViewer

View File

@@ -40,17 +40,11 @@ public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions,
/// <response code="401">Wenn es kein zugelassenes Cookie gibt, wird „nicht zugelassen“ zurückgegeben.</response>
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpPost("logout")]
public async Task<IActionResult> Logout()
public IActionResult Logout()
{
if (await this.IsUserInPolicyAsync(AuthPolicy.Sender))
Response.Cookies.Delete(authTokenKeys.Cookie);
else if (await this.IsUserInPolicyAsync(AuthPolicy.ReceiverOrReceiverTFA))
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
else
return Unauthorized();
Response.Cookies.Delete(authTokenKeys.Cookie);
return Ok();
}
@@ -69,7 +63,7 @@ public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions,
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[HttpGet("check")]
[Authorize]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public IActionResult Check(string? role = null)
=> role is not null && !User.IsInRole(role)
? Unauthorized()

View File

@@ -30,35 +30,16 @@ public class DocumentController(IMediator mediator, IAuthorizationService authSe
/// <param name="query">Encoded envelope key.</param>
/// <param name="cancel">Cancellation token.</param>
[HttpGet]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery? query = null)
{
// Sender: expects query with envelope key
if (await this.IsUserInPolicyAsync(AuthPolicy.Sender))
{
if (query is null)
return BadRequest("Missing document query.");
if (query is null)
return BadRequest("Missing document query.");
var senderDoc = await mediator.Send(query, cancel);
return senderDoc.ByteData is byte[] senderDocByte
? File(senderDocByte, "application/octet-stream")
: NotFound("Document is empty.");
}
// Receiver: resolve envelope id from claims
if (await this.IsUserInPolicyAsync(AuthPolicy.Receiver))
{
if (query is not null)
return BadRequest("Query parameters are not allowed for receiver role.");
var envelopeId = User.EnvelopeId();
var receiverDoc = await mediator.Send(new ReadDocumentQuery { EnvelopeId = envelopeId }, cancel);
return receiverDoc.ByteData is byte[] receiverDocByte
? File(receiverDocByte, "application/octet-stream")
: NotFound("Document is empty.");
}
return Unauthorized();
var senderDoc = await mediator.Send(query, cancel);
return senderDoc.ByteData is byte[] senderDocByte
? File(senderDocByte, "application/octet-stream")
: NotFound("Document is empty.");
}
/// <summary>

View File

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

View File

@@ -94,7 +94,7 @@ public class EnvelopeController : ControllerBase
/// <param name="command"></param>
/// <returns></returns>
[NonAction]
[Authorize]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpPost]
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
{

View File

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

View File

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

View File

@@ -1,9 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFrameworks>net8.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageId>EnvelopeGenerator.Server</PackageId>
<Title></Title>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>EnvelopeGenerator.Server</Product>
<Version>1.0.1-beta</Version>
<FileVersion>1.0.1.0</FileVersion>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
@@ -28,13 +39,12 @@
<PackageReference Include="DigitalData.Core.API" Version="2.2.1" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.11" />
<PackageReference Include="itext" Version="8.0.5" />
<PackageReference Include="itext.bouncy-castle-adapter" Version="8.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.17" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
<PackageReference Include="NLog" Version="5.2.5" />
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.0" />
<PackageReference Include="PDFsharp" Version="6.2.4" />
<PackageReference Include="Scalar.AspNetCore" Version="2.2.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />

View File

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

View File

@@ -1,5 +1,6 @@
namespace EnvelopeGenerator.Server.Models;
[Obsolete("Use auth DTO")]
public record Auth(string? AccessCode = null, string? SmsCode = null, string? AuthenticatorCode = null, bool UserSelectSMS = default)
{
public bool HasAccessCode => AccessCode is not null;

View File

@@ -10,6 +10,7 @@ using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Localization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using System.Globalization;
using Scalar.AspNetCore;
using Microsoft.OpenApi.Models;
@@ -53,7 +54,11 @@ try
.AddInteractiveWebAssemblyComponents();
// Add API Controllers
builder.Services.AddControllers();
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
});
builder.Services.AddHttpClient();
// YARP Reverse Proxy (for forwarding auth requests to AuthHub)
@@ -64,6 +69,9 @@ try
builder.Services.AddHttpContextAccessor();
// Named HttpClient for internal API calls
// SenderAuthCookieHandler forwards the browser's Cookie header so that
// Blazor Server components can call cookie-authenticated endpoints (AuthScheme.Sender).
builder.Services.AddTransient<EnvelopeGenerator.Server.Handlers.SenderAuthCookieHandler>();
builder.Services.AddHttpClient("EnvelopeGenerator.Server", (sp, client) =>
{
var httpContextAccessor = sp.GetRequiredService<IHttpContextAccessor>();
@@ -74,7 +82,8 @@ try
// Set base address to current host for SSR scenarios
client.BaseAddress = new Uri($"{request.Scheme}://{request.Host}");
}
});
})
.AddHttpMessageHandler<EnvelopeGenerator.Server.Handlers.SenderAuthCookieHandler>();
// CORS Policy
var allowedOrigins = config.GetSection("AllowedOrigins").Get<string[]>() ??
@@ -99,7 +108,7 @@ try
{
Version = "v1",
Title = "signFLOW Absender-API",
Description = "Eine API zur Verwaltung der Erstellung, des Versands und der Nachverfolgung von Umschlägen in der signFLOW-Anwendung.",
Description = "Eine API zur Verwaltung der Erstellung, des Versands und der Nachverfolgung von Umschl<EFBFBD>gen in der signFLOW-Anwendung.",
Contact = new OpenApiContact
{
Name = "Digital Data GmbH",
@@ -255,7 +264,6 @@ try
// Authorization Policies
builder.Services.AddAuthorizationBuilder()
.AddPolicy(AuthPolicy.SenderOrReceiver, policy => policy.RequireRole(Role.Sender, Role.Receiver.Full))
.AddPolicy(AuthPolicy.Sender, policy => policy
.RequireRole(Role.Sender)
.AddAuthenticationSchemes(AuthScheme.Sender))
@@ -319,13 +327,25 @@ try
builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddSingleton<AppVersionService>();
// EnvelopeService with HttpClient factory (for SSR scenarios)
builder.Services.AddScoped<EnvelopeService>();
// DocReceiverElementService (SignatureService alternative)
builder.Services.AddScoped<DocReceiverElementService>();
// SSR Authentication Service (for Envelope Receiver pages)
builder.Services.AddScoped<EnvelopeGenerator.Server.Services.IEnvelopeAuthService, EnvelopeGenerator.Server.Services.EnvelopeAuthService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService>();
// DevExpress Server-Side Services (CRITICAL for DxPdfViewer)
builder.Services.AddDevExpressBlazor();
builder.Services.AddDevExpressServerSideBlazorPdfViewer();
// PdfSharp font resolver — required for .NET 8 (no system font access without it)
PdfSharp.Fonts.GlobalFontSettings.FontResolver =
EnvelopeGenerator.Server.Services.PdfSharpFontResolver.Instance;
// Configuration Options
builder.Services.Configure<EnvelopeGenerator.Server.Client.Options.ApiOptions>(
builder.Configuration.GetSection("ApiOptions"));

View File

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

View File

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

View File

@@ -0,0 +1,93 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using DigitalData.Auth.Claims;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Server.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Server.Services;
/// <summary>
/// Authorizes receiver access for interactive server pages without calling a controller endpoint.
/// </summary>
public class EnvelopeReceiverAuthorizationService(
IHttpContextAccessor httpContextAccessor,
IAuthorizationService authorizationService,
IOptions<AuthTokenKeys> authTokenKeyOptions,
IOptionsMonitor<JwtBearerOptions> jwtBearerOptionsMonitor,
ILogger<EnvelopeReceiverAuthorizationService> logger)
{
private readonly AuthTokenKeys _authTokenKeys = authTokenKeyOptions.Value;
/// <summary>
/// Returns the authenticated receiver principal for the specified envelope key when authorization succeeds.
/// </summary>
public async Task<ClaimsPrincipal?> AuthorizeAsync(string envelopeKey, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(envelopeKey))
return null;
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null)
return null;
if (await IsAuthorizedReceiverAsync(httpContext.User, envelopeKey, cancellationToken))
return httpContext.User;
var cookieName = CookieNames.GetEnvelopeReceiverCookieName(_authTokenKeys.Cookie, envelopeKey);
if (!httpContext.Request.Cookies.TryGetValue(cookieName, out var token) || string.IsNullOrWhiteSpace(token))
{
logger.LogDebug("Receiver cookie '{CookieName}' was not found for envelope '{EnvelopeKey}'.", cookieName, envelopeKey);
return null;
}
var principal = ValidateReceiverToken(token);
if (principal is null)
return null;
if (!await IsAuthorizedReceiverAsync(principal, envelopeKey, cancellationToken))
return null;
httpContext.User = principal;
return principal;
}
/// <summary>
/// Checks whether the current request is authorized for the specified envelope key.
/// </summary>
public async Task<bool> IsAuthorizedAsync(string envelopeKey, CancellationToken cancellationToken = default)
=> await AuthorizeAsync(envelopeKey, cancellationToken) is not null;
private async Task<bool> IsAuthorizedReceiverAsync(ClaimsPrincipal? principal, string envelopeKey, CancellationToken cancellationToken)
{
if (principal?.Identity?.IsAuthenticated != true)
return false;
var authorizationResult = await authorizationService.AuthorizeAsync(principal, AuthPolicy.Receiver);
if (!authorizationResult.Succeeded)
return false;
var subject = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? principal.FindFirst("sub")?.Value;
return string.Equals(subject, envelopeKey, StringComparison.Ordinal);
}
private ClaimsPrincipal? ValidateReceiverToken(string token)
{
try
{
var tokenValidationParameters = jwtBearerOptionsMonitor.Get(AuthScheme.Receiver).TokenValidationParameters.Clone();
var tokenHandler = new JwtSecurityTokenHandler();
return tokenHandler.ValidateToken(token, tokenValidationParameters, out _);
}
catch (Exception ex)
{
logger.LogDebug(ex, "Receiver token validation failed.");
return null;
}
}
}

View File

@@ -0,0 +1,126 @@
using System.Security.Claims;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Receivers.Queries;
using EnvelopeGenerator.Server.Client.Models;
using EnvelopeGenerator.Server.Client.Models.Constants;
using EnvelopeGenerator.Server.Extensions;
using EnvelopeGenerator.Server.Options;
using MediatR;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using ApplicationEnvelopeReceiverDto = EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto;
namespace EnvelopeGenerator.Server.Services;
/// <summary>
/// Loads receiver page data directly from MediatR and distributed cache.
/// </summary>
public class EnvelopeReceiverPageDataService(
IMediator mediator,
IDistributedCache cache,
IOptions<CacheOptions> cacheOptions,
IMemoryCache memoryCache)
{
private const string SignatureCacheKeyPrefix = "envelope-generator.receiver-ui.signature:";
/// <summary>
/// Loads the PDF document bytes for the authenticated receiver.
/// </summary>
public async Task<byte[]?> GetDocumentAsync(ClaimsPrincipal user, CancellationToken cancellationToken = default)
{
var document = await mediator.Send(new ReadDocumentQuery(EnvelopeId: user.EnvelopeId()), cancellationToken);
return document.ByteData;
}
/// <summary>
/// Loads the current receiver's signature placeholders.
/// </summary>
public async Task<IReadOnlyList<SignatureDto>> GetSignaturesAsync(ClaimsPrincipal user, CancellationToken cancellationToken = default)
{
var receiverId = user.ReceiverId();
var document = await mediator.Send(new ReadDocumentQuery(EnvelopeId: user.EnvelopeId()), cancellationToken);
if (document.Elements is not IEnumerable<DocReceiverElementDto> elements)
return [];
var signatures = elements
.Where(element => element.ReceiverId == receiverId)
.Select(MapSignature)
.ToList();
return signatures.Convert(UnitOfLength.Point);
}
/// <summary>
/// Loads the envelope receiver data for the specified envelope key.
/// </summary>
public async Task<ApplicationEnvelopeReceiverDto?> GetEnvelopeReceiverAsync(string envelopeKey, CancellationToken cancellationToken = default)
{
var result = await mediator.Send(new ReadEnvelopeReceiverQuery { Key = envelopeKey }, cancellationToken);
return result.SingleOrDefault();
}
/// <summary>
/// Loads the cached signature for the authenticated receiver.
/// </summary>
public async Task<SignatureCaptureDto?> GetCachedSignatureAsync(ClaimsPrincipal user, CancellationToken cancellationToken = default)
{
var json = await cache.GetStringAsync(GetSignatureCacheKey(user), cancellationToken);
return json is null ? null : JsonSerializer.Deserialize<SignatureCaptureDto>(json);
}
/// <summary>
/// Saves the cached signature for the authenticated receiver.
/// </summary>
public async Task SaveCachedSignatureAsync(ClaimsPrincipal user, SignatureCaptureDto signature, CancellationToken cancellationToken = default)
{
var json = JsonSerializer.Serialize(signature);
var options = cacheOptions.Value.SignatureCacheExpiration.HasValue
? new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = cacheOptions.Value.SignatureCacheExpiration.Value }
: new DistributedCacheEntryOptions();
await cache.SetStringAsync(GetSignatureCacheKey(user), json, options, cancellationToken);
}
/// <summary>
/// Deletes the cached signature for the authenticated receiver.
/// </summary>
public Task DeleteCachedSignatureAsync(ClaimsPrincipal user, CancellationToken cancellationToken = default)
=> cache.RemoveAsync(GetSignatureCacheKey(user), cancellationToken);
private static string GetSignatureCacheKey(ClaimsPrincipal user)
=> $"{SignatureCacheKeyPrefix}{user.ReceiverSignature()}";
private static SignatureDto MapSignature(DocReceiverElementDto element) => new()
{
Id = element.Id,
X = element.X,
Y = element.Y,
Page = element.Page,
SenderAppType = (EnvelopeGenerator.Server.Client.Models.Constants.SenderAppType)element.SenderAppType
};
private static readonly string ReceiverEmailSearchCacheKey = Guid.NewGuid().ToString();
public async Task<IEnumerable<string>> SearchReceiverEMailsAsync(string emailSearchTerm, CancellationToken cancellationToken = default)
{
return await memoryCache.GetOrCreateAsync(ReceiverEmailSearchCacheKey + emailSearchTerm, async entry =>
{
var query = new ReadReceiverQuery { EmailAddressSearch = emailSearchTerm };
var receivers = await mediator.Send(query, cancellationToken);
if(receivers.Any())
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
else
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(10);
return receivers.Select(r => r.EmailAddress);
}) ?? [];
}
}

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,41 @@
overflow: auto;
}
.pdf-editor-wrapper {
width: 100%;
height: 100%;
}
.sender-editor-pdf-viewer {
width: 100%;
height: 100%;
}
.sender-editor-pdf-viewer .dxbl-toolbar {
justify-content: center;
}
.sender-editor-pdf-viewer .dxbl-toolbar-left {
margin-inline: auto;
}
.sender-editor-pdf-viewer .dxbrv-document-surface {
display: flex;
flex-direction: column;
align-items: center;
}
.sender-editor-pdf-viewer .dxbrv-report-preview-content-flex-item {
width: 100%;
display: flex;
justify-content: center;
}
.sender-editor-pdf-viewer .dxbrv-report-preview-content {
margin-left: auto;
margin-right: auto;
}
.pdf-viewer-container {
height: 100%;
display: flex;
@@ -546,6 +581,285 @@ body.resizing {
white-space: nowrap;
}
.sender-toolbar-action-btn {
min-width: auto;
padding: 0.5rem 0.75rem;
}
.sender-toolbar-action-btn--compact {
padding: 0.45rem 0.7rem;
}
.sender-receivers-panel {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.75rem 0.9rem;
border-radius: 12px;
background: linear-gradient(135deg, rgba(126, 34, 206, 0.05) 0%, rgba(42, 82, 152, 0.05) 100%);
border: 1px solid rgba(126, 34, 206, 0.12);
}
.sender-receivers-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.sender-receivers-panel__title-wrap {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.sender-receivers-panel__title {
font-size: 0.8rem;
font-weight: 700;
color: #4c1d95;
letter-spacing: 0.02em;
}
.sender-receivers-panel__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
min-height: 1.5rem;
padding: 0 0.45rem;
border-radius: 999px;
background: rgba(79, 70, 229, 0.12);
color: #5b21b6;
font-size: 0.72rem;
font-weight: 700;
}
.sender-receivers-panel__add-btn .dxbl-btn {
border-radius: 8px;
font-weight: 600;
}
.pdf-toolbar-like-btn .dxbl-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
min-height: 34px;
padding: 0.45rem 0.85rem;
border-radius: 8px;
border: 1px solid rgba(126, 34, 206, 0.2);
background: linear-gradient(135deg, rgba(126, 34, 206, 0.05) 0%, rgba(42, 82, 152, 0.05) 100%);
color: #1e293b;
font-size: 0.75rem;
font-weight: 600;
box-shadow: none;
transition: all 0.2s ease;
}
.pdf-toolbar-like-btn .dxbl-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(126, 34, 206, 0.1) 0%, rgba(42, 82, 152, 0.1) 100%);
border-color: rgba(126, 34, 206, 0.4);
color: #1e293b;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(126, 34, 206, 0.2);
}
.pdf-toolbar-like-btn .dxbl-btn:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(126, 34, 206, 0.15);
}
.pdf-toolbar-like-btn--add .dxbl-btn::before,
.pdf-toolbar-like-btn--signature .dxbl-btn::before {
display: inline-block;
font-size: 0.9rem;
line-height: 1;
font-weight: 700;
}
.pdf-toolbar-like-btn--add .dxbl-btn::before {
content: '+';
color: #7e22ce;
}
.pdf-toolbar-like-btn--signature .dxbl-btn {
color: #7e22ce;
}
.pdf-toolbar-like-btn--signature .dxbl-btn::before {
content: '?';
color: #7e22ce;
}
.pdf-toolbar-like-btn--signature .dxbl-btn:hover:not(:disabled) {
color: #7e22ce;
}
.sender-receivers-panel__empty {
font-size: 0.78rem;
color: #64748b;
}
.sender-receivers-list {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
}
.sender-receiver-chip {
display: inline-flex;
align-items: center;
gap: 0.75rem;
min-width: 220px;
max-width: 100%;
padding: 0.625rem 0.75rem;
border-radius: 12px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(126, 34, 206, 0.12);
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06);
}
.sender-receiver-chip__body {
min-width: 0;
flex: 1;
}
.sender-receiver-chip__name {
font-size: 0.78rem;
font-weight: 700;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sender-receiver-chip__email {
margin-top: 0.15rem;
font-size: 0.72rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sender-receiver-chip__phone {
margin-top: 0.15rem;
font-size: 0.72rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sender-receiver-popup .dxbl-modal {
border-radius: 18px;
}
.sender-receiver-popup .dxbl-popup {
max-width: min(720px, calc(100vw - 2rem));
}
.sender-receiver-popup .dxbl-popup-content {
padding: 1rem 1.25rem 1.1rem;
}
.sender-receiver-popup .dxbl-popup-header {
padding: 0.95rem 1.25rem;
}
.sender-receiver-popup .dxbl-popup-footer {
padding: 0.75rem 1.25rem 1rem;
}
.sender-receiver-popup__body {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.sender-receiver-popup__form-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 1rem 1.25rem;
align-items: start;
min-height: 250px;
}
.sender-receiver-popup__field {
min-width: 0;
}
.sender-receiver-popup__label {
margin-bottom: 0.45rem;
font-size: 0.82rem;
font-weight: 600;
color: #475569;
}
.sender-receiver-popup__field .dxbl-text-edit,
.sender-receiver-popup__field .dxbl-dropdown-edit {
width: 100%;
}
.sender-receiver-popup__field .dxbl-input-editor,
.sender-receiver-popup__field .dxbl-text-edit-input {
min-height: 38px;
}
.sender-receiver-popup__suggestions-shell {
min-height: 188px;
margin-top: 0.5rem;
}
.sender-receiver-popup__suggestions {
border: 1px solid rgba(126, 34, 206, 0.12);
border-radius: 10px;
background: rgba(255, 255, 255, 0.98);
overflow: hidden;
}
.sender-receiver-popup__suggestions .dxbl-listbox {
border: none;
}
.sender-receiver-popup__suggestions .dxbl-listbox-scroll-viewer {
max-height: 180px;
}
.sender-receiver-popup__hint {
font-size: 0.78rem;
color: #64748b;
}
.sender-receiver-popup__loading {
font-size: 0.78rem;
color: #4f46e5;
font-weight: 600;
}
.sender-receiver-popup__validation {
padding: 0.625rem 0.75rem;
border-radius: 10px;
background: rgba(239, 68, 68, 0.08);
color: #b91c1c;
font-size: 0.8rem;
font-weight: 600;
}
.sender-receiver-popup__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
width: 100%;
}
.sender-receiver-popup__footer .dxbl-btn {
min-width: 148px;
border-radius: 8px;
font-weight: 600;
}
.pdf-frame {
background: white;
border-radius: 16px;
@@ -748,6 +1062,30 @@ body.resizing {
flex-wrap: wrap;
}
.sender-receivers-panel {
padding: 0.625rem 0.75rem;
}
.sender-receiver-chip {
width: 100%;
min-width: 0;
flex-wrap: wrap;
}
.sender-receiver-chip__action {
width: 100%;
}
.sender-receiver-chip__action {
width: 100%;
}
.sender-receiver-popup__form-grid {
grid-template-columns: 1fr;
gap: 0.85rem;
min-height: 0;
}
.envelope-title {
font-size: 1rem;
}

View File

@@ -0,0 +1,297 @@
.sender-dashboard-layout {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 50%, #7e22ce 100%);
}
.sender-action-bar {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-bottom: 3px solid rgba(126, 34, 206, 0.3);
padding: 1rem 2rem;
flex-shrink: 0;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.sender-action-bar__inner {
max-width: 1600px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
.sender-title-section {
display: flex;
align-items: center;
gap: 1rem;
}
.sender-logo svg {
filter: drop-shadow(0 2px 4px rgba(126, 34, 206, 0.3));
color: #7e22ce;
}
.sender-title {
font-size: 1.25rem;
font-weight: 700;
color: #1e293b;
letter-spacing: -0.025em;
}
.sender-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.sender-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.125rem;
background: linear-gradient(135deg, rgba(126, 34, 206, 0.05) 0%, rgba(42, 82, 152, 0.05) 100%);
border: 1px solid rgba(126, 34, 206, 0.2);
border-radius: 8px;
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.sender-btn:hover:not(:disabled) {
background: linear-gradient(135deg, rgba(126, 34, 206, 0.1) 0%, rgba(42, 82, 152, 0.1) 100%);
border-color: rgba(126, 34, 206, 0.4);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(126, 34, 206, 0.2);
}
.sender-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
background: rgba(0, 0, 0, 0.02);
border-color: rgba(0, 0, 0, 0.1);
}
.sender-btn--primary {
background: linear-gradient(135deg, #7e22ce 0%, #2a5298 100%);
border-color: transparent;
color: white;
}
.sender-btn--primary:hover:not(:disabled) {
background: linear-gradient(135deg, #6b1cb0 0%, #1e3a72 100%);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(126, 34, 206, 0.3);
}
.sender-btn--danger {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.08) 100%);
border-color: rgba(239, 68, 68, 0.3);
color: #dc2626;
}
.sender-btn--danger:hover:not(:disabled) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border-color: transparent;
color: white;
}
.sender-btn--logout {
padding: 0.5rem;
min-width: 38px;
}
.sender-content {
flex: 1;
min-height: 0;
padding: 1.5rem;
position: relative;
overflow: auto;
}
.sender-grid-container {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(20px);
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.1);
overflow: hidden;
position: relative;
max-width: 1600px;
margin: 0 auto;
}
.sender-grid-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #7e22ce 0%, #2a5298 100%);
z-index: 1;
border-radius: 16px 16px 0 0;
}
.sender-tabs {
display: flex;
border-bottom: 2px solid rgba(126, 34, 206, 0.1);
padding: 0 2rem;
background: rgba(126, 34, 206, 0.02);
}
.sender-tab {
padding: 1rem 1.5rem;
font-size: 0.875rem;
font-weight: 600;
color: #6b7280;
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.sender-tab:hover {
color: #7e22ce;
background: rgba(126, 34, 206, 0.05);
}
.sender-tab--active {
color: #7e22ce;
border-bottom-color: #7e22ce;
background: white;
}
.sender-grid-wrapper {
padding: 1.5rem 2rem 2rem;
}
/* Hide DevExpress empty cells */
.dxbl-grid-empty-cell {
display: none !important;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.status-badge--partly-signed,
.status-badge--completed {
background: rgba(129, 199, 132, 0.15);
color: #2e7d32;
}
.status-badge--queued,
.status-badge--sent {
background: rgba(255, 183, 77, 0.15);
color: #e65100;
}
.status-badge--deleted,
.status-badge--rejected,
.status-badge--withdrawn {
background: rgba(229, 115, 115, 0.15);
color: #c62828;
}
.status-badge--created,
.status-badge--saved {
background: rgba(100, 181, 246, 0.15);
color: #1565c0;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-dot--green {
background: #81c784;
}
.status-dot--orange {
background: #ffb74d;
}
.status-dot--red {
background: #e57373;
}
.status-dot--blue {
background: #64b5f6;
}
.receiver-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
background: #f3f4f6;
border-radius: 4px;
font-size: 0.75rem;
color: #374151;
white-space: nowrap;
}
.receiver-badge--signed {
background: rgba(129, 199, 132, 0.15);
color: #2e7d32;
}
.receiver-badge--unsigned {
background: rgba(229, 115, 115, 0.15);
color: #c62828;
}
@@media (max-width: 768px) {
.sender-action-bar {
padding: 1rem 1.25rem;
}
.sender-action-bar__inner {
flex-wrap: wrap;
}
.sender-toolbar {
width: 100%;
justify-content: flex-start;
}
.sender-title {
font-size: 1.125rem;
}
.sender-content {
padding: 0.75rem;
}
.sender-grid-wrapper {
padding: 1rem;
}
.sender-tabs {
padding: 0 1rem;
overflow-x: auto;
}
.sender-tab {
padding: 0.875rem 1rem;
font-size: 0.813rem;
}
}

View File

@@ -0,0 +1,95 @@
window.envelopeEditor = {
/**
* Returns the click position normalised to [0,1] relative to the rendered PDF page
* element inside DxPdfViewer (or DxReportViewer as fallback).
*
* Normalising means the result is independent of zoom level: no matter how much the
* user has zoomed in/out, the same physical spot on the PDF will always yield the same
* normalised value. C# multiplies by the page's point dimensions to get PDF points.
*
* @param {string} viewerCssClass - CssClass set on DxPdfViewer (e.g. "sender-editor-pdf-viewer")
* @param {number} clientX - MouseEvent.clientX from Blazor
* @param {number} clientY - MouseEvent.clientY from Blazor
* @returns {{ normX, normY, pageIndex } | null}
* normX / normY : 0..1 fraction within the page element
* pageIndex : 0-based index of the page the click landed on (-1 if not found)
*/
getClickCoordsOnPdfPage: function (viewerCssClass, clientX, clientY) {
// Find the viewer root element
const viewer = document.querySelector('.' + viewerCssClass);
if (!viewer) {
console.warn('[envelopeEditor] viewer not found for class:', viewerCssClass);
return null;
}
// --- Candidate page elements (ordered by preference) ---
// DxPdfViewer renders individual pages as .dxbl-pdfv-page elements.
// DxReportViewer uses .dxbrv-report-preview-content-img as fallback.
const pageSelectors = [
'.dxbl-pdfv-page',
'.dxbrv-report-preview-page',
'.dxbrv-report-preview-content-img',
];
let allPages = [];
for (const sel of pageSelectors) {
const found = Array.from(viewer.querySelectorAll(sel));
if (found.length > 0) {
allPages = found;
break;
}
}
if (allPages.length === 0) {
console.warn('[envelopeEditor] no page elements found inside viewer');
return null;
}
// --- Find which page the click landed on ---
// Walk through all pages; pick the one whose bounding rect contains the click.
// If none contains it exactly, fall back to the page closest vertically.
let targetPage = null;
let targetIndex = -1;
let minDist = Infinity;
for (let i = 0; i < allPages.length; i++) {
const rect = allPages[i].getBoundingClientRect();
// Exact hit
if (clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom) {
targetPage = allPages[i];
targetIndex = i;
break;
}
// Track closest page (vertical centre distance) as fallback
const cy = rect.top + rect.height / 2;
const dist = Math.abs(clientY - cy);
if (dist < minDist) {
minDist = dist;
targetPage = allPages[i];
targetIndex = i;
}
}
if (!targetPage) return null;
const pageRect = targetPage.getBoundingClientRect();
// Clamp click inside page boundaries before normalising
const clampedX = Math.max(pageRect.left, Math.min(clientX, pageRect.right));
const clampedY = Math.max(pageRect.top, Math.min(clientY, pageRect.bottom));
const normX = (clampedX - pageRect.left) / pageRect.width;
const normY = (clampedY - pageRect.top) / pageRect.height;
return {
normX: normX,
normY: normY,
pageIndex: targetIndex
};
}
};

View File

@@ -30,7 +30,7 @@
"auth-hub": {
"Destinations": {
"primary": {
"Address": "https://localhost:9090"
"Address": "http://172.24.12.39:9090"
}
}
}

View File

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

View File

@@ -23,7 +23,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{134D4164-B29
ProjectSection(SolutionItems) = preProject
COPILOT_CONTEXT.md = COPILOT_CONTEXT.md
FORM_APPLICATION_CONTEXT.md = FORM_APPLICATION_CONTEXT.md
OPEN_SSR_TASK.md = OPEN_SSR_TASK.md
RECEIVER_PDF_VIEWER_CONTEXT.md = RECEIVER_PDF_VIEWER_CONTEXT.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0CBC2432-A561-4440-89BC-671B66A24146}"
@@ -45,6 +45,9 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.ReceiverUI", "EnvelopeGenerator.ReceiverUI\EnvelopeGenerator.ReceiverUI.csproj", "{FB2D306B-1042-4A70-31ED-F991A1599371}"
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
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj", "{4E6C54DA-576D-0955-2564-9EC890BB8279}"
EndProject

View File

@@ -1,553 +0,0 @@
# SSR Authentication Migration — Implementation Notes
## Overview
Migration from WASM client-side authentication to SSR (Server-Side Rendering) authentication for `EnvelopeReceiverPage.razor` to fix authentication issues in Blazor InteractiveServer mode.
---
## Problem Statement
### Issue
`EnvelopeReceiverPage.razor` uses `@rendermode InteractiveServer` but was calling **WASM client service** `AuthService.CheckEnvelopeAccessAsync()`:
```razor
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
var hasAccess = await AuthService.CheckEnvelopeAccessAsync(EnvelopeKey);
```
**Why This Failed:**
- `AuthService` is a **WASM client service** that uses `IHttpClientFactory`
- In SSR context, `HttpContext` is required to configure the base address
- `CheckEnvelopeAccessAsync()` makes an HTTP request to `/api/auth/check/envelope/{key}`
- This request **goes to itself** (server calling its own endpoint), causing issues
- Returns `false` even when user is authenticated
---
## Solution Architecture
### Created New SSR Authentication Service
**Files Created:**
1. `EnvelopeGenerator.Server/Services/IEnvelopeAuthService.cs` (Interface)
2. `EnvelopeGenerator.Server/Services/EnvelopeAuthService.cs` (Implementation)
**Purpose:** Direct `HttpContext.User` validation without HTTP requests
---
## Implementation Details
### 1. IEnvelopeAuthService Interface
**Location:** `EnvelopeGenerator.Server/Services/IEnvelopeAuthService.cs`
```csharp
namespace EnvelopeGenerator.Server.Services;
public interface IEnvelopeAuthService
{
/// <summary>
/// Checks if the current user is authenticated for the given envelope key.
/// Validates both that the user is authenticated AND that the envelope key matches their claims.
/// </summary>
bool IsAuthenticated(string envelopeKey);
/// <summary>
/// Gets the authenticated envelope key from the current user's claims (NameIdentifier or "sub" claim).
/// </summary>
string? GetAuthenticatedEnvelopeKey();
/// <summary>
/// Gets the current HttpContext user principal.
/// </summary>
ClaimsPrincipal? GetCurrentUser();
}
```
**Key Methods:**
- `IsAuthenticated(string envelopeKey)`: Validates user auth + envelope key match
- `GetAuthenticatedEnvelopeKey()`: Extracts envelope key from claims
- `GetCurrentUser()`: Returns `ClaimsPrincipal` for advanced scenarios
---
### 2. EnvelopeAuthService Implementation
**Location:** `EnvelopeGenerator.Server/Services/EnvelopeAuthService.cs`
**Dependencies:**
- `IHttpContextAccessor`: Access current HTTP context
- `ILogger<EnvelopeAuthService>`: Structured logging
**Logic:**
```csharp
public bool IsAuthenticated(string envelopeKey)
{
// 1. Validate envelope key parameter
if (string.IsNullOrWhiteSpace(envelopeKey))
return false;
// 2. Get HttpContext
var context = _httpContextAccessor.HttpContext;
// 3. Check if user is authenticated
if (context?.User?.Identity?.IsAuthenticated != true)
return false;
// 4. Extract envelope key from claims
var sub = GetEnvelopeKeyFromClaims(context.User);
// 5. Verify match
return sub == envelopeKey;
}
private string? GetEnvelopeKeyFromClaims(ClaimsPrincipal user)
{
// Try standard claim first
var sub = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
// Fallback to JWT "sub" claim
if (string.IsNullOrWhiteSpace(sub))
sub = user.FindFirst("sub")?.Value;
return sub;
}
```
**Claim Priority:**
1. `ClaimTypes.NameIdentifier` (standard .NET claim)
2. `"sub"` (JWT standard claim)
---
### 3. Service Registration
**Location:** `EnvelopeGenerator.Server/Program.cs`
**Added:**
```csharp
// SSR Authentication Service (for Envelope Receiver pages)
builder.Services.AddScoped<EnvelopeGenerator.Server.Services.IEnvelopeAuthService,
EnvelopeGenerator.Server.Services.EnvelopeAuthService>();
```
**Lifetime:** `Scoped` (per-request, matches `IHttpContextAccessor`)
---
### 4. EnvelopeReceiverPage.razor Changes
**Changes Made (REVERTED - To Be Re-Applied):**
#### 4.1 Using Statements
```razor
@using EnvelopeGenerator.Server.Services
```
#### 4.2 Dependency Injection
**Old:**
```razor
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
```
**New:**
```razor
@inject IEnvelopeAuthService EnvelopeAuth
@inject IHttpClientFactory HttpClientFactory
```
#### 4.3 Authentication Check in `OnInitializedAsync()`
**Old:**
```csharp
var hasAccess = await AuthService.CheckEnvelopeAccessAsync(EnvelopeKey);
if (!hasAccess) {
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
return;
}
```
**New:**
```csharp
// ? SSR Authentication check via service
if (!EnvelopeAuth.IsAuthenticated(EnvelopeKey)) {
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
return;
}
```
**Benefits:**
- ? Synchronous (no HTTP overhead)
- ? Direct `HttpContext.User` access
- ? No self-referencing HTTP calls
- ? Works in SSR context
#### 4.4 Logout Method
**Old:**
```csharp
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
```
**New:**
```csharp
try
{
// ? SSR: Direct HTTP call instead of WASM client service
using var http = HttpClientFactory.CreateClient("EnvelopeGenerator.Server");
await http.PostAsync($"/api/auth/logout/envelope/{Uri.EscapeDataString(EnvelopeKey)}", null);
}
catch (Exception ex)
{
logger.LogError(ex, "Logout failed for envelope {EnvelopeKey}", EnvelopeKey);
}
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
```
**Why Changed:**
- WASM `AuthService.LogoutEnvelopeReceiverAsync()` doesn't work in SSR
- Use named HttpClient `"EnvelopeGenerator.Server"` (configured in `Program.cs`)
- Graceful error handling (logout errors shouldn't block redirect)
---
## Remaining Tasks
### ? Completed
1. ? Created `IEnvelopeAuthService` interface
2. ? Implemented `EnvelopeAuthService` with `HttpContext` access
3. ? Registered service in `Program.cs`
4. ?? **REVERTED** `EnvelopeReceiverPage.razor` changes (merge conflict)
### ? TODO (Next Agent)
#### 1. Re-apply EnvelopeReceiverPage.razor Changes
**File:** `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage.razor`
**Steps:**
1. Add using statement:
```razor
@using EnvelopeGenerator.Server.Services
```
2. Replace injection:
```razor
@inject IEnvelopeAuthService EnvelopeAuth
@inject IHttpClientFactory HttpClientFactory
```
Remove:
```razor
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
```
3. Update `OnInitializedAsync()` authentication check:
```csharp
// Replace this:
var hasAccess = await AuthService.CheckEnvelopeAccessAsync(EnvelopeKey);
if (!hasAccess) {
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
return;
}
// With this:
if (!EnvelopeAuth.IsAuthenticated(EnvelopeKey)) {
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
return;
}
```
4. Update `LogoutAsync()` method:
```csharp
async Task LogoutAsync() {
if (string.IsNullOrWhiteSpace(EnvelopeKey) || _isLoggingOut) return;
_isLoggingOut = true;
await InvokeAsync(StateHasChanged);
try
{
// ? SSR: Direct HTTP call instead of WASM client service
using var http = HttpClientFactory.CreateClient("EnvelopeGenerator.Server");
await http.PostAsync($"/api/auth/logout/envelope/{Uri.EscapeDataString(EnvelopeKey)}", null);
}
catch (Exception ex)
{
logger.LogError(ex, "Logout failed for envelope {EnvelopeKey}", EnvelopeKey);
}
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
}
```
#### 2. Test Authentication Flow
**Scenarios:**
- ? Valid cookie ? Page loads
- ? Invalid cookie ? Redirect to login
- ? No cookie ? Redirect to login
- ? Envelope key mismatch ? Redirect to login
- ? Logout ? Cookie cleared, redirect to login
#### 3. Remove WASM Client Services from SSR Pages
**Optional Cleanup:**
- Review other SSR pages (`EnvelopeReceiverPage_DxPdfViewer.razor`, etc.)
- Replace WASM client services with SSR equivalents where applicable
- Document which services are WASM-only vs SSR-compatible
---
## Authentication Flow Comparison
### ? Old Flow (WASM Client Service in SSR)
```
EnvelopeReceiverPage (@rendermode InteractiveServer)
?
AuthService.CheckEnvelopeAccessAsync() (WASM client)
?
IHttpClientFactory.CreateClient("EnvelopeGenerator.Server")
?
GET /api/auth/check/envelope/{key}
?
[SELF-REFERENCING REQUEST - FAILS]
?
Returns false even when authenticated
```
### ? New Flow (SSR Service)
```
EnvelopeReceiverPage (@rendermode InteractiveServer)
?
IEnvelopeAuthService.IsAuthenticated(envelopeKey)
?
IHttpContextAccessor.HttpContext.User (Direct access)
?
ClaimsPrincipal.FindFirst("sub" or NameIdentifier)
?
Compare with envelopeKey
?
Return true/false (synchronous, no HTTP)
```
---
## Technical Decisions
### Why Not Use `[Authorize]` Attribute?
- Blazor SSR components **don't support** `[Authorize]` at component level
- Would require `<AuthorizeView>` component (less clean)
- Custom service provides more control + logging
### Why Scoped Lifetime?
- `IHttpContextAccessor` is scoped (per-request)
- `EnvelopeAuthService` depends on `IHttpContextAccessor`
- Scoped ensures same `HttpContext` throughout request
### Why Two Claims (`NameIdentifier` + `"sub"`)?
- **`NameIdentifier`**: Standard .NET claim type
- **`"sub"`**: JWT standard claim
- Fallback ensures compatibility with different token formats
---
## Logging & Debugging
### Log Levels
- **Debug:** Successful authentication
- **Warning:** Null envelope key, key mismatch
- **Error:** (Reserved for future exceptions)
### Sample Logs
```
[Debug] User authenticated for envelope 517bb9c5-6082-4e61-aaa5-9846386e67ee
[Warning] Envelope key mismatch: Expected abc123, Got 517bb9c5-6082-4e61-aaa5-9846386e67ee
[Warning] IsAuthenticated called with null or empty envelope key
```
---
## Testing Checklist
### Unit Tests (TODO)
```csharp
// EnvelopeGenerator.Tests/Services/EnvelopeAuthServiceTests.cs
[Fact]
public void IsAuthenticated_ValidUser_ReturnsTrue() { ... }
[Fact]
public void IsAuthenticated_InvalidKey_ReturnsFalse() { ... }
[Fact]
public void IsAuthenticated_UnauthenticatedUser_ReturnsFalse() { ... }
[Fact]
public void GetAuthenticatedEnvelopeKey_ValidUser_ReturnsKey() { ... }
```
### Integration Tests (Manual)
1. ? Login with valid access code ? Cookie set
2. ? Navigate to `/envelope/{key}` ? Page loads
3. ? Logout ? Cookie cleared, redirect
4. ? Try accessing `/envelope/{key}` without cookie ? Redirect to login
5. ? Try accessing `/envelope/{wrongKey}` with valid cookie ? Redirect to login
---
## Migration Checklist
- [x] Create `IEnvelopeAuthService` interface
- [x] Implement `EnvelopeAuthService`
- [x] Register service in `Program.cs`
- [ ] **Re-apply** `EnvelopeReceiverPage.razor` changes (after merge)
- [ ] Test authentication flow
- [ ] Add unit tests
- [ ] Update other SSR pages (if needed)
- [ ] Document in `COPILOT_CONTEXT.md`
---
## Documentation Updates Needed
### COPILOT_CONTEXT.md
**Add Section:**
```markdown
## SSR Authentication Service
**Purpose:** Server-side authentication for Blazor InteractiveServer pages.
**Location:** `EnvelopeGenerator.Server/Services/`
**Service:** `IEnvelopeAuthService` / `EnvelopeAuthService`
**Usage:**
```razor
@inject IEnvelopeAuthService EnvelopeAuth
protected override async Task OnInitializedAsync() {
if (!EnvelopeAuth.IsAuthenticated(EnvelopeKey)) {
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
return;
}
}
```
**Why Not Use WASM Client Services in SSR?**
- WASM client services use `IHttpClientFactory` with base address configuration
- SSR context requires `HttpContext` to configure base address
- Calling API endpoints from server-side component creates self-referencing requests
- Use `IEnvelopeAuthService` for direct `HttpContext.User` access instead
**Authentication Flow:**
1. JWT token stored in per-envelope cookie (`AuthTokenSignFLOWReceiver.{envelopeKey}`)
2. JWT middleware validates token, sets `HttpContext.User`
3. `EnvelopeAuthService` checks `ClaimsPrincipal.FindFirst("sub")` or `NameIdentifier`
4. Compares claim value with route parameter `{EnvelopeKey}`
**Claim Priority:**
1. `ClaimTypes.NameIdentifier` (standard .NET)
2. `"sub"` (JWT standard)
**Service Lifetime:** Scoped (per-request)
```
---
## Common Mistakes to Avoid
### ? Don't Do This
```csharp
// SSR page using WASM client service
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
var hasAccess = await AuthService.CheckEnvelopeAccessAsync(EnvelopeKey);
```
**Why Wrong:**
- Creates self-referencing HTTP request
- WASM client service doesn't work in SSR context
- Always returns `false` even when authenticated
### ? Do This Instead
```csharp
// SSR page using SSR authentication service
@inject IEnvelopeAuthService EnvelopeAuth
if (!EnvelopeAuth.IsAuthenticated(EnvelopeKey)) {
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
return;
}
```
**Why Correct:**
- Direct `HttpContext.User` access
- Synchronous (no HTTP overhead)
- Works in SSR context
---
## References
### Related Files
- `EnvelopeGenerator.Server/Program.cs` (Service registration, JWT middleware)
- `EnvelopeGenerator.Server.Client/Services/AuthService.cs` (WASM client version)
- `EnvelopeGenerator.Server.Client/Pages/LoginReceiverPage.razor` (WASM login page)
- `EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage.razor` (SSR viewer page)
### JWT Configuration
**File:** `EnvelopeGenerator.Server/Program.cs`
```csharp
.AddJwtBearer(AuthScheme.Receiver, opt =>
{
opt.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var envelopeKey = context.Request.Path.Value?.Split('/').LastOrDefault();
if (envelopeKey is not null)
{
var cookieName = CookieNames.GetEnvelopeReceiverCookieName(authTokenKeys.Cookie, envelopeKey);
if (context.Request.Cookies.TryGetValue(cookieName, out var cookieToken))
context.Token = cookieToken;
}
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
var envelopeKey = context.Request.Path.Value?.Split('/').LastOrDefault();
var sub = context.Principal?.FindFirst("sub")?.Value;
if (envelopeKey is null || sub != envelopeKey)
context.Fail("Envelope key mismatch");
return Task.CompletedTask;
}
};
});
```
---
## Summary
**What Was Done:**
1. Created SSR authentication service (`IEnvelopeAuthService` / `EnvelopeAuthService`)
2. Registered service in DI container
3. Updated `EnvelopeReceiverPage.razor` (REVERTED due to merge)
**What's Left:**
1. **Re-apply** `EnvelopeReceiverPage.razor` changes after merge
2. Test authentication flow
3. Add unit tests
4. Update documentation
**Key Insight:**
- **WASM client services ? SSR server services**
- Use `IHttpContextAccessor` for direct `HttpContext.User` access in SSR
- Avoid HTTP requests from server-side components to own endpoints
---
**Last Updated:** 2025-01-27
**Status:** ?? Partial (Service created, page changes reverted for merge)
**Next Agent:** Re-apply `EnvelopeReceiverPage.razor` changes + testing

File diff suppressed because it is too large Load Diff