Compare commits

...

67 Commits

Author SHA1 Message Date
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
7456babe0d Merge branch 'master' into bugfix/devexpress-pdf-not-displaying 2026-06-24 16:17:40 +02:00
6fe99d0cd0 docs: add culture management migration warnings for Server/Auto
Prepare for Blazor Server/Auto migration by documenting current
WASM-specific culture initialization approach and providing
detailed migration checklist in COPILOT_CONTEXT.md.
2026-06-18 12:50:45 +02:00
45018d04b1 Refactor culture initialization logic
Moved culture initialization from App.razor to Program.cs to ensure culture settings are applied before the app starts. Removed CultureService injection and OnInitializedAsync method from App.razor. Updated LanguageSelector.razor to change language without page reload, enhancing user experience. Added System.Globalization to Program.cs for culture support.
2026-06-18 12:43:56 +02:00
b5af3e61ed Improve language display in LanguageSelector
Replaced the placeholder arrow in the language selector button with the current language name using the new `GetLanguageName` method. Added `GetLanguageName` to map culture codes to language names. Fixed encoding issue by correcting "Fran�ais" to "Français" in the French language option.
2026-06-18 10:34:05 +02:00
314608f27f Fix duplicate root route entry in ReceiverUI docs
Removed a redundant entry for the root route (`/`) in the
`ReceiverUI Route Structure` section of the documentation.
Both entries referenced the same file (`Index.razor`) and
served the same purpose, making the duplication unnecessary.
2026-06-17 17:04:52 +02:00
ba9f233993 Add Language Selector component to footer
Introduced a Language Selector component (`LanguageSelector.razor`) to enable users to switch between German, English, and French. The component includes a dropdown UI with flag icons and language labels, along with logic for toggling the dropdown and changing the app's culture.

Injected `IJSRuntime`, `NavigationManager`, and `CultureService` into the component to handle culture changes and navigation updates.

Updated `MainLayout.razor` to include the Language Selector in the footer. Refactored footer layout in `MainLayout.razor.css` to use flexbox for better alignment and spacing.

Added styles for the Language Selector in `app.css` to ensure a clean and responsive design. Integrated the `flag-icons` library in `index.html` to display flag icons for language representation.
2026-06-17 16:57:39 +02:00
9d962708c4 Initialize culture settings in App.razor
Added dependency injection for `CultureService` in `App.razor` to dynamically initialize culture settings during the component's lifecycle.

Included `System.Globalization` and `EnvelopeGenerator.ReceiverUI.Services` namespaces to support culture-related functionality.

Injected `CultureService` and added an `OnInitializedAsync` method to set `CultureInfo.DefaultThreadCurrentCulture` and `CultureInfo.DefaultThreadCurrentUICulture` based on the service's initialization.

Reformatted surrounding `<Router>` code for clarity.
2026-06-17 16:55:44 +02:00
c93a056ca5 Add CultureService and localization support
Added a new `CultureService` to manage application culture and
localization. The service supports retrieving and setting the
application culture, storing it in `localStorage`, and initializing
it based on stored values, browser settings, or a default fallback.

Registered `CultureService` in the dependency injection container
and added localization support in `Program.cs` using
`builder.Services.AddLocalization()`.
2026-06-17 16:54:40 +02:00
a88a26c248 Remove EGDbContextFactory and related configuration code
The `EGDbContextFactory` class, which implemented the
`IDesignTimeDbContextFactory<EGDbContext>` interface, has been
removed. This includes the `CreateDbContext` method, which handled
design-time DbContext creation by loading configuration from
`appsettings.migration.json`, setting up `DbContextOptions`, and
constructing `DbTriggerParams`.

Additionally, the `#if NET` preprocessor directive and its
corresponding `#endif` have been removed, along with all associated
`using` directives. This change suggests that the factory is no
longer needed due to a shift in the application's architecture or
design-time DbContext handling.
2026-06-17 16:26:24 +02:00
1e963ea215 Add support for JWT authentication
Added `Microsoft.AspNetCore.Authentication.JwtBearer` package to the project to enable JWT authentication. Updated `Program.cs` to include the necessary `using` directive for JWT authentication. No functional changes were made beyond the integration of the new package.
2026-06-17 16:25:57 +02:00
02b857382c Replace SignatureService with DocReceiverElementService
Refactor EnvelopeReceiverPage.razor to inject
DocReceiverElementService instead of SignatureService.

Update Program.cs to remove SignatureService from the
dependency injection configuration and add
DocReceiverElementService as a scoped service.

This change reflects a shift in architecture where
DocReceiverElementService now handles functionality
previously managed by SignatureService.
2026-06-17 16:09:57 +02:00
ca4ec7cb6f Rename SignatureController to DocReceiverElementController
The class `SignatureController` has been renamed to `DocReceiverElementController` to better reflect its purpose. The constructor and its XML documentation have been updated accordingly. The `[Authorize]`, `[ApiController]`, and `[Route]` attributes remain unchanged. A `TODO` comment has been added to indicate future updates for using a signature query.
2026-06-17 16:09:26 +02:00
f2356b3ce4 Refactor SignatureService to DocReceiverElementService
Renamed the `SignatureService` class to `DocReceiverElementService` to reflect its updated purpose. Updated the `GetAsync` method to use the `DocReceiverElement` API endpoint (`/api/DocReceiverElement/{envelopeKey}`) instead of the `Signature` API endpoint. These changes align the service with the new `DocReceiverElement` context.
2026-06-17 16:07:43 +02:00
d61fe79613 Add localization support to the application
Added a package reference for `Microsoft.Extensions.Localization`
to enable localization support. Registered localization services
in `Program.cs` using `builder.Services.AddLocalization()`.
Introduced the `Microsoft.Extensions.Localization` and
`EnvelopeGenerator.Application.Resources` namespaces to support
localized resources. These changes allow the application to
provide content in multiple languages or regions.
2026-06-17 16:05:26 +02:00
714cb9555f feat(i18n): add sender-side UI resources and extension methods
- Added 46 resource strings (de-DE, en-US, fr-FR) for sender UI
- Added corresponding extension methods in Resource.cs
- Migrated from Form app for ReceiverUI sender pages
2026-06-17 15:58:30 +02:00
315a022cb8 feat: Add sender-side UI resource strings for all languages
Added 46 new localized strings to support sender-side UI implementation:

Resources added:
- Resource.de-DE.resx (German)
- Resource.en-US.resx (English)
- Resource.fr-FR.resx (French)

Categories covered:
- Dashboard actions (NewEnvelope, LoadEnvelope, DeleteEnvelope, etc.)
- Grid columns (Receivers, Status, Type, CreatedOn, etc.)
- Toolbar actions (RefreshData, ShowDocument, ResendInvitation, etc.)
- Envelope editor (AddFile, MergeFiles, EditFields, SendEnvelope, etc.)
- Settings (Language, UseAccessCode, TwoFactorEnabled, etc.)
- Reminders & expiry (FirstReminderDays, ExpiresWhenDays, etc.)

Source: Migrated from EnvelopeGenerator.Form resx files
Purpose: Support upcoming ReceiverUI sender pages implementation
2026-06-17 15:55:13 +02:00
746635979b Ensure _allEnvelopes is never null after assignment
Modified the assignment of `_allEnvelopes` to use the result of
`EnvelopeService.GetAsync()` or an empty list (`[]`) if the
result is null. This change prevents potential null reference
issues when `_allEnvelopes` is used later in the code.
2026-06-17 15:10:31 +02:00
31548728cd Refactor EnvelopeStatus to shared domain library
Moved the `EnvelopeStatus` enum and its extension methods
from `ReceiverUI.Models` to `Domain.Constants` to improve
code reuse and maintainability. Updated `EnvelopeSenderPage.razor`
to reference the new namespace.
2026-06-17 15:10:17 +02:00
06c8af2ed8 Remove EnvelopeGenerator.Dto project from solution
The `EnvelopeGenerator.Dto.csproj` file has been removed entirely, including its `<Project>` XML structure.

References to `EnvelopeGenerator.Dto` have been removed from `EnvelopeGenerator.sln`, including the project declaration, build configurations in `SolutionConfigurationPlatforms`, and nested project mappings in `NestedProjects`.

This change fully removes the `EnvelopeGenerator.Dto` project from the solution.
2026-06-17 14:38:30 +02:00
9f57baf2e5 refactor(ReceiverUI/Models): update to use Application layer's DTO 2026-06-17 14:05:01 +02:00
73d793f0a0 remove ApiExplorerSettings attribute 2026-06-17 11:53:01 +02:00
65bb68feef Update DevExpress package versions to 25.2.8
Updated the following DevExpress package references in the project:
- `DevExpress.Blazor.PdfViewer` from 25.2.3 to 25.2.8
- `DevExpress.Blazor.Reporting.JSBasedControls` from 25.2.3 to 25.2.8
- `DevExpress.Blazor.Reporting.Viewer` from 25.2.3 to 25.2.8
- `DevExpress.Drawing.Skia` from 25.2.3 to 25.2.8

No changes were made to other package references or the `NativeFileReference` entry.
2026-06-17 11:05:53 +02:00
c5e97ee30b move dto to common dir 2026-06-17 09:46:27 +02:00
3a4f449b59 Add EnvelopeGenerator.Dto project to the solution
Introduce a new project, `EnvelopeGenerator.Dto`, targeting .NET 8.0.
The project is configured with implicit `using` directives and nullable
reference types enabled. Update the solution file to include the new
project, its build configurations (Debug/Release for Any CPU), and its
hierarchical relationship in the `NestedProjects` section.
2026-06-17 09:45:22 +02:00
6ca7767e4d Enhance grid functionality in EnvelopeSenderPage
Added support for column reordering, sorting, and resizing in the grid:
- Enabled `AllowColumnReorder` for column reordering.
- Enabled `AllowSort` to allow sorting of grid columns.
- Set `ColumnResizeMode` to `GridColumnResizeMode.ColumnsContainer` for improved column resizing behavior.
2026-06-17 09:41:46 +02:00
4237f0a815 Add CellDisplayTemplate for ID column in grid
Updated the `DxGridDataColumn` for the `Id` field in
`EnvelopeSenderPage.razor` to include a `CellDisplayTemplate`.
This enables customized rendering of the `Id` property from
the `EnvelopeDto` object, allowing for additional formatting
or logic during display.
2026-06-16 17:01:38 +02:00
3302be9348 Refactor grid columns and improve UI styling
Removed fixed column widths in `EnvelopeSenderPage.razor` for dynamic sizing. Added `CellDisplayTemplate` to `Title`, `Status`, and `EnvelopeReceivers` columns for custom data rendering. Fixed gradient typo in progress bar CSS.

Added a new CSS rule to hide empty DevExpress grid cells and updated `sender-page.css` to include this rule while preserving existing styles.
2026-06-16 16:47:11 +02:00
4572e20c51 Restrict Logout method to Sender auth scheme
The `[Authorize]` attribute on the `Logout` method in the
`AuthController` class was updated to use the
`AuthenticationSchemes = AuthScheme.Sender` instead of the
`Policy = AuthPolicy.SenderOrReceiver`. This change narrows
the authorization requirement, ensuring only users under the
`Sender` authentication scheme can access the `Logout`
functionality.
2026-06-16 16:32:53 +02:00
b3a70d7259 Add sender authentication check to EnvelopeSenderPage
Added an authentication check in `EnvelopeSenderPage.razor` to verify sender access before loading envelopes. Redirects unauthorized users to the sender login page.

Introduced `CheckSenderAsync` in `AuthService` to validate sender tokens via the `/api/auth/check` endpoint. Updated `OnInitializedAsync` to use this method, enhancing security by ensuring only authorized users can access envelope-related functionality.
2026-06-16 15:55:59 +02:00
bb81920d44 Refactor sender page styles and add versioned URLs
Moved inline styles from `EnvelopeSenderPage.razor` to a new
`sender-page.css` file for better maintainability and separation
of concerns. Updated `EnvelopeSenderPage.razor` to use versioned
URLs for stylesheets via the newly injected `AppVersionService`,
enabling cache-busting. Added responsive design support in
`sender-page.css` to improve layout on smaller screens.
2026-06-16 15:05:00 +02:00
3b66de0691 Enhance EnvelopeSenderPage with new UI and features
Integrated DevExpress Blazor components and added a responsive,
modern UI for the sender dashboard. Replaced placeholder content
with a functional layout, including a grid-based envelope viewer
with filtering, pagination, and detailed row templates.

Added status badges, progress indicators, and a sender action
bar with buttons for creating, editing, deleting, refreshing
envelopes, and logging out. Introduced loading and error
handling states for better user experience.

Refactored data loading with `LoadEnvelopesAsync` to fetch and
categorize envelopes. Added methods for envelope management
and logout functionality. Improved state management and removed
unused code. These changes lay the groundwork for future
enhancements.
2026-06-15 17:00:23 +02:00
9f6004ba8c Refactor EnvelopeDto and add EnvelopeStatus enum
Updated the `EnvelopeDto` class to use a simplified receiver model (`EnvelopeReceiverSimpleDto`) for streamlined data handling. Added the `EnvelopeReceiverSimpleDto` class to represent basic receiver information (`Name`, `Email`, `Signed`).

Introduced the `EnvelopeStatus` enumeration in `EnvelopeStatus.cs` to define envelope lifecycle statuses, repurposed for the `ReceiverUI` context. Added `EnvelopeStatusExtensions` with `IsActive` and `IsCompleted` methods to evaluate envelope status states.
2026-06-15 16:59:37 +02:00
ef246bae32 Add LogoutSenderAsync method to AuthService
A new asynchronous method `LogoutSenderAsync` was added to the `AuthService` class to handle sender user logout. The method sends a POST request to the `/api/auth/logout` endpoint and removes the authentication cookie. It accepts an optional `CancellationToken` parameter and returns a `bool` indicating the success of the operation. XML documentation comments were included to describe the method's functionality.
2026-06-15 16:59:16 +02:00
e4ebb29969 Add authorization and data fetching to sender page
Added an `[Authorize]` attribute with the "Sender" policy to restrict access to the `EnvelopeSenderPage.razor`. Updated the page title to "Umschläge" and added placeholder text for data loading.

Injected `EnvelopeService` and `IJSRuntime` to fetch and log active and completed envelopes. Introduced `_activeEnvelopes` and `_completedEnvelopes` fields to store fetched data. Configured `JsonSerializerOptions` for consistent JSON handling.

Implemented `OnInitializedAsync` to fetch data asynchronously, log results to the console, and handle errors gracefully.
2026-06-15 16:24:45 +02:00
83cdb9dfe9 Update launch settings for HTTPS and ReceiverUI profiles
Changed the `launchUrl` for the HTTPS profile in `launchSettings.json` from `"swagger"` to `"sender"`, updating the default URL path.
Disabled `launchBrowser` for the `EnvelopeGenerator.ReceiverUI` profile, preventing the browser from automatically opening when this profile is executed.
2026-06-15 16:20:28 +02:00
c5db676e01 Add EnvelopeService to DI container in Program.cs
Registered EnvelopeService with a scoped lifetime in the
dependency injection container by adding
`builder.Services.AddScoped<EnvelopeService>();` to Program.cs.
This ensures a new instance is created per HTTP request.
2026-06-15 15:56:49 +02:00
95c8e15887 Add EnvelopeDto and EnvelopeService for API integration
Introduced the `EnvelopeDto` class to represent envelope data with JSON property mappings. Added the `EnvelopeService` class to handle API interactions, including fetching envelopes with optional filters and query string construction using `Microsoft.AspNetCore.WebUtilities`. Updated the project file to include the required package reference for query string manipulation.
2026-06-15 15:40:59 +02:00
561b844e59 Add filtering for active and completed envelopes
Added `OnlyActive` and `OnlyCompleted` properties to the `ReadEnvelopeQuery` class to enable filtering envelopes by their active or completed status. Updated the `ReadEnvelopeQueryHandler` to apply these filters when the properties are set.

Enhanced the `EnvelopeStatus` class by introducing `Active` and `Completed` status lists and adding extension methods (`IsActive` and `IsCompleted`) to determine status categories. Included necessary `using` directives for `System` and `System.Linq`.
2026-06-15 15:07:12 +02:00
011960be75 Add EnvelopeStatus extensions and update documentation URL
The `System` namespace was added to `EnvelopeStatus.cs` to enable additional functionality. A documentation URL in the comments was updated to point to a new location, replacing the outdated link.

Introduced a new static class `EnvelopeStatusExtensions` with two extension methods for the `EnvelopeStatus` enum:
- `IsActive`: Checks if the status is active (between `EnvelopeCreated` and `EnvelopePartlySigned`).
- `IsCompleted`: Checks if the status is completed (between `EnvelopeCompletelySigned` and `EnvelopeWithdrawn`).
2026-06-15 14:55:01 +02:00
151c785af9 Enhance JSON options and authorization policies
Added JSON serialization options to ignore reference cycles in
the `AddControllers` method by configuring `ReferenceHandler`
to `IgnoreCycles`. Updated the `AddAuthorizationBuilder` to
include authentication schemes for the `SenderOrReceiver`
policy, requiring roles and schemes for enhanced security.
2026-06-12 15:21:50 +02:00
fa354a05cc Update authorization policy in ConfigController
Replaced the generic [Authorize] attribute with a more specific
[Authorize(Policy = AuthPolicy.SenderOrReceiver)] to enforce
a stricter authorization policy. Added a `using` directive for
`EnvelopeGenerator.Domain.Constants` to support the new policy.
2026-06-12 15:21:29 +02:00
1326407462 Update AuthController to use specific auth scheme
The `[Authorize]` attribute on the `Check` method was updated to specify the `AuthScheme.Sender` authentication scheme. This change ensures that the `Check` endpoint now requires authentication using this specific scheme, enhancing security and supporting multiple authentication schemes within the application.
2026-06-12 15:15:44 +02:00
a3c653ddb3 Simplify Envelope to EnvelopeDto mapping
Removed custom mapping logic for the `Receivers` property in the
`Envelope` to `EnvelopeDto` mapping within the `MappingProfile`
class. The mapping now uses default behavior without projecting
`EnvelopeReceivers` to `Receivers`.
2026-06-12 15:15:09 +02:00
8d736cdc5e Refactor EnvelopeDto property for receiver handling
Replaced the `Receivers` property with `EnvelopeReceivers` in the `EnvelopeDto` class to improve clarity and better align with the updated data model. The new property uses `IEnumerable<EnvelopeReceiverDto>?` instead of `IEnumerable<ReceiverDto>?`.
2026-06-12 15:14:51 +02:00
76 changed files with 5260 additions and 1553 deletions

View File

@@ -1,477 +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
---
## Key Files & Routes
## Receiver Page Data Loading
### 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 server-side page data service:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverPageDataService.cs`
### 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). |
This service loads directly via MediatR and distributed cache:
- document bytes
- receiver envelope data
- signature placeholders
- cached signature data
### 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. |
For signature placeholders, the service:
- reads document receiver elements
- filters them for the authenticated receiver
- converts coordinates to `UnitOfLength.Point` before UI use
---
## Coordinate System — CRITICAL
## Receiver PDF Viewer
**Database Format:** INCHES (GdPicture14 native)
**Origin:** Top-left corner
**Axes:** X right, Y down
**Main file:** `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage.razor`
### Conversion Formulas
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`
| 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` |
### JS Assets
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/pdf-viewer.js`
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/receiver-signature.js`
**A4 Dimensions:**
- Width: 8.27" = 595pt = 827 DX
- Height: 11.69" = 842pt = 1169 DX
### CSS
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css`
### 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** |
### 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`
---
## EnvelopeReceiver — PDF.js Viewer & Signing
## Signature Workflow
**Route:** `/envelope/{EnvelopeKey}`
**Tech:** PDF.js 3.11.174 + Blazor Server (`@rendermode InteractiveServer`) + configurable quality
**File:** `WebUI/Components/Pages/EnvelopeReceiverPage.razor`
Receiver signatures are handled as a **viewer overlay workflow**.
### 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)
### 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.
### Configuration
**File:** `WebUI/wwwroot/appsettings.json`
### 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.
```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

@@ -40,7 +40,7 @@ public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions,
/// <response code="401">Wenn es kein zugelassenes Cookie gibt, wird „nicht zugelassen“ zurückgegeben.</response>
[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()
{
@@ -69,7 +69,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

@@ -1,4 +1,5 @@
using EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
@@ -13,7 +14,7 @@ namespace EnvelopeGenerator.API.Controllers;
/// </remarks>
[Route("api/[controller]")]
[ApiController]
[Authorize]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
public class ConfigController(IOptionsMonitor<AnnotationParams> annotationParamsOptions) : ControllerBase
{
private readonly AnnotationParams _annotationParams = annotationParamsOptions.CurrentValue;

View File

@@ -15,14 +15,14 @@ namespace EnvelopeGenerator.API.Controllers;
[Authorize(Policy = AuthPolicy.Receiver)]
[ApiController]
[Route("api/[controller]")]
public class SignatureController : ControllerBase
public class DocReceiverElementController : ControllerBase
{
private readonly IMediator _mediator;
/// <summary>
/// Initializes a new instance of <see cref="SignatureController"/>.
/// Initializes a new instance of <see cref="DocReceiverElementController"/>.
/// </summary>
public SignatureController(IMediator mediator)
public DocReceiverElementController(IMediator mediator)
{
_mediator = mediator;
}

View File

@@ -34,6 +34,7 @@
<PackageReference Include="DigitalData.Auth.Client" Version="1.3.7" />
<PackageReference Include="DigitalData.Core.API" Version="2.2.1" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.28" />
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.11" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="itext" Version="8.0.5" />
<PackageReference Include="itext.bouncy-castle-adapter" Version="8.0.5" />

View File

@@ -13,7 +13,6 @@ using EnvelopeGenerator.Application;
using DigitalData.Auth.Client;
using DigitalData.Core.Abstractions;
using EnvelopeGenerator.API.Models;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using DigitalData.Core.Abstractions.Security.Extensions;
using EnvelopeGenerator.API.Middleware;
@@ -22,6 +21,7 @@ using NLog.Web;
using NLog;
using DigitalData.Auth.Claims;
using EnvelopeGenerator.API;
using Microsoft.AspNetCore.Authentication.JwtBearer;
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
logger.Info("Logging initialized!");
@@ -44,7 +44,11 @@ try
var deferredProvider = new DeferredServiceProvider();
builder.Services.AddControllers();
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
});
builder.Services.AddHttpClient();
builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
@@ -238,8 +242,9 @@ try
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy(AuthPolicy.SenderOrReceiver, policy => policy.RequireRole(Role.Sender, Role.Receiver.Full))
.AddPolicy(AuthPolicy.SenderOrReceiver, policy => policy
.RequireRole(Role.Sender, Role.Receiver.Full)
.AddAuthenticationSchemes(AuthScheme.Sender, AuthScheme.Receiver))
.AddPolicy(AuthPolicy.Sender, policy => policy
.RequireRole(Role.Sender)
.AddAuthenticationSchemes(AuthScheme.Sender))

View File

@@ -22,8 +22,8 @@
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"launchUrl": "swagger",
"launchBrowser": true,
"launchUrl": "sender",
"applicationUrl": "https://localhost:8088;http://localhost:5131",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"

View File

@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
/// Data Transfer Object representing configuration settings.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public class ConfigDto
{
/// <summary>

View File

@@ -1,14 +1,11 @@
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Interfaces;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations.Schema;
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
/// Data Transfer Object representing a positioned element assigned to a document receiver.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public class DocReceiverElementDto : IDocReceiverElement
{
/// <summary>

View File

@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
/// Data Transfer Object representing a document within an envelope, including optional binary data and form elements.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public class DocumentDto
{
/// <summary>

View File

@@ -1,12 +1,10 @@
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
/// Data Transfer Object representing the status of a document for a specific receiver.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public class DocumentStatusDto
{
/// <summary>

View File

@@ -1,19 +1,15 @@
using DigitalData.EmailProfilerDispatcher.Abstraction.Attributes;
using DigitalData.UserManager.Application.DTOs.User;
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Application.Common.Dto.History;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Interfaces;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json.Serialization;
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
///
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public record EnvelopeDto : IEnvelope
{
/// <summary>
@@ -133,5 +129,10 @@ public record EnvelopeDto : IEnvelope
/// <summary>
///
/// </summary>
public IEnumerable<ReceiverDto>? Receivers { get; set; }
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,13 +1,12 @@
using DigitalData.EmailProfilerDispatcher.Abstraction.Attributes;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using Microsoft.AspNetCore.Mvc;
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
/// <summary>
///
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public record EnvelopeReceiverDto
{
/// <summary>
@@ -75,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,11 +1,8 @@
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
namespace EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
/// <summary>
///
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public record EnvelopeReceiverSecretDto : EnvelopeReceiverDto
{
/// <summary>

View File

@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
@@ -8,7 +7,6 @@ namespace EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
///
/// </summary>
/// <param name="DateValid"></param>
[ApiExplorerSettings(IgnoreApi = true)]
public record EnvelopeReceiverReadOnlyCreateDto(
DateTime DateValid)
{

View File

@@ -1,6 +1,4 @@
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using Microsoft.AspNetCore.Mvc;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
namespace EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
@@ -8,7 +6,6 @@ namespace EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
/// Represents a read-only Data Transfer Object (DTO) for an envelope receiver.
/// Contains information about the receiver, associated envelope, and audit details.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public class EnvelopeReceiverReadOnlyDto
{
/// <summary>

View File

@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
namespace EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
/// <summary>
/// Data Transfer Object for updating a read-only envelope receiver.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public class EnvelopeReceiverReadOnlyUpdateDto
{
/// <summary>

View File

@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
/// Data Transfer Object representing a type of envelope with its configuration settings.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public class EnvelopeTypeDto
{
/// <summary>

View File

@@ -1,9 +1,6 @@
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Application.Common.Dto.Messaging;
namespace EnvelopeGenerator.Application.Common.Dto.Messaging;
/// <summary>
///
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public class GtxMessagingResponse : Dictionary<string, object?> { }

View File

@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Application.Common.Dto.Messaging;
namespace EnvelopeGenerator.Application.Common.Dto.Messaging;
/// <summary>
///
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public record SmsResponse
{
/// <summary>

View File

@@ -1,5 +1,4 @@
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
using Microsoft.AspNetCore.Mvc;
using System.Text.Json.Serialization;
namespace EnvelopeGenerator.Application.Common.Dto.Receiver;
@@ -7,7 +6,6 @@ namespace EnvelopeGenerator.Application.Common.Dto.Receiver;
/// <summary>
///
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public class ReceiverDto
{
/// <summary>

View File

@@ -7,7 +7,9 @@ using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Domain.Entities;
namespace EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common;
/// <summary>
/// Represents the AutoMapper profile configuration for mapping between
@@ -26,15 +28,15 @@ public class MappingProfile : Profile
CreateMap<DocReceiverElement, DocReceiverElementDto>();
CreateMap<DocumentStatus, DocumentStatusDto>();
CreateMap<EmailTemplate, EmailTemplateDto>();
CreateMap<Envelope, EnvelopeDto>().ForMember(dest => dest.Receivers, opt => opt.MapFrom(src => src.EnvelopeReceivers.Select(er => er.Receiver)));
CreateMap<Envelope, EnvelopeDto>();
CreateMap<Document, DocumentDto>();
CreateMap<Domain.Entities.History, HistoryDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
CreateMap<Domain.Entities.History, HistoryCreateDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
CreateMap<Domain.Entities.EnvelopeReceiver, EnvelopeReceiverDto>();
CreateMap<Domain.Entities.EnvelopeReceiver, EnvelopeReceiverSecretDto>();
CreateMap<History, HistoryDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
CreateMap<History, HistoryCreateDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
CreateMap<EnvelopeReceiver, EnvelopeReceiverDto>();
CreateMap<EnvelopeReceiver, EnvelopeReceiverSecretDto>();
CreateMap<EnvelopeType, EnvelopeTypeDto>();
CreateMap<Domain.Entities.Receiver, ReceiverDto>();
CreateMap<Domain.Entities.EnvelopeReceiverReadOnly, EnvelopeReceiverReadOnlyDto>();
CreateMap<Receiver, ReceiverDto>();
CreateMap<EnvelopeReceiverReadOnly, EnvelopeReceiverReadOnlyDto>();
CreateMap<ElementAnnotation, AnnotationDto>();
// DTO to Entity mappings
@@ -47,13 +49,13 @@ public class MappingProfile : Profile
CreateMap<EmailTemplateDto, EmailTemplate>();
CreateMap<EnvelopeDto, Envelope>();
CreateMap<DocumentDto, Document>();
CreateMap<HistoryDto, Domain.Entities.History>().ForMember(dest => dest.ChangedWhen, opt => opt.MapFrom(src => src.ActionDate));
CreateMap<HistoryCreateDto, Domain.Entities.History>().ForMember(dest => dest.ChangedWhen, opt => opt.MapFrom(src => src.ActionDate));
CreateMap<EnvelopeReceiverDto, Domain.Entities.EnvelopeReceiver>();
CreateMap<HistoryDto, History>().ForMember(dest => dest.ChangedWhen, opt => opt.MapFrom(src => src.ActionDate));
CreateMap<HistoryCreateDto, History>().ForMember(dest => dest.ChangedWhen, opt => opt.MapFrom(src => src.ActionDate));
CreateMap<EnvelopeReceiverDto, EnvelopeReceiver>();
CreateMap<EnvelopeTypeDto, EnvelopeType>();
CreateMap<ReceiverDto, Domain.Entities.Receiver>().ForMember(rcv => rcv.EnvelopeReceivers, rcvReadDto => rcvReadDto.Ignore());
CreateMap<EnvelopeReceiverReadOnlyCreateDto, Domain.Entities.EnvelopeReceiverReadOnly>();
CreateMap<EnvelopeReceiverReadOnlyUpdateDto, Domain.Entities.EnvelopeReceiverReadOnly>();
CreateMap<ReceiverDto, Receiver>().ForMember(rcv => rcv.EnvelopeReceivers, rcvReadDto => rcvReadDto.Ignore());
CreateMap<EnvelopeReceiverReadOnlyCreateDto, EnvelopeReceiverReadOnly>();
CreateMap<EnvelopeReceiverReadOnlyUpdateDto, EnvelopeReceiverReadOnly>();
CreateMap<AnnotationCreateDto, ElementAnnotation>()
.MapAddedWhen();

View File

@@ -7,7 +7,7 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<None Remove="Resources\Model.Designer.vb" />
</ItemGroup>
@@ -21,7 +21,6 @@
<PackageReference Include="DigitalData.EmailProfilerDispatcher" Version="3.1.1" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.18" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="QRCoder" Version="1.6.0" />
@@ -79,25 +78,25 @@
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net7.0'">
<PackageReference Include="AutoMapper" Version="13.0.1" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="CommandDotNet">
<PackageReference Include="AutoMapper" Version="13.0.1" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="CommandDotNet">
<Version>7.0.5</Version>
</PackageReference>
</PackageReference>
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="AutoMapper" Version="14.0.0" />
<PackageReference Include="CommandDotNet">
<Version>8.1.1</Version>
</PackageReference>
<PackageReference Include="AutoMapper" Version="14.0.0" />
<PackageReference Include="CommandDotNet">
<Version>8.1.1</Version>
</PackageReference>
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="AutoMapper" Version="14.0.0" />
<PackageReference Include="CommandDotNet">
<Version>8.1.1</Version>
</PackageReference>
<PackageReference Include="AutoMapper" Version="14.0.0" />
<PackageReference Include="CommandDotNet">
<Version>8.1.1</Version>
</PackageReference>
</ItemGroup>
</Project>

View File

@@ -14,6 +14,16 @@ namespace EnvelopeGenerator.Application.Envelopes.Queries;
/// </summary>
public record ReadEnvelopeQuery : EnvelopeQueryBase, IRequest<IEnumerable<EnvelopeDto>>
{
/// <summary>
///
/// </summary>
public bool OnlyActive { get; init; } = false;
/// <summary>
///
/// </summary>
public bool OnlyCompleted { get; init; } = false;
/// <summary>
/// Abfrage des Include des Umschlags
/// </summary>
@@ -132,6 +142,12 @@ public class ReadEnvelopeQueryHandler : IRequestHandler<ReadEnvelopeQuery, IEnum
query = query.Where(e => !status.Ignore.Contains(e.Status));
}
if(request is { OnlyActive: true })
query = query.Where(e => Status.Active.Contains(e.Status));
if (request is { OnlyCompleted: true })
query = query.Where(e => Status.Completed.Contains(e.Status));
var envelopes = await query
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver)
.ToListAsync(cancel);

View File

@@ -3,7 +3,6 @@ using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
@@ -14,7 +13,6 @@ namespace EnvelopeGenerator.Application.Receivers.Commands;
/// <summary>
///
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public record CreateReceiverCommand : IRequest<(ReceiverDto Receiver, bool AlreadyExists)>
{
/// <summary>

View File

@@ -1,11 +1,8 @@
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Application.Receivers.Commands;
namespace EnvelopeGenerator.Application.Receivers.Commands;
/// <summary>
/// Data Transfer Object for updating a receiver's information.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
public class UpdateReceiverCommand
{
/// <summary>

View File

@@ -397,4 +397,412 @@ public static class Extensions
/// <param name="suffix"></param>
/// <returns></returns>
public static string LockedFooterBody(this IStringLocalizer localizer, string suffix) => localizer[nameof(LockedFooterBody) + suffix].Value;
// Sender-side UI resources
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string NewEnvelope(this IStringLocalizer localizer) => localizer[nameof(NewEnvelope)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string LoadEnvelope(this IStringLocalizer localizer) => localizer[nameof(LoadEnvelope)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string DeleteEnvelope(this IStringLocalizer localizer) => localizer[nameof(DeleteEnvelope)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string RefreshData(this IStringLocalizer localizer) => localizer[nameof(RefreshData)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string RefreshedAt(this IStringLocalizer localizer) => localizer[nameof(RefreshedAt)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string ShowDocument(this IStringLocalizer localizer) => localizer[nameof(ShowDocument)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string ContactReceiver(this IStringLocalizer localizer) => localizer[nameof(ContactReceiver)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string EnvelopeId(this IStringLocalizer localizer) => localizer[nameof(EnvelopeId)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string OpenLogDirectory(this IStringLocalizer localizer) => localizer[nameof(OpenLogDirectory)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string ShowResultsReport(this IStringLocalizer localizer) => localizer[nameof(ShowResultsReport)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string SupportMail(this IStringLocalizer localizer) => localizer[nameof(SupportMail)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string ResendInvitation(this IStringLocalizer localizer) => localizer[nameof(ResendInvitation)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string Export(this IStringLocalizer localizer) => localizer[nameof(Export)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string Receivers(this IStringLocalizer localizer) => localizer[nameof(Receivers)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string EmailSalutation(this IStringLocalizer localizer) => localizer[nameof(EmailSalutation)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string SignedWhen(this IStringLocalizer localizer) => localizer[nameof(SignedWhen)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string AccessCode(this IStringLocalizer localizer) => localizer[nameof(AccessCode)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string User(this IStringLocalizer localizer) => localizer[nameof(User)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string Type(this IStringLocalizer localizer) => localizer[nameof(Type)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string Title(this IStringLocalizer localizer) => localizer[nameof(Title)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string CreatedOn(this IStringLocalizer localizer) => localizer[nameof(CreatedOn)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string LastModified(this IStringLocalizer localizer) => localizer[nameof(LastModified)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string OpenEnvelopes(this IStringLocalizer localizer) => localizer[nameof(OpenEnvelopes)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string CompletedEnvelopes(this IStringLocalizer localizer) => localizer[nameof(CompletedEnvelopes)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string SendAccessCode(this IStringLocalizer localizer) => localizer[nameof(SendAccessCode)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string TwoFactorProperties(this IStringLocalizer localizer) => localizer[nameof(TwoFactorProperties)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string Name(this IStringLocalizer localizer) => localizer[nameof(Name)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string PhoneNumber(this IStringLocalizer localizer) => localizer[nameof(PhoneNumber)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string AddReceiver(this IStringLocalizer localizer) => localizer[nameof(AddReceiver)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string DeleteReceiver(this IStringLocalizer localizer) => localizer[nameof(DeleteReceiver)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string AddFile(this IStringLocalizer localizer) => localizer[nameof(AddFile)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string MergeFiles(this IStringLocalizer localizer) => localizer[nameof(MergeFiles)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string DeleteFile(this IStringLocalizer localizer) => localizer[nameof(DeleteFile)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string ShowFile(this IStringLocalizer localizer) => localizer[nameof(ShowFile)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string EditFields(this IStringLocalizer localizer) => localizer[nameof(EditFields)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string EditData(this IStringLocalizer localizer) => localizer[nameof(EditData)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string Save(this IStringLocalizer localizer) => localizer[nameof(Save)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string SendEnvelope(this IStringLocalizer localizer) => localizer[nameof(SendEnvelope)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string Cancel(this IStringLocalizer localizer) => localizer[nameof(Cancel)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string AddSignature(this IStringLocalizer localizer) => localizer[nameof(AddSignature)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string DeleteSignature(this IStringLocalizer localizer) => localizer[nameof(DeleteSignature)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string Language(this IStringLocalizer localizer) => localizer[nameof(Language)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string UseAccessCode(this IStringLocalizer localizer) => localizer[nameof(UseAccessCode)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string TwoFactorEnabled(this IStringLocalizer localizer) => localizer[nameof(TwoFactorEnabled)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string CertificationType(this IStringLocalizer localizer) => localizer[nameof(CertificationType)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string FinalEmailToCreator(this IStringLocalizer localizer) => localizer[nameof(FinalEmailToCreator)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string FinalEmailToReceivers(this IStringLocalizer localizer) => localizer[nameof(FinalEmailToReceivers)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string SendReminderEmails(this IStringLocalizer localizer) => localizer[nameof(SendReminderEmails)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string FirstReminderDays(this IStringLocalizer localizer) => localizer[nameof(FirstReminderDays)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string ReminderIntervalDays(this IStringLocalizer localizer) => localizer[nameof(ReminderIntervalDays)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string ExpiresWhenDays(this IStringLocalizer localizer) => localizer[nameof(ExpiresWhenDays)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string ExpiresWarningDays(this IStringLocalizer localizer) => localizer[nameof(ExpiresWarningDays)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string Message(this IStringLocalizer localizer) => localizer[nameof(Message)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string EnvelopeType(this IStringLocalizer localizer) => localizer[nameof(EnvelopeType)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string AllOptions(this IStringLocalizer localizer) => localizer[nameof(AllOptions)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string DeleteReason(this IStringLocalizer localizer) => localizer[nameof(DeleteReason)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string PleaseProvideReason(this IStringLocalizer localizer) => localizer[nameof(PleaseProvideReason)].Value;
/// <summary>
///
/// </summary>
/// <param name="localizer"></param>
/// <returns></returns>
public static string Status(this IStringLocalizer localizer) => localizer[nameof(Status)].Value;
}

View File

@@ -477,4 +477,178 @@
<data name="Confirmations" xml:space="preserve">
<value>Bestätigungen</value>
</data>
<data name="NewEnvelope" xml:space="preserve">
<value>Neuer Umschlag</value>
</data>
<data name="LoadEnvelope" xml:space="preserve">
<value>Umschlag laden</value>
</data>
<data name="DeleteEnvelope" xml:space="preserve">
<value>Umschlag zurückrufen/löschen</value>
</data>
<data name="RefreshData" xml:space="preserve">
<value>Daten Aktualisieren</value>
</data>
<data name="RefreshedAt" xml:space="preserve">
<value>Aktualisiert: {0}</value>
</data>
<data name="ShowDocument" xml:space="preserve">
<value>Dokument anzeigen</value>
</data>
<data name="ContactReceiver" xml:space="preserve">
<value>Empfänger kontaktieren</value>
</data>
<data name="EnvelopeId" xml:space="preserve">
<value>Umschlag-ID: {0}</value>
</data>
<data name="OpenLogDirectory" xml:space="preserve">
<value>Öffne Log Verzeichnis</value>
</data>
<data name="ShowResultsReport" xml:space="preserve">
<value>Ergebnisbericht anzeigen</value>
</data>
<data name="SupportMail" xml:space="preserve">
<value>Support Mail</value>
</data>
<data name="ResendInvitation" xml:space="preserve">
<value>Einladung manuell versenden</value>
</data>
<data name="Export" xml:space="preserve">
<value>Export</value>
</data>
<data name="Receivers" xml:space="preserve">
<value>Empfänger</value>
</data>
<data name="EmailSalutation" xml:space="preserve">
<value>Email Anrede</value>
</data>
<data name="SignedWhen" xml:space="preserve">
<value>Unterschrieben wann</value>
</data>
<data name="AccessCode" xml:space="preserve">
<value>Zugangscode</value>
</data>
<data name="User" xml:space="preserve">
<value>Benutzer</value>
</data>
<data name="Type" xml:space="preserve">
<value>Typ</value>
</data>
<data name="Title" xml:space="preserve">
<value>Titel</value>
</data>
<data name="CreatedOn" xml:space="preserve">
<value>Erstellt am</value>
</data>
<data name="LastModified" xml:space="preserve">
<value>Zuletzt geändert am</value>
</data>
<data name="OpenEnvelopes" xml:space="preserve">
<value>Offene Umschläge</value>
</data>
<data name="CompletedEnvelopes" xml:space="preserve">
<value>Abgeschlossene Umschläge</value>
</data>
<data name="SendAccessCode" xml:space="preserve">
<value>Zugangscode senden</value>
</data>
<data name="TwoFactorProperties" xml:space="preserve">
<value>2-Faktor Eigenschaften</value>
</data>
<data name="Name" xml:space="preserve">
<value>Name</value>
</data>
<data name="PhoneNumber" xml:space="preserve">
<value>Telefonnummer</value>
</data>
<data name="AddReceiver" xml:space="preserve">
<value>Empfänger hinzufügen</value>
</data>
<data name="DeleteReceiver" xml:space="preserve">
<value>Empfänger löschen</value>
</data>
<data name="AddFile" xml:space="preserve">
<value>Datei hinzufügen</value>
</data>
<data name="MergeFiles" xml:space="preserve">
<value>Dateien zusammenführen</value>
</data>
<data name="DeleteFile" xml:space="preserve">
<value>Datei löschen</value>
</data>
<data name="ShowFile" xml:space="preserve">
<value>Datei anzeigen</value>
</data>
<data name="EditFields" xml:space="preserve">
<value>Felder bearbeiten</value>
</data>
<data name="EditData" xml:space="preserve">
<value>Daten bearbeiten</value>
</data>
<data name="Save" xml:space="preserve">
<value>Speichern</value>
</data>
<data name="SendEnvelope" xml:space="preserve">
<value>Umschlag versenden</value>
</data>
<data name="Cancel" xml:space="preserve">
<value>Abbrechen</value>
</data>
<data name="AddSignature" xml:space="preserve">
<value>Signatur hinzufügen</value>
</data>
<data name="DeleteSignature" xml:space="preserve">
<value>Signatur löschen</value>
</data>
<data name="Language" xml:space="preserve">
<value>Sprache</value>
</data>
<data name="UseAccessCode" xml:space="preserve">
<value>Zugangscode verwenden</value>
</data>
<data name="TwoFactorEnabled" xml:space="preserve">
<value>2-Faktor-Authentifizierung aktiviert</value>
</data>
<data name="CertificationType" xml:space="preserve">
<value>Zertifizierungstyp</value>
</data>
<data name="FinalEmailToCreator" xml:space="preserve">
<value>Finale E-Mail an Ersteller</value>
</data>
<data name="FinalEmailToReceivers" xml:space="preserve">
<value>Finale E-Mail an Empfänger</value>
</data>
<data name="SendReminderEmails" xml:space="preserve">
<value>Erinnerungs-E-Mails senden</value>
</data>
<data name="FirstReminderDays" xml:space="preserve">
<value>Erste Erinnerung (Tage)</value>
</data>
<data name="ReminderIntervalDays" xml:space="preserve">
<value>Erinnerungsintervall (Tage)</value>
</data>
<data name="ExpiresWhenDays" xml:space="preserve">
<value>Läuft ab nach (Tage)</value>
</data>
<data name="ExpiresWarningDays" xml:space="preserve">
<value>Ablaufwarnung (Tage)</value>
</data>
<data name="Message" xml:space="preserve">
<value>Nachricht</value>
</data>
<data name="EnvelopeType" xml:space="preserve">
<value>Umschlagtyp</value>
</data>
<data name="AllOptions" xml:space="preserve">
<value>Alle Optionen</value>
</data>
<data name="DeleteReason" xml:space="preserve">
<value>Grund für Löschung</value>
</data>
<data name="PleaseProvideReason" xml:space="preserve">
<value>Bitte geben Sie einen Grund an</value>
</data>
<data name="Status" xml:space="preserve">
<value>Status</value>
</data>
</root>

View File

@@ -477,4 +477,178 @@
<data name="Confirmations" xml:space="preserve">
<value>Confirmations</value>
</data>
<data name="NewEnvelope" xml:space="preserve">
<value>New Envelope</value>
</data>
<data name="LoadEnvelope" xml:space="preserve">
<value>Load Envelope</value>
</data>
<data name="DeleteEnvelope" xml:space="preserve">
<value>Delete Envelope</value>
</data>
<data name="RefreshData" xml:space="preserve">
<value>Reload Data</value>
</data>
<data name="RefreshedAt" xml:space="preserve">
<value>Refreshed: {0}</value>
</data>
<data name="ShowDocument" xml:space="preserve">
<value>Show Document</value>
</data>
<data name="ContactReceiver" xml:space="preserve">
<value>Contact Receiver</value>
</data>
<data name="EnvelopeId" xml:space="preserve">
<value>Envelope-ID: {0}</value>
</data>
<data name="OpenLogDirectory" xml:space="preserve">
<value>Open Log Directory</value>
</data>
<data name="ShowResultsReport" xml:space="preserve">
<value>Show Results Report</value>
</data>
<data name="SupportMail" xml:space="preserve">
<value>Support Mail</value>
</data>
<data name="ResendInvitation" xml:space="preserve">
<value>Send Invitation Again</value>
</data>
<data name="Export" xml:space="preserve">
<value>Export</value>
</data>
<data name="Receivers" xml:space="preserve">
<value>Receivers</value>
</data>
<data name="EmailSalutation" xml:space="preserve">
<value>Email Salutation</value>
</data>
<data name="SignedWhen" xml:space="preserve">
<value>Signed When</value>
</data>
<data name="AccessCode" xml:space="preserve">
<value>Access Code</value>
</data>
<data name="User" xml:space="preserve">
<value>User</value>
</data>
<data name="Type" xml:space="preserve">
<value>Type</value>
</data>
<data name="Title" xml:space="preserve">
<value>Title</value>
</data>
<data name="CreatedOn" xml:space="preserve">
<value>Created On</value>
</data>
<data name="LastModified" xml:space="preserve">
<value>Last Modified</value>
</data>
<data name="OpenEnvelopes" xml:space="preserve">
<value>Open Envelopes</value>
</data>
<data name="CompletedEnvelopes" xml:space="preserve">
<value>Completed Envelopes</value>
</data>
<data name="SendAccessCode" xml:space="preserve">
<value>Send Access Code</value>
</data>
<data name="TwoFactorProperties" xml:space="preserve">
<value>2-Factor Properties</value>
</data>
<data name="Name" xml:space="preserve">
<value>Name</value>
</data>
<data name="PhoneNumber" xml:space="preserve">
<value>Phone Number</value>
</data>
<data name="AddReceiver" xml:space="preserve">
<value>Add Receiver</value>
</data>
<data name="DeleteReceiver" xml:space="preserve">
<value>Delete Receiver</value>
</data>
<data name="AddFile" xml:space="preserve">
<value>Add File</value>
</data>
<data name="MergeFiles" xml:space="preserve">
<value>Merge Files</value>
</data>
<data name="DeleteFile" xml:space="preserve">
<value>Delete File</value>
</data>
<data name="ShowFile" xml:space="preserve">
<value>Show File</value>
</data>
<data name="EditFields" xml:space="preserve">
<value>Edit Fields</value>
</data>
<data name="EditData" xml:space="preserve">
<value>Edit Data</value>
</data>
<data name="Save" xml:space="preserve">
<value>Save</value>
</data>
<data name="SendEnvelope" xml:space="preserve">
<value>Send Envelope</value>
</data>
<data name="Cancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="AddSignature" xml:space="preserve">
<value>Add Signature</value>
</data>
<data name="DeleteSignature" xml:space="preserve">
<value>Delete Signature</value>
</data>
<data name="Language" xml:space="preserve">
<value>Language</value>
</data>
<data name="UseAccessCode" xml:space="preserve">
<value>Use Access Code</value>
</data>
<data name="TwoFactorEnabled" xml:space="preserve">
<value>2-Factor Authentication Enabled</value>
</data>
<data name="CertificationType" xml:space="preserve">
<value>Certification Type</value>
</data>
<data name="FinalEmailToCreator" xml:space="preserve">
<value>Final Email to Creator</value>
</data>
<data name="FinalEmailToReceivers" xml:space="preserve">
<value>Final Email to Receivers</value>
</data>
<data name="SendReminderEmails" xml:space="preserve">
<value>Send Reminder Emails</value>
</data>
<data name="FirstReminderDays" xml:space="preserve">
<value>First Reminder (Days)</value>
</data>
<data name="ReminderIntervalDays" xml:space="preserve">
<value>Reminder Interval (Days)</value>
</data>
<data name="ExpiresWhenDays" xml:space="preserve">
<value>Expires After (Days)</value>
</data>
<data name="ExpiresWarningDays" xml:space="preserve">
<value>Expiry Warning (Days)</value>
</data>
<data name="Message" xml:space="preserve">
<value>Message</value>
</data>
<data name="EnvelopeType" xml:space="preserve">
<value>Envelope Type</value>
</data>
<data name="AllOptions" xml:space="preserve">
<value>All Options</value>
</data>
<data name="DeleteReason" xml:space="preserve">
<value>Deletion Reason</value>
</data>
<data name="PleaseProvideReason" xml:space="preserve">
<value>Please provide a reason</value>
</data>
<data name="Status" xml:space="preserve">
<value>Status</value>
</data>
</root>

View File

@@ -477,4 +477,178 @@
<data name="Confirmations" xml:space="preserve">
<value>Confirmations</value>
</data>
<data name="NewEnvelope" xml:space="preserve">
<value>Nouvelle enveloppe</value>
</data>
<data name="LoadEnvelope" xml:space="preserve">
<value>Charger l'enveloppe</value>
</data>
<data name="DeleteEnvelope" xml:space="preserve">
<value>Supprimer l'enveloppe</value>
</data>
<data name="RefreshData" xml:space="preserve">
<value>Actualiser les données</value>
</data>
<data name="RefreshedAt" xml:space="preserve">
<value>Actualisé : {0}</value>
</data>
<data name="ShowDocument" xml:space="preserve">
<value>Afficher le document</value>
</data>
<data name="ContactReceiver" xml:space="preserve">
<value>Contacter le destinataire</value>
</data>
<data name="EnvelopeId" xml:space="preserve">
<value>ID d'enveloppe : {0}</value>
</data>
<data name="OpenLogDirectory" xml:space="preserve">
<value>Ouvrir le répertoire des logs</value>
</data>
<data name="ShowResultsReport" xml:space="preserve">
<value>Afficher le rapport de résultats</value>
</data>
<data name="SupportMail" xml:space="preserve">
<value>E-mail de support</value>
</data>
<data name="ResendInvitation" xml:space="preserve">
<value>Renvoyer l'invitation</value>
</data>
<data name="Export" xml:space="preserve">
<value>Exporter</value>
</data>
<data name="Receivers" xml:space="preserve">
<value>Destinataires</value>
</data>
<data name="EmailSalutation" xml:space="preserve">
<value>Formule de politesse</value>
</data>
<data name="SignedWhen" xml:space="preserve">
<value>Signé quand</value>
</data>
<data name="AccessCode" xml:space="preserve">
<value>Code d'accès</value>
</data>
<data name="User" xml:space="preserve">
<value>Utilisateur</value>
</data>
<data name="Type" xml:space="preserve">
<value>Type</value>
</data>
<data name="Title" xml:space="preserve">
<value>Titre</value>
</data>
<data name="CreatedOn" xml:space="preserve">
<value>Créé le</value>
</data>
<data name="LastModified" xml:space="preserve">
<value>Dernière modification</value>
</data>
<data name="OpenEnvelopes" xml:space="preserve">
<value>Enveloppes ouvertes</value>
</data>
<data name="CompletedEnvelopes" xml:space="preserve">
<value>Enveloppes terminées</value>
</data>
<data name="SendAccessCode" xml:space="preserve">
<value>Envoyer le code d'accès</value>
</data>
<data name="TwoFactorProperties" xml:space="preserve">
<value>Propriétés 2-facteurs</value>
</data>
<data name="Name" xml:space="preserve">
<value>Nom</value>
</data>
<data name="PhoneNumber" xml:space="preserve">
<value>Numéro de téléphone</value>
</data>
<data name="AddReceiver" xml:space="preserve">
<value>Ajouter un destinataire</value>
</data>
<data name="DeleteReceiver" xml:space="preserve">
<value>Supprimer le destinataire</value>
</data>
<data name="AddFile" xml:space="preserve">
<value>Ajouter un fichier</value>
</data>
<data name="MergeFiles" xml:space="preserve">
<value>Fusionner les fichiers</value>
</data>
<data name="DeleteFile" xml:space="preserve">
<value>Supprimer le fichier</value>
</data>
<data name="ShowFile" xml:space="preserve">
<value>Afficher le fichier</value>
</data>
<data name="EditFields" xml:space="preserve">
<value>Modifier les champs</value>
</data>
<data name="EditData" xml:space="preserve">
<value>Modifier les données</value>
</data>
<data name="Save" xml:space="preserve">
<value>Enregistrer</value>
</data>
<data name="SendEnvelope" xml:space="preserve">
<value>Envoyer l'enveloppe</value>
</data>
<data name="Cancel" xml:space="preserve">
<value>Annuler</value>
</data>
<data name="AddSignature" xml:space="preserve">
<value>Ajouter une signature</value>
</data>
<data name="DeleteSignature" xml:space="preserve">
<value>Supprimer la signature</value>
</data>
<data name="Language" xml:space="preserve">
<value>Langue</value>
</data>
<data name="UseAccessCode" xml:space="preserve">
<value>Utiliser un code d'accès</value>
</data>
<data name="TwoFactorEnabled" xml:space="preserve">
<value>Authentification à 2 facteurs activée</value>
</data>
<data name="CertificationType" xml:space="preserve">
<value>Type de certification</value>
</data>
<data name="FinalEmailToCreator" xml:space="preserve">
<value>E-mail final au créateur</value>
</data>
<data name="FinalEmailToReceivers" xml:space="preserve">
<value>E-mail final aux destinataires</value>
</data>
<data name="SendReminderEmails" xml:space="preserve">
<value>Envoyer des e-mails de rappel</value>
</data>
<data name="FirstReminderDays" xml:space="preserve">
<value>Premier rappel (jours)</value>
</data>
<data name="ReminderIntervalDays" xml:space="preserve">
<value>Intervalle de rappel (jours)</value>
</data>
<data name="ExpiresWhenDays" xml:space="preserve">
<value>Expire après (jours)</value>
</data>
<data name="ExpiresWarningDays" xml:space="preserve">
<value>Avertissement d'expiration (jours)</value>
</data>
<data name="Message" xml:space="preserve">
<value>Message</value>
</data>
<data name="EnvelopeType" xml:space="preserve">
<value>Type d'enveloppe</value>
</data>
<data name="AllOptions" xml:space="preserve">
<value>Toutes les options</value>
</data>
<data name="DeleteReason" xml:space="preserve">
<value>Motif de suppression</value>
</data>
<data name="PleaseProvideReason" xml:space="preserve">
<value>Veuillez indiquer une raison</value>
</data>
<data name="Status" xml:space="preserve">
<value>Statut</value>
</data>
</root>

View File

@@ -1,8 +1,10 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
namespace EnvelopeGenerator.Domain.Constants
{
// http://wiki.dd/xwiki13/bin/view/Anwendungen/Produkt-Handbuch/Sonstiges/SignFlow/Envelope%20Status/
// http://wiki.dd/xwiki_prod/bin/view/Anwendungen/Produkt-Handbuch/Sonstiges/signFLOW/signFLOW%20-%20Enwickler-Handbuch/4.%20Anhang/4.3%20Historie%20und%20Status%20der%20Umschl%C3%A4ge/
public enum EnvelopeStatus
{
Invalid = 0,
@@ -49,5 +51,28 @@ namespace EnvelopeGenerator.Domain.Constants
EnvelopeStatus.EnvelopeCreated,
EnvelopeStatus.DocumentMod_Rotation
};
public static readonly List<EnvelopeStatus> Active = Enum.GetValues(typeof(EnvelopeStatus))
.Cast<EnvelopeStatus>()
.Where(status => status.IsActive())
.ToList();
public static readonly List<EnvelopeStatus> Completed = Enum.GetValues(typeof(EnvelopeStatus))
.Cast<EnvelopeStatus>()
.Where(status => status.IsCompleted())
.ToList();
}
public static class EnvelopeStatusExtensions
{
public static bool IsActive(this EnvelopeStatus status)
{
return status >= EnvelopeStatus.EnvelopeCreated && status < EnvelopeStatus.EnvelopePartlySigned;
}
public static bool IsCompleted(this EnvelopeStatus status)
{
return status >= EnvelopeStatus.EnvelopeCompletelySigned && status <= EnvelopeStatus.EnvelopeWithdrawn;
}
}
}

View File

@@ -1,48 +0,0 @@
#if NET
using EnvelopeGenerator.Application.Common.Configurations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Infrastructure
{
public class EGDbContextFactory : IDesignTimeDbContextFactory<EGDbContext>
{
public EGDbContext CreateDbContext(string[] args)
{
var config = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.migration.json")
.Build();
// create DbContextOptions
var optionsBuilder = new DbContextOptionsBuilder<EGDbContext>();
optionsBuilder.UseSqlServer(config.GetConnectionString("Default"));
// create DbTriggerParams
var triggerLists = config.GetSection("DbTriggerParams").Get<Dictionary<string, List<string>>>();
var dbTriggerParams = new DbTriggerParams();
if (triggerLists is not null)
foreach (var triggerList in triggerLists)
{
if (triggerList.Value.Count == 0)
continue; // Skip empty trigger lists
var tableName = triggerList.Key;
dbTriggerParams[tableName] = new List<string>();
foreach (var trigger in triggerList.Value)
{
dbTriggerParams[tableName].Add(trigger);
}
}
var dbContext = new EGDbContext(optionsBuilder.Options, Options.Create(dbTriggerParams));
dbContext.IsMigration = true;
return dbContext;
}
}
}
#endif

View File

@@ -1,4 +1,6 @@
<Router AppAssembly="@typeof(Program).Assembly">
@using System.Globalization
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
@@ -7,4 +9,4 @@
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</Router>

View File

@@ -24,11 +24,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DevExpress.Blazor.PdfViewer" Version="25.2.3" />
<PackageReference Include="DevExpress.Blazor.Reporting.JSBasedControls" Version="25.2.3" />
<PackageReference Include="DevExpress.Blazor.Reporting.Viewer" Version="25.2.3" />
<PackageReference Include="DevExpress.Drawing.Skia" Version="25.2.3" />
<PackageReference Include="DevExpress.Blazor.PdfViewer" Version="25.2.8" />
<PackageReference Include="DevExpress.Blazor.Reporting.JSBasedControls" Version="25.2.8" />
<PackageReference Include="DevExpress.Blazor.Reporting.Viewer" Version="25.2.8" />
<PackageReference Include="DevExpress.Drawing.Skia" Version="25.2.8" />
<PackageReference Include="HarfBuzzSharp.NativeAssets.WebAssembly" Version="8.3.1.2" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="8.0.28" />
<PackageReference Include="Microsoft.Extensions.Localization" Version="8.0.11" />
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" Version="3.119.1" />
<PackageReference Include="SkiaSharp.Views.Blazor" Version="3.119.1" />
<NativeFileReference Include="$(HarfBuzzSharpStaticLibraryPath)\2.0.23\*.a" />
@@ -40,6 +42,9 @@
<ItemGroup>
<Folder Include="Properties\PublishProfiles\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\docs\privacy-policy.en-US.html">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>

View File

@@ -1,32 +0,0 @@
namespace EnvelopeGenerator.ReceiverUI.Models;
/// <summary>
/// Represents a pre-assigned signature annotation position on a specific page.
/// <br/><br/>
/// <b>Coordinate unit (X, Y):</b> Inches (GdPicture14 native unit),
/// origin at the <b>top-left</b> corner of the page, both axes increase downward/rightward.
/// <br/><br/>
/// <b>Conversion to DevExpress:</b> Multiply by 100 (DX uses 1/100 inch).
/// Convert: <c>xDX = xInches * 100.0</c>
/// <br/>
/// <b>Conversion to PDF Points:</b> Multiply by 72 (1 inch = 72 points).
/// Convert: <c>xPt = xInches * 72.0</c>
/// <br/>
/// <b>Y-axis for PDF (bottom-left origin):</b> Flip required for iText7.
/// Convert: <c>yPt = (pageHeightInches - yInches - elemHeightInches) * 72.0</c>
/// </summary>
[Obsolete("Use SignatureDto with SignatureService.")]
public record AnnotationDto
{
/// <summary>Unique identifier of the annotation.</summary>
public long Id { get; init; }
/// <summary>1-based page number within the document.</summary>
public int Page { get; init; }
/// <summary>Horizontal position in INCHES from the left edge of the page.</summary>
public double X { get; init; }
/// <summary>Vertical position in INCHES from the top edge of the page.</summary>
public double Y { get; init; }
}

View File

@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace EnvelopeGenerator.ReceiverUI.Models;
public class EnvelopeDto
{
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("uuid")]
public string? Uuid { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("status")]
public int Status { get; set; }
[JsonPropertyName("docResult")]
public byte[]? DocResult { get; set; }
[JsonPropertyName("envelopeReceivers")]
public List<EnvelopeReceiverSimpleDto> EnvelopeReceivers { get; set; } = new();
}
/// <summary>
/// Simplified receiver model for envelope list display
/// </summary>
public class EnvelopeReceiverSimpleDto
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("email")]
public string? Email { get; set; }
[JsonPropertyName("signed")]
public bool Signed { get; set; }
}

View File

@@ -11,7 +11,7 @@
@inject IOptions<ApiOptions> AppOptions
@inject IOptions<PdfViewerOptions> PdfViewerOptions
@inject IJSRuntime JSRuntime
@inject SignatureService SignatureService
@inject DocReceiverElementService SignatureService
@inject SignatureCacheService SignatureCacheService
@inject EnvelopeGenerator.ReceiverUI.Services.AuthService AuthService
@inject EnvelopeGenerator.ReceiverUI.Services.EnvelopeReceiverService EnvelopeReceiverService

View File

@@ -1,7 +1,442 @@
@page "/sender"
@attribute [Microsoft.AspNetCore.Authorization.Authorize(Policy = "Sender")]
<h3>EnvelopeSender</h3>
@using System.Text.Json
@using EnvelopeGenerator.Domain.Constants
@using EnvelopeGenerator.ReceiverUI.Models
@using DevExpress.Blazor
@using EnvelopeGenerator.ReceiverUI.Services
@inject EnvelopeGenerator.ReceiverUI.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.ReceiverUI.Services.AuthService AuthService
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/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">
<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 ?? new List<EnvelopeReceiverSimpleDto>();
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.Email</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">
<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 ?? new List<EnvelopeReceiverSimpleDto>();
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.Email</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.CheckSenderAsync();
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()
{
// TODO: Navigate to envelope creation page
JSRuntime.InvokeVoidAsync("console.log", "Create envelope clicked - not yet implemented");
}
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(int statusCode)
{
var status = (EnvelopeStatus)statusCode;
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

@@ -6,6 +6,7 @@
@using DevExpress.Utils
@using DevExpress.XtraPrinting
@using DevExpress.XtraPrinting.Drawing
@using EnvelopeGenerator.Application.Common.Dto
@using Microsoft.JSInterop
@using XtraReport = DevExpress.XtraReports.UI.XtraReport
@using BottomMarginBand = DevExpress.XtraReports.UI.BottomMarginBand
@@ -301,7 +302,10 @@ Shown="OnPopupShownAsync">
bool IsLoggingOut;
IReadOnlyList<AnnotationDto> _annotations = [];
IEnumerable<int> AnnotationPages => _annotations.Select(a => a.Page).Distinct().OrderBy(p => p);
IEnumerable<int> AnnotationPages => _annotations
.Select(a => a.Page ?? throw new InvalidOperationException($"Annotation page is missing for annotation ID {a.Id}. Annotation details: X={a.X}, Y={a.Y}"))
.Distinct()
.OrderBy(p => p);
EnvelopeReceiverDto? _envelopeReceiver;
record SignatureCapture(string DataUrl, string FullName, string Position, string Place);
SignatureCapture? _capturedSignature;

View File

@@ -7,6 +7,9 @@ using EnvelopeGenerator.ReceiverUI.Options;
using DevExpress.XtraReports.Services;
using DevExpress.Blazor.Reporting;
using DevExpress.XtraReports.Web.Extensions;
using EnvelopeGenerator.Application.Resources;
using Microsoft.Extensions.Localization;
using System.Globalization;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
@@ -20,9 +23,14 @@ builder.Services.AddScoped<DocumentService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<AnnotationService>();
builder.Services.AddScoped<EnvelopeReceiverService>();
builder.Services.AddScoped<SignatureService>();
builder.Services.AddScoped<DocReceiverElementService>();
builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddSingleton<AppVersionService>();
builder.Services.AddScoped<EnvelopeService>();
builder.Services.AddScoped<CultureService>();
// Localization services
builder.Services.AddLocalization();
builder.Services.AddDevExpressWebAssemblyBlazorReportViewer();
builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer();
@@ -41,5 +49,30 @@ builder.Services.AddScoped<IReportProviderAsync, CustomReportProvider>();
ReportStorageWebExtension.RegisterExtensionGlobal(new InMemoryReportStorageWebExtension());
var host = builder.Build();
// ⚠️ IMPORTANT: BLAZOR WASM-SPECIFIC CULTURE INITIALIZATION
// This approach sets DefaultThreadCurrentCulture globally, which is SAFE for WebAssembly
// because each user runs their own isolated app instance in their browser.
//
// ⚠️ TODO: REMOVE/REFACTOR WHEN MIGRATING TO BLAZOR SERVER/AUTO
// In Server/Auto render modes, this is DANGEROUS because:
// - Server runs a single shared instance for all users
// - Setting global culture affects ALL connected users simultaneously
// - Race conditions and culture conflicts will occur
//
// Migration Guide:
// - Option 1: Use RequestLocalizationMiddleware for per-request culture
// - Option 2: Use CascadingParameter with per-circuit culture state
// - See: https://learn.microsoft.com/aspnet/core/blazor/globalization-localization
//
// Related files to update on migration:
// - LanguageSelector.razor (remove manual culture setting)
// - App.razor (may need CascadingValue for culture)
// - Startup/Program.cs (add middleware)
var cultureService = host.Services.GetRequiredService<CultureService>();
var culture = await cultureService.InitializeCultureAsync();
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
await FontLoader.LoadFonts(host.Services.GetRequiredService<HttpClient>(), new List<string> { "opensans.ttf" });
await host.RunAsync();

View File

@@ -2,7 +2,7 @@
"profiles": {
"EnvelopeGenerator.ReceiverUI": {
"commandName": "Project",
"launchBrowser": true,
"launchBrowser": false,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},

View File

@@ -1,5 +1,6 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.ReceiverUI.Models;
using EnvelopeGenerator.ReceiverUI.Options;
using Microsoft.Extensions.Options;

View File

@@ -58,6 +58,16 @@ public class AuthService(HttpClient http, IOptions<ApiOptions> apiOptions)
return response.IsSuccessStatusCode;
}
/// <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> CheckSenderAsync(CancellationToken cancel = default)
{
var response = await http.GetAsync($"{_api.BaseUrl}/api/auth/check", cancel);
return response.StatusCode == HttpStatusCode.OK;
}
/// <summary>
/// Authenticates a sender user with username and password.
/// Calls POST /api/auth?cookie=true with JSON body.
@@ -78,4 +88,16 @@ public class AuthService(HttpClient http, IOptions<ApiOptions> apiOptions)
_ => SenderLoginResult.Error
};
}
/// <summary>
/// Logs out the sender user by removing the authentication cookie.
/// Calls POST /api/auth/logout.
/// </summary>
public async Task<bool> LogoutSenderAsync(CancellationToken cancel = default)
{
var response = await http.PostAsync(
$"{_api.BaseUrl}/api/auth/logout",
null, cancel);
return response.IsSuccessStatusCode;
}
}

View File

@@ -0,0 +1,74 @@
using System.Globalization;
using Microsoft.JSInterop;
namespace EnvelopeGenerator.ReceiverUI.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

@@ -6,13 +6,13 @@ using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.ReceiverUI.Services;
public class SignatureService(HttpClient http, IOptions<ApiOptions> apiOptions)
public class DocReceiverElementService(HttpClient http, IOptions<ApiOptions> apiOptions)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
public async Task<IReadOnlyList<SignatureDto>> GetAsync(string envelopeKey, CancellationToken cancel = default)
{
var url = $"{apiOptions.Value.BaseUrl}/api/Signature/{Uri.EscapeDataString(envelopeKey)}";
var url = $"{apiOptions.Value.BaseUrl}/api/DocReceiverElement/{Uri.EscapeDataString(envelopeKey)}";
var response = await http.GetAsync(url, cancel);
if (!response.IsSuccessStatusCode)

View File

@@ -0,0 +1,72 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.ReceiverUI.Models;
using EnvelopeGenerator.ReceiverUI.Options;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.ReceiverUI.Services;
/// <summary>
/// Retrieves <see cref="EnvelopeDto"/>s from the API.
/// </summary>
public class EnvelopeService
{
private readonly HttpClient _http;
private readonly ApiOptions _apiOptions;
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
public EnvelopeService(HttpClient http, IOptions<ApiOptions> apiOptions)
{
_http = http;
_apiOptions = apiOptions.Value;
}
/// <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 = $"{_apiOptions.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 response = await _http.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,80 @@
@using System.Globalization
@using EnvelopeGenerator.ReceiverUI.Services
@inject IJSRuntime JSRuntime
@inject NavigationManager Navigation
@inject CultureService CultureService
<div class="language-selector">
<button class="language-selector__trigger" @onclick="ToggleDropdown" aria-label="Select Language">
<span class="fi fi-@GetFlagCode(CurrentCulture)"></span>
<span class="language-selector__arrow">@GetLanguageName(CurrentCulture)</span>
</button>
@if (isOpen)
{
<div class="language-selector__dropdown">
<button class="language-selector__option" @onclick="@(() => ChangeLanguageAsync("de-DE"))">
<span class="fi fi-de"></span>
<span>Deutsch</span>
</button>
<button class="language-selector__option" @onclick="@(() => ChangeLanguageAsync("en-US"))">
<span class="fi fi-us"></span>
<span>English</span>
</button>
<button class="language-selector__option" @onclick="@(() => ChangeLanguageAsync("fr-FR"))">
<span class="fi fi-fr"></span>
<span>Français</span>
</button>
</div>
}
</div>
@code {
private bool isOpen = false;
private string CurrentCulture => CultureInfo.CurrentCulture.Name;
private void ToggleDropdown()
{
isOpen = !isOpen;
}
private async Task ChangeLanguageAsync(string culture)
{
if (CultureInfo.CurrentCulture.Name != culture)
{
await CultureService.SetCultureAsync(culture);
// Set culture without page reload
var cultureInfo = new CultureInfo(culture);
CultureInfo.DefaultThreadCurrentCulture = cultureInfo;
CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
// Navigate without reload to trigger re-render
Navigation.NavigateTo(Navigation.Uri, forceLoad: false);
}
isOpen = false;
}
private string GetFlagCode(string culture)
{
return culture switch
{
"de-DE" => "de",
"en-US" => "us",
"fr-FR" => "fr",
_ => "de"
};
}
private string GetLanguageName(string culture)
{
return culture switch
{
"de-DE" => "Deutsch",
"en-US" => "English",
"fr-FR" => "Français",
_ => "Deutsch"
};
}
}

View File

@@ -8,9 +8,12 @@
</article>
</main>
<footer class="receiver-footer">
<span>&copy; SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
<span class="receiver-footer__sep">&#124;</span>
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
<div class="receiver-footer__content">
<span>&copy; SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
<span class="receiver-footer__sep">&#124;</span>
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
</div>
<LanguageSelector />
</footer>
</div>

View File

@@ -15,3 +15,18 @@ article {
padding: 0 !important;
margin: 0 !important;
}
.receiver-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
gap: 1rem;
}
.receiver-footer__content {
display: flex;
align-items: center;
gap: 0.5rem;
}

View File

@@ -365,4 +365,75 @@ article {
.receiver-footer__sep {
opacity: 0.4;
}
}
/* ── Language Selector (Footer) ──────────────────────────────────────────── */
.language-selector {
position: relative;
display: inline-block;
}
.language-selector__trigger {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
}
.language-selector__trigger:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.language-selector__arrow {
font-size: 0.6rem;
transition: transform 0.2s ease;
opacity: 0.7;
}
.language-selector__dropdown {
position: absolute;
bottom: calc(100% + 0.5rem);
right: 0;
background: white;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 160px;
z-index: 1000;
overflow: hidden;
}
.language-selector__option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.65rem 1rem;
background: white;
border: none;
color: #333;
cursor: pointer;
font-size: 0.9rem;
transition: background-color 0.2s ease;
text-align: left;
}
.language-selector__option:hover {
background-color: #f5f5f5;
}
.language-selector__option .fi {
font-size: 1.25rem;
}
.language-selector__option span:last-child {
font-weight: 500;
}

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

@@ -12,6 +12,7 @@
<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="EnvelopeGenerator.ReceiverUI.styles.css" rel="stylesheet" />
<link href="css/app.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.2.3/css/flag-icons.min.css" />
<style type="text/css">
.splash-screen {
display: flex;

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,446 @@
@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">
<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">
<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()
{
// TODO: Navigate to envelope creation page
JSRuntime.InvokeVoidAsync("console.log", "Create envelope clicked - not yet implemented");
}
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,5 +1,4 @@
@page "/"
@rendermode InteractiveWebAssembly
@inject IJSRuntime JS
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
@@ -59,8 +58,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

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

@@ -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(Policy = AuthPolicy.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(Policy = AuthPolicy.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

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

@@ -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)
@@ -255,7 +260,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,8 +323,16 @@ 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();

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,104 @@
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.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.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)
{
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
};
}

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

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

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}"

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