Compare commits

...

25 Commits

Author SHA1 Message Date
e960c02cd4 Add AccessCode feature to EnvelopeSenderPage UI
Introduced a new `AccessCode` property in `EnvelopeReceiverDto` to support displaying and copying access codes in the envelope sender page.

- Updated `EnvelopeReceiverDto` to include the nullable `AccessCode` property.
- Enhanced `EnvelopeSenderPage.razor` to display the `AccessCode` for each receiver, with a copy-to-clipboard button.
- Implemented `CopyAccessCodeAsync` for clipboard functionality using JavaScript interop.
- Styled the `AccessCode` section in `sender-page.css` for a responsive and user-friendly design.
- Improved error handling for clipboard operations with console warnings.

These changes improve the user experience by allowing senders to view and copy access codes for receivers.
2026-09-28 12:47:37 +02:00
a0e2492757 Add email queuing for envelope invitation process
Introduced functionality to queue and send invitation emails to envelope receivers. Added new dependencies and private properties to support email handling, including repositories for `EmailTemplate` and `EmailOut`, as well as configuration services for `MailParams` and `DispatcherParams`.

Added the `QueueInvitationEmailsAsync` method to generate personalized email content using placeholders and store outgoing emails in the `EmailOut` repository. Introduced a helper method `ReplacePlaceHolders` for placeholder substitution in email templates.

Updated the `Handle` method to invoke `QueueInvitationEmailsAsync` after setting the envelope status to `EnvelopeQueued`. These changes ensure a modular and reusable email queuing process.
2026-09-28 12:35:51 +02:00
28f8b829e3 Refactor: Unify grid logic for active/completed tabs
Replaced separate `DxGrid` components for "active" and "completed" tabs with a single unified grid. Introduced dynamic properties (`CurrentGridData` and `CurrentGridKey`) to handle data and layout switching based on the active tab.

Refactored layout loading/saving methods into shared versions (`OnGridLayoutAutoLoading` and `OnGridLayoutAutoSaving`) using dynamic keys. Simplified export logic to use the unified grid instance.

Removed redundant code, templates, and private fields specific to the "completed" tab. Updated status filter logic to dynamically fetch data. Improved maintainability by consolidating logic and reducing duplication.
2026-09-28 12:06:35 +02:00
d00c459124 Optimize query with eager loading for related entities
Added `Include` statements to the LINQ query in the
`ReadEnvelopeQueryHandler` class to eagerly load related
entities (`Type`, `EnvelopeReceivers` with `Receiver`, and
`Histories`). This change reduces lazy-loading overhead and
ensures all necessary data is retrieved in a single query,
improving performance.
2026-09-28 11:57:41 +02:00
287c14e114 Refactor signed status logic in EnvelopeSenderPage
Introduce `GetSignedReceiverCount` and `IsReceiverSigned` helper methods to centralize and improve the logic for determining the signed status of receivers. Replace inline logic with these methods for better encapsulation and reusability.

Update UI rendering logic to dynamically determine receiver badge states based on the new methods. These changes enhance code maintainability, reduce duplication, and ensure consistent behavior across the application.
2026-09-28 11:55:31 +02:00
212ba9d569 Enhance grid filtering and sorting functionality
Updated `DxGrid` components for `_activeEnvelopes` and `_completedEnvelopes`:
- Disabled `ShowFilterRow` and added `FilterMenuButtonDisplayMode` to always display filter menu buttons.
- Enabled sorting for the `Id` column with descending order and added a display format.
- Added `EditSettings` to the `Status` column with a `DxComboBoxSettings` for filtering, using the new `GetStatusFilterItems` method.

Introduced `GetStatusFilterItems` to generate distinct status filter items and added a private `StatusFilterItem` record type for encapsulating status values and labels.

Made minor adjustments to grid layout auto-saving logic for consistency.
2026-09-28 11:40:22 +02:00
735f072dd1 Add localization support for multi-language UI
This commit introduces comprehensive localization support for the application, enabling dynamic language switching and localized UI elements. Key changes include:

- Added `.resx` files for German (de-DE), English (en-US), and French (fr-FR).
- Updated Razor components to use `IStringLocalizer` for dynamic string localization.
- Introduced the `L(string key, string fallback)` helper method for consistent localized string retrieval with fallback support.
- Implemented dynamic culture initialization in `Program.cs` via an API call to `/api/Localization/lang`.
- Localized error pages, grid columns, tabs, popups, validation messages, and tooltips.
- Dynamically generated privacy policy links based on the current culture.
- Improved user experience with fully localized loading states, error handling, and UI labels.

These changes enhance accessibility and usability, making the application more user-friendly for a global audience.
2026-09-28 10:46:06 +02:00
48cfd3f416 Add French localization resource file
Added `Model.fr-FR.resx` to support French localization.
Defined XML schema for the resource file structure, including
metadata, assembly, and data elements. Added localized
French strings for various application messages, ensuring
proper display when the locale is set to `fr-FR`.
2026-09-28 09:40:56 +02:00
0d2e43b2d6 Add multilingual support with language selector
Introduced a `LanguageControls` component for switching between
languages (`de-DE`, `en-US`, `fr-FR`) and integrated a new
`LocalizationService` to manage language preferences. Updated
`LocalizationController` to handle language persistence via
cookies and support multiple cultures. Enhanced the footer
layout to include the language selector alongside theme
controls.

Added `flag-icons` library for displaying country flags and
configured request localization in `Program.cs`. Styled the
language selector dropdown and improved sender page visuals
with updated CSS. Defaulted language handling to `de-DE` for
fallback scenarios. Cleaned up redundant code in
`LocalizationController`.
2026-09-28 09:19:46 +02:00
5aaa37b8ec Default localization to "de-DE" and refactor resources
Added a new `Model` class in the `EnvelopeGenerator.Domain.Resources` namespace as a marker for `IStringLocalizer` resource lookup. Updated `EnvelopeSenderPage.razor` to use the new `Model` class for localization.

Modified `LocalizationController` to default to "de-DE" when no culture is specified or found. Refactored localization setup in `Program.cs` to use a fixed default culture of "de-DE" and persist culture via cookies. Removed dynamic culture setup and `QueryStringRequestCultureProvider`.

Performed minor cleanup in `LocalizationController` and `Program.cs` to align with the new localization approach.
2026-09-28 08:46:11 +02:00
051a8ce211 Add history display and enhance status handling
Introduced a "Historie" section in `EnvelopeSenderPage.razor` to display envelope history with status badges, actor info, and timestamps. Updated `ReadEnvelopeQueryHandler` to include histories in queries.

Refactored `GetStatusInfo` to use localized labels and expanded supported statuses. Added helper methods for history processing and display.

Enhanced UI with new styles for the history section in `sender-page.css` and improved responsive design. Added dependency injections for localization and history handling.
2026-09-28 08:39:39 +02:00
519a8bf3f9 Refactor sender resolution and add history logging
Refactored sender email resolution into a new private async
method `ResolveSenderReferenceAsync` to improve code
reusability and readability. Replaced inline logic for
resolving sender references with this method.

Added a `CreateHistoryCommand` to log envelope creation
events, including envelope ID, sender reference, and status
(`EnvelopeCreated`). This enhances auditability and ensures
consistent event logging.
2026-09-27 20:20:01 +02:00
a7d0b40e39 Refactor DocReceiverElement handling and add draft workflow
Removed `IDocReceiverElementWriter` interface and its implementation, replacing it with repository-based operations (`IRepository<DocReceiverElement>`). Updated `CreateEnvelopeCommandHandler` to use repository methods for deleting and creating `DocReceiverElement` records.

Cleaned up dependency injection by removing `IDocReceiverElementWriter` registration. Added new database triggers for `DocReceiverElement` in `appsettings.json` and `appsettings.Database.json`.

Introduced `SENDER_SAVE_EDIT_MIGRATION_PLAN.md` to outline the migration of the sender save/edit workflow to Blazor Server. This includes splitting save and send actions, supporting draft persistence, and enabling draft editing from the dashboard.

Removed unused namespaces and performed code cleanup. Updated testing strategies to ensure correctness and prevent regressions.
2026-09-27 19:56:02 +02:00
3dc9a180d3 docs(agents): align sender workflow endpoint guidance with upsert API
- replace outdated draft/update/send endpoint list with current POST /api/Envelope workflow contract
2026-09-26 13:11:50 +02:00
4dba965c75 feat(sender): route editor save and send through unified envelope upsert API
- expose POST /api/Envelope for sender-authenticated create/update/send operations

- replace client draft-specific call with EnvelopeService.UpsertAsync(CreateEnvelopeCommand)

- update sender editor save/send flow to persist draft first and send by envelope id

- allow editor hydration without document by making sender document fetch nullable
2026-09-26 13:11:44 +02:00
4d2538cc2c refactor(envelope): consolidate draft flow into upsert command
- extend CreateEnvelopeCommand to support draft updates and send intent

- merge draft save logic into CreateEnvelopeCommandHandler and remove duplicate handler

- add DocReceiverElement writer abstraction and infrastructure registration for SQL-based field upserts
2026-09-26 13:11:28 +02:00
d201eacc97 Unify Blazor frontend and API into EnvelopeGenerator.Server
The `EnvelopeGenerator.Server` project now serves as the unified runtime host for both the Blazor frontend and Web API backend, deprecating the `EnvelopeGenerator.API` project. All API endpoints have been merged into `EnvelopeGenerator.Server`.

Key changes:
- Updated `EnvelopeGenerator.Server` to include API controllers.
- Deprecated `EnvelopeGenerator.API` and removed it from the active solution.
- Simplified development workflow to run only `EnvelopeGenerator.Server`.
- Updated `appsettings.json` to consolidate configuration settings.
- Adjusted documentation to reflect the new unified architecture.
- Updated manual testing steps and environment variable references.
- Migrated legacy VB.NET app references to the new architecture.

These changes simplify development, deployment, and testing workflows while consolidating the solution architecture.
2026-09-25 14:26:16 +02:00
9daa671655 chore(solution): remove API project entry from solution 2026-09-25 14:22:08 +02:00
3fbf011fe3 refactor(envelope): update delete handler with explicit field update 2026-09-25 14:21:56 +02:00
a815705fa4 feat(sender): support draft editing flow in sender pages 2026-09-25 14:21:23 +02:00
6e44d0d4b5 feat(sender): add envelope draft save command and endpoint 2026-09-25 14:21:08 +02:00
42a853cb0d refactor(API): removed because migrated to the server-application 2026-09-25 12:56:46 +02:00
b46c3c8ebe feat(sender): add envelope delete popup and client call 2026-09-25 11:31:53 +02:00
9f712a6b8c test: cover delete envelope command scenarios 2026-09-25 11:31:47 +02:00
4449743105 feat: add envelope delete command and API endpoint 2026-09-25 11:31:40 +02:00
94 changed files with 2883 additions and 3916 deletions

View File

@@ -69,9 +69,9 @@ This section explains every markdown file in the repo: what it contains, why it
- **EnvelopeGenerator.Server** (Server): `@rendermode InteractiveServer` - PDF viewers requiring DevExpress backend
- **EnvelopeGenerator.Server.Client** (WASM): `@rendermode InteractiveWebAssembly` - Login, dashboards, business logic
**Backend:** EnvelopeGenerator.API (ASP.NET Core 8.0)
**Backend + API Host:** `EnvelopeGenerator.Server` (ASP.NET Core 8.0, merged Blazor + Web API host)
**Proxy:** YARP in EnvelopeGenerator.Server routes `/api/*` → `localhost:8088` (API)
**Proxy:** YARP in `EnvelopeGenerator.Server` is used for external integrations (for example AuthHub forwarding), not for routing app APIs to `EnvelopeGenerator.API`.
### Deprecated Projects - DO NOT USE
- `EnvelopeGenerator.ReceiverUI` - Pure WASM (migrated to Server)
@@ -80,18 +80,14 @@ This section explains every markdown file in the repo: what it contains, why it
## Development Commands
### Run Both Projects (Required)
### Run Active Host
```powershell
# Terminal 1 - API Backend
cd EnvelopeGenerator.API
dotnet run
# Terminal 2 - Blazor Frontend
# Terminal - Blazor + Web API host
cd EnvelopeGenerator.Server\EnvelopeGenerator.Server
dotnet run
```
**Critical:** Both must run simultaneously. EnvelopeGenerator.Server proxy forwards `/api/*` to API.
**Critical:** `EnvelopeGenerator.Server` is the active runtime host for both UI and API controllers.
### Build
```powershell
@@ -104,10 +100,10 @@ dotnet build EnvelopeGenerator.sln
EnvelopeGenerator.Domain/ # Entities (Envelope, Receiver, Document, etc.)
EnvelopeGenerator.Application/ # MediatR CQRS (Commands, Queries, Handlers)
EnvelopeGenerator.Infrastructure/ # EF Core, SQL executors, repositories
EnvelopeGenerator.API/ # Controllers, endpoints
EnvelopeGenerator.Server/
EnvelopeGenerator.Server/ # Server-side Blazor components
EnvelopeGenerator.Server/ # Server-side Blazor + API controllers
├─ Components/Pages/ # @rendermode InteractiveServer
├─ Controllers/ # Active Web API endpoints
EnvelopeGenerator.Server.Client/ # Client-side WASM components
├─ Pages/ # @rendermode InteractiveWebAssembly
├─ Services/ # HTTP API clients
@@ -156,15 +152,13 @@ float inches = (pixelX / canvasWidth) * pageWidthInches;
- **Use case:** External API consumers
- **Not suitable for:** Step-by-step UI workflow (no draft support, no partial updates)
### Missing Granular Endpoints (Need to Create)
### Sender Workflow Endpoint
```
POST /api/Envelope/draft # Create draft envelope
PUT /api/Envelope/{id} # Update metadata
POST /api/Envelope # Create/update/save/send envelope workflow payload
DELETE /api/Envelope/{id} # Delete with reason
POST /api/Envelope/{id}/document # Upload PDF
POST /api/Envelope/{id}/receivers # Add receiver
POST /api/Envelope/{id}/signature-fields # Place signature field
POST /api/Envelope/{id}/send # Send to receivers
```
See `FORM_APPLICATION_CONTEXT.md` for detailed workflow requirements.
@@ -199,7 +193,7 @@ Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:
}
```
### API Config (`API/appsettings.json`)
### Server Host Config (`EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json`)
- `ConnectionStrings:Default` - SQL Server DB
- `AllowedOrigins` - CORS (includes `http://localhost:5131`, `http://localhost:7192`)
- `Cache:SignatureCacheExpiration` - Signature persistence timeout
@@ -237,16 +231,15 @@ Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:
**No automated tests exist yet.**
Manual testing workflow:
1. Start API (`dotnet run` in `EnvelopeGenerator.API`)
2. Start EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`)
3. Navigate to `https://localhost:5131` (or check console output for port)
4. Test sender login at `/sender/login`
5. Test receiver flow at `/envelope/login/{envelopeKey}`
1. Start EnvelopeGenerator.Server (`dotnet run` in `EnvelopeGenerator.Server\EnvelopeGenerator.Server`)
2. Navigate to `https://localhost:5131` (or check console output for port)
3. Test sender login at `/sender/login`
4. Test receiver flow at `/envelope/login/{envelopeKey}`
## Database
**SQL Server** (DD_ECM)
- Connection string in `API/appsettings.json`
- Connection string in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json`
- EF Core migrations NOT used (manual SQL scripts)
- Stored procedures: `PRSIG_*` prefix
@@ -320,5 +313,4 @@ Each envelope maintains independent authentication state.
None required. All config in `appsettings.json`.
**Local dev ports:**
- API: `https://localhost:8088`
- WebUI: `https://localhost:5131` or `http://localhost:7192`
- App host (UI + API): `https://localhost:5131` or `http://localhost:7192`

View File

@@ -39,7 +39,7 @@ This project contains:
- `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`
- `EnvelopeGenerator.API` — deprecated project (no longer part of the active solution/runtime path)
### Legacy / Do Not Touch
- `EnvelopeGenerator.Service`
@@ -121,7 +121,7 @@ Current controller set includes:
- `SignatureController`
- `TfaRegistrationController`
Do not assume API behavior lives only in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly.
Do not assume API behavior lives in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly.
---

View File

@@ -1,17 +0,0 @@
namespace EnvelopeGenerator.API;
/// <summary>
///
/// </summary>
public static class AuthScheme
{
/// <summary>
/// Scheme name used for per-envelope receiver JWT authentication.
/// </summary>
public const string Receiver = "EnvelopeGenerator.API.ReceiverJWT";
/// <summary>
/// Scheme name used for per-envelope sender JWT authentication.
/// </summary>
public const string Sender = "EnvelopeGenerator.API.SenderJWT";
}

View File

@@ -1,132 +0,0 @@
using DigitalData.Core.Abstraction.Application.DTO;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.API.Extensions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.Common.Notifications.DocSigned;
using EnvelopeGenerator.Application.Common.Notifications.RemoveSignature;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Histories.Queries;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Manages annotations and signature lifecycle for envelopes.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[ApiController]
[Route("api/[controller]")]
public class AnnotationController : ControllerBase
{
[Obsolete("Use MediatR")]
private readonly IEnvelopeHistoryService _historyService;
[Obsolete("Use MediatR")]
private readonly IEnvelopeReceiverService _envelopeReceiverService;
private readonly IMediator _mediator;
private readonly ILogger<AnnotationController> _logger;
/// <summary>
/// Initializes a new instance of <see cref="AnnotationController"/>.
/// </summary>
[Obsolete("Use MediatR")]
public AnnotationController(
ILogger<AnnotationController> logger,
IEnvelopeHistoryService envelopeHistoryService,
IEnvelopeReceiverService envelopeReceiverService,
IMediator mediator)
{
_historyService = envelopeHistoryService;
_envelopeReceiverService = envelopeReceiverService;
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// Creates or updates annotations for the authenticated envelope receiver.
/// </summary>
/// <param name="psPdfKitAnnotation">Annotation payload.</param>
/// <param name="cancel">Cancellation token.</param>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost]
[Obsolete("PSPDF Kit will no longer be used.")]
public async Task<IActionResult> CreateOrUpdate([FromBody] PsPdfKitAnnotation? psPdfKitAnnotation = null, CancellationToken cancel = default)
{
var signature = User.ReceiverSignature();
var uuid = User.EnvelopeUuid();
var envelopeReceiver = await _mediator.ReadEnvelopeReceiverAsync(uuid, signature, cancel).ThrowIfNull(Exceptions.NotFound);
if (!envelopeReceiver.Envelope!.ReadOnly && psPdfKitAnnotation is null)
return BadRequest();
if (await _mediator.IsSignedAsync(uuid, signature, cancel))
return Problem(statusCode: StatusCodes.Status409Conflict);
else if (await _mediator.AnyHistoryAsync(uuid, new[] { EnvelopeStatus.EnvelopeRejected, EnvelopeStatus.DocumentRejected }, cancel))
return Problem(statusCode: StatusCodes.Status423Locked);
var envelopeReceiverDto = await _mediator.ReadEnvelopeReceiverAsync(uuid, signature, cancel);
var docSignedNotification = envelopeReceiverDto is not null
? new DocSignedNotification { EnvelopeReceiver = envelopeReceiverDto, PsPdfKitAnnotation = psPdfKitAnnotation }
: throw new NotFoundException("Envelope receiver is not found.");
try
{
await _mediator.Publish(docSignedNotification, cancel);
}
catch (Exception)
{
await _mediator.Publish(new RemoveSignatureNotification()
{
EnvelopeId = docSignedNotification.EnvelopeReceiver.EnvelopeId,
ReceiverId = docSignedNotification.EnvelopeReceiver.ReceiverId
}, cancel);
throw;
}
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Ok();
}
/// <summary>
/// Rejects the document for the current receiver.
/// </summary>
/// <param name="reason">Optional rejection reason.</param>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("reject")]
[Obsolete("Use MediatR")]
public async Task<IActionResult> Reject([FromBody] string? reason = null)
{
var signature = User.ReceiverSignature();
var uuid = User.EnvelopeUuid();
var mail = User.ReceiverMail();
var envRcvRes = await _envelopeReceiverService.ReadByUuidSignatureAsync(uuid: uuid, signature: signature);
if (envRcvRes.IsFailed)
{
_logger.LogNotice(envRcvRes.Notices);
return Unauthorized("you are not authorized");
}
var histRes = await _historyService.RecordAsync(envRcvRes.Data.EnvelopeId, userReference: mail, EnvelopeStatus.DocumentRejected, comment: reason);
if (histRes.IsSuccess)
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return NoContent();
}
_logger.LogEnvelopeError(uuid: uuid, signature: signature, message: "Unexpected error happened in api/envelope/reject");
_logger.LogNotice(histRes.Notices);
return StatusCode(500, histRes.Messages);
}
}

View File

@@ -1,117 +0,0 @@
using DigitalData.Auth.Claims;
using EnvelopeGenerator.API.Controllers.Interfaces;
using EnvelopeGenerator.API.Models;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Controller verantwortlich für die Benutzer-Authentifizierung, einschließlich Anmelden, Abmelden und Überprüfung des Authentifizierungsstatus.
/// </summary>
[Route("api/[controller]")]
[ApiController]
public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions, IAuthorizationService authService) : ControllerBase, IAuthController
{
private readonly AuthTokenKeys authTokenKeys = authTokenKeyOptions.Value;
/// <summary>
///
/// </summary>
public IAuthorizationService AuthService { get; } = authService;
/// <summary>
/// Entfernt das Authentifizierungs-Cookie des Benutzers (AuthCookie)
/// </summary>
/// <returns>
/// Gibt eine HTTP 200 oder 401.
/// </returns>
/// <remarks>
/// Sample request:
///
/// POST /api/auth/logout
///
/// </remarks>
/// <response code="200">Erfolgreich gelöscht, wenn der Benutzer ein berechtigtes Cookie hat.</response>
/// <response code="401">Wenn es kein zugelassenes Cookie gibt, wird „nicht zugelassen“ zurückgegeben.</response>
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpPost("logout")]
public async Task<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();
return Ok();
}
/// <summary>
/// Prüft, ob der Benutzer ein autorisiertes Token hat.
/// </summary>
/// <returns>Wenn ein autorisiertes Token vorhanden ist HTTP 200 asynchron 401</returns>
/// <remarks>
/// Sample request:
///
/// GET /api/auth
///
/// </remarks>
/// <response code="200">Wenn es einen autorisierten Cookie gibt.</response>
/// <response code="401">Wenn kein Cookie vorhanden ist oder nicht autorisierte.</response>
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[HttpGet("check")]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public IActionResult Check(string? role = null)
=> role is not null && !User.IsInRole(role)
? Unauthorized()
: Ok();
/// <summary>
/// Checks whether the caller holds a valid per-envelope receiver token for the given envelope key.
/// The request must carry a cookie named <c>AuthTokenSignFLOWReceiver.{envelopeKey}</c>.
/// </summary>
/// <param name="envelopeKey">The unique envelope key extracted from the route.</param>
/// <response code="200">Valid per-envelope token found.</response>
/// <response code="401">Token is missing, expired or invalid.</response>
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpGet("check/envelope/{envelopeKey}")]
public IActionResult CheckEnvelopeReceiver([FromRoute] string envelopeKey) => Ok();
/// <summary>
/// Removes the per-envelope receiver cookie for the given envelope key.
/// </summary>
/// <param name="envelopeKey">The unique envelope key whose cookie should be deleted.</param>
/// <response code="200">Cookie successfully deleted.</response>
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[HttpPost("logout/envelope/{envelopeKey}")]
public IActionResult LogoutEnvelopeReceiver([FromRoute] string envelopeKey)
{
var cookieName = CookieNames.GetEnvelopeReceiverCookieName(authTokenKeys.Cookie, envelopeKey);
Response.Cookies.Delete(cookieName);
return Ok();
}
/// <summary>
/// Removes all per-envelope receiver cookies from the current request.
/// </summary>
/// <response code="200">All envelope receiver cookies successfully deleted.</response>
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
[HttpPost("logout/envelope")]
public IActionResult LogoutAllEnvelopeReceivers()
{
foreach (var cookieName in Request.Cookies.Keys.Where(k => CookieNames.IsEnvelopeReceiverCookie(k, authTokenKeys.Cookie)))
Response.Cookies.Delete(cookieName);
return Ok();
}
}

View File

@@ -1,84 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using System.Text.Json;
using EnvelopeGenerator.API.Options;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.API.Extensions;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Manages cached data for receivers using distributed cache.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = AuthPolicy.Receiver)]
public class CacheController(
IDistributedCache cache,
IOptions<CacheOptions> cacheOptions) : ControllerBase
{
private const string SignatureCacheKeyPrefix = "envelope-generator.receiver-ui.signature:";
/// <summary>
/// Stores a receiver's signature in cache for the specified envelope.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("SignatureCapture/{envelopeKey}")]
public async Task<IActionResult> SaveSignature(
[FromRoute] string envelopeKey,
[FromBody] SignatureCacheRequest request,
CancellationToken cancel)
{
var cacheKey = $"{SignatureCacheKeyPrefix}{User.ReceiverSignature()}";
var json = JsonSerializer.Serialize(request);
var options = cacheOptions.Value.SignatureCacheExpiration.HasValue
? new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = cacheOptions.Value.SignatureCacheExpiration.Value }
: null;
await cache.SetStringAsync(cacheKey, json, options ?? new DistributedCacheEntryOptions(), cancel);
return Ok();
}
/// <summary>
/// Retrieves a cached signature for the specified envelope.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpGet("SignatureCapture/{envelopeKey}")]
public async Task<IActionResult> GetSignature([FromRoute] string envelopeKey, CancellationToken cancel)
{
var cacheKey = $"{SignatureCacheKeyPrefix}{User.ReceiverSignature()}";
var json = await cache.GetStringAsync(cacheKey, cancel);
if (json is null)
return NotFound();
var signature = JsonSerializer.Deserialize<SignatureCacheRequest>(json);
return Ok(signature);
}
/// <summary>
/// Deletes a cached signature for the specified envelope.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpDelete("SignatureCapture/{envelopeKey}")]
public async Task<IActionResult> DeleteSignature([FromRoute] string envelopeKey, CancellationToken cancel)
{
var cacheKey = $"{SignatureCacheKeyPrefix}{User.ReceiverSignature()}";
await cache.RemoveAsync(cacheKey, cancel);
return Ok();
}
}
/// <summary>
/// Request model for caching signature data.
/// </summary>
public sealed record SignatureCacheRequest(
string DataUrl,
string FullName,
string Place,
string? Position = null);

View File

@@ -1,31 +0,0 @@
using EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Exposes configuration data required by the client applications.
/// </summary>
/// <remarks>
/// Initializes a new instance of <see cref="ConfigController"/>.
/// </remarks>
[Route("api/[controller]")]
[ApiController]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
public class ConfigController(IOptionsMonitor<AnnotationParams> annotationParamsOptions) : ControllerBase
{
private readonly AnnotationParams _annotationParams = annotationParamsOptions.CurrentValue;
/// <summary>
/// Returns annotation configuration that was previously rendered by MVC.
/// </summary>
[HttpGet("Annotations")]
[Obsolete("PSPDF Kit will no longer be used.")]
public IActionResult GetAnnotationParams()
{
return Ok(_annotationParams.AnnotationJSObject);
}
}

View File

@@ -1,57 +0,0 @@
using EnvelopeGenerator.API.Extensions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
///
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[ApiController]
[Route("api/[controller]")]
public class DocReceiverElementController : ControllerBase
{
private readonly IMediator _mediator;
/// <summary>
/// Initializes a new instance of <see cref="DocReceiverElementController"/>.
/// </summary>
public DocReceiverElementController(IMediator mediator)
{
_mediator = mediator;
}
//TODO: update to use signature query
/// <summary>
///
/// </summary>
/// <param name="envelopeKey"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpGet("{envelopeKey}")]
public async Task<IActionResult> Get(string envelopeKey, CancellationToken cancel)
{
int envelopeId = User.EnvelopeId();
int receiverId = User.ReceiverId();
var doc = await _mediator.Send(new ReadDocumentQuery() { EnvelopeId = envelopeId }, cancel);
if (doc.Elements is not IEnumerable<DocReceiverElementDto> docSignatures)
return NotFound("Document is empty.");
var rcvSignatures = docSignatures.Where(s => s.ReceiverId == receiverId).ToList();
if (rcvSignatures is null)
return NotFound("No signatures found for the current receiver.");
else
return Ok(rcvSignatures);
}
}

View File

@@ -1,84 +0,0 @@
using DigitalData.Auth.Claims;
using EnvelopeGenerator.API.Controllers.Interfaces;
using EnvelopeGenerator.API.Extensions;
using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Provides access to envelope documents for authenticated receivers.
/// </summary>
/// <remarks>
/// Initializes a new instance of the <see cref="DocumentController"/> class.
/// </remarks>
[ApiController]
[Route("api/[controller]")]
public class DocumentController(IMediator mediator, IAuthorizationService authService, ILogger<DocumentController> logger) : ControllerBase, IAuthController
{
/// <summary>
///
/// </summary>
public IAuthorizationService AuthService => authService;
/// <summary>
/// Returns the document bytes receiver.
/// </summary>
/// <param name="query">Encoded envelope key.</param>
/// <param name="cancel">Cancellation token.</param>
[HttpGet]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
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.");
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();
}
/// <summary>
/// Gets the document for the specified envelope key.
/// </summary>
/// <param name="envelopeKey"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpGet("{envelopeKey}")]
public async Task<IActionResult> GetDocumentOfReceiver(string envelopeKey, CancellationToken cancel)
{
int envelopeId = User.EnvelopeId();
var senderDoc = await mediator.Send(new ReadDocumentQuery() { EnvelopeId = envelopeId }, cancel);
if (senderDoc.ByteData is not byte[] senderDocByte)
return NotFound("Document is empty.");
Response.Headers.ContentDisposition = $"inline; filename=\"{envelopeKey}.pdf\"";
return File(senderDocByte, "application/pdf");
}
}

View File

@@ -1,69 +0,0 @@
using AutoMapper;
using EnvelopeGenerator.Application.EmailTemplates.Commands;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MediatR;
using EnvelopeGenerator.Application.Common.Dto;
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Application.EmailTemplates.Queries;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Controller for managing temp templates.
/// Steuerung zur Verwaltung von E-Mail-Vorlagen.
/// </summary>
/// <remarks>
/// Initialisiert eine neue Instanz der <see cref="EmailTemplateController"/>-Klasse.
/// </remarks>
/// <param name="mediator">
/// Die Mediator-Instanz, die zum Senden von Befehlen und Abfragen verwendet wird.
/// </param>
[Route("api/[controller]")]
[ApiController]
[Authorize(Policy = AuthPolicy.Sender)]
public class EmailTemplateController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Ruft E-Mail-Vorlagen basierend auf der angegebenen Abfrage ab.
/// Gibt alles zurück, wenn keine Id- oder Typ-Informationen eingegeben wurden.
/// </summary>
/// <param name="emailTemplate">Die Abfrageparameter zum Abrufen von E-Mail-Vorlagen.</param>
/// <param name="cancel"></param>
/// <returns>Gibt HTTP-Antwort zurück</returns>
/// <remarks>
/// Sample request:
/// GET /api/EmailTemplate?emailTemplateId=123
/// </remarks>
/// <response code="200">Wenn die E-Mail-Vorlagen erfolgreich abgerufen werden.</response>
/// <response code="400">Wenn die Abfrageparameter ungültig sind.</response>
/// <response code="401">Wenn der Benutzer nicht authentifiziert ist.</response>
/// <response code="404">Wenn die gesuchte Abfrage nicht gefunden wird.</response>
[HttpGet]
public async Task<IActionResult> Get([FromQuery] ReadEmailTemplateQuery emailTemplate, CancellationToken cancel)
{
var result = await mediator.Send(emailTemplate, cancel);
return Ok(result);
}
/// <summary>
/// Updates an temp template or resets it if no update command is provided.
/// Aktualisiert eine E-Mail-Vorlage oder setzt sie zurück, wenn kein Aktualisierungsbefehl angegeben ist.
/// </summary>
/// <param name="update"></param>
/// <param name="cancel"></param>
/// <returns></returns>
/// <response code="200">Wenn die E-Mail-Vorlage erfolgreich aktualisiert oder zurückgesetzt wird.</response>
/// <response code="400">Wenn die Abfrage ohne einen String gesendet wird.</response>
/// <response code="401">Wenn der Benutzer nicht authentifiziert ist.</response>
/// <response code="404">Wenn die gesuchte Abfrage nicht gefunden wird.</response>
[HttpPut]
public async Task<IActionResult> Update([FromBody] UpdateEmailTemplateCommand update, CancellationToken cancel)
{
await mediator.Send(update, cancel);
return Ok();
}
}

View File

@@ -1,111 +0,0 @@
using EnvelopeGenerator.API.Extensions;
using EnvelopeGenerator.Application.Envelopes.Commands;
using EnvelopeGenerator.Application.Envelopes.Queries;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Dieser Controller stellt Endpunkte für die Verwaltung von Umschlägen bereit.
/// </summary>
/// <remarks>
/// Die API ermöglicht das Abrufen und Verwalten von Umschlägen basierend auf Benutzerinformationen und Statusfiltern.
///
/// Mögliche Antworten:
/// - 200 OK: Die Anfrage war erfolgreich, und die angeforderten Daten werden zurückgegeben.
/// - 400 Bad Request: Die Anfrage war fehlerhaft oder unvollständig.
/// - 401 Unauthorized: Der Benutzer ist nicht authentifiziert.
/// - 403 Forbidden: Der Benutzer hat keine Berechtigung, auf die Ressource zuzugreifen.
/// - 404 Not Found: Die angeforderte Ressource wurde nicht gefunden.
/// - 500 Internal Server Error: Ein unerwarteter Fehler ist aufgetreten.
/// </remarks>
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class EnvelopeController : ControllerBase
{
private readonly ILogger<EnvelopeController> _logger;
private readonly IMediator _mediator;
/// <summary>
/// Erstellt eine neue Instanz des EnvelopeControllers.
/// </summary>
/// <param name="logger">Der Logger, der für das Protokollieren von Informationen verwendet wird.</param>
/// <param name="mediator"></param>
public EnvelopeController(ILogger<EnvelopeController> logger, IMediator mediator)
{
_logger = logger;
_mediator = mediator;
}
/// <summary>
/// Ruft eine Liste von Umschlägen basierend auf dem Benutzer und den angegebenen Statusfiltern ab.
/// </summary>
/// <param name="envelope"></param>
/// <returns>Eine IActionResult-Instanz, die die abgerufenen Umschläge oder einen Fehlerstatus enthält.</returns>
/// <response code="200">Die Anfrage war erfolgreich, und die Umschläge werden zurückgegeben.</response>
/// <response code="400">Die Anfrage war fehlerhaft oder unvollständig.</response>
/// <response code="401">Der Benutzer ist nicht authentifiziert.</response>
/// <response code="403">Der Benutzer hat keine Berechtigung, auf die Ressource zuzugreifen.</response>
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpGet]
public async Task<IActionResult> GetAsync([FromQuery] ReadEnvelopeQuery envelope)
{
var result = await _mediator.Send(envelope.Authorize(User.GetId()));
return result.Any() ? Ok(result) : NotFound();
}
/// <summary>
/// Ruft das Ergebnis eines Dokuments basierend auf der ID ab.
/// </summary>
/// <param name="query"></param>
/// <param name="view">Gibt an, ob das Dokument inline angezeigt werden soll (true) oder als Download bereitgestellt wird (false).</param>
/// <returns>Eine IActionResult-Instanz, die das Dokument oder einen Fehlerstatus enthält.</returns>
/// <response code="200">Das Dokument wurde erfolgreich abgerufen.</response>
/// <response code="404">Das Dokument wurde nicht gefunden oder ist nicht verfügbar.</response>
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
[HttpGet("doc-result")]
public async Task<IActionResult> GetDocResultAsync([FromQuery] ReadEnvelopeQuery query, [FromQuery] bool view = false)
{
var envelopes = await _mediator.Send(query.Authorize(User.GetId()));
var envelope = envelopes.FirstOrDefault();
if (envelope is null)
return NotFound("Envelope not available.");
if (envelope.DocResult is null)
return NotFound("The document has not been fully signed or the result has not yet been released.");
if (view)
{
Response.Headers.Append("Content-Disposition", "inline; filename=\"" + envelope.Uuid + ".pdf\"");
return File(envelope.DocResult, "application/pdf");
}
return File(envelope.DocResult, "application/pdf", $"{envelope.Uuid}.pdf");
}
/// <summary>
///
/// </summary>
/// <param name="command"></param>
/// <returns></returns>
[NonAction]
[Authorize]
[HttpPost]
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
{
var res = await _mediator.Send(command.WithAuth(User.GetId()));
if (res is null)
{
_logger.LogError("Failed to create envelope. Envelope details: {EnvelopeDetails}", JsonConvert.SerializeObject(command));
return StatusCode(StatusCodes.Status500InternalServerError);
}
else
return Ok(res);
}
}

View File

@@ -1,281 +0,0 @@
using AutoMapper;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Envelopes.Queries;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.API.Models;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Options;
using System.Data;
using EnvelopeGenerator.Application.Common.SQL;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.API.Extensions;
using EnvelopeGenerator.Domain.Constants;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Controller für die Verwaltung von Umschlagempfängern.
/// </summary>
/// <remarks>
/// Dieser Controller bietet Endpunkte für das Abrufen und Verwalten von Umschlagempfängerdaten.
/// </remarks>
[Route("api/[controller]")]
[Authorize]
[ApiController]
public class EnvelopeReceiverController : ControllerBase
{
private readonly ILogger<EnvelopeReceiverController> _logger;
private readonly IMediator _mediator;
private readonly IMapper _mapper;
private readonly IEnvelopeExecutor _envelopeExecutor;
private readonly IEnvelopeReceiverExecutor _erExecutor;
private readonly IDocumentExecutor _documentExecutor;
private readonly string _cnnStr;
/// <summary>
/// Konstruktor für den EnvelopeReceiverController.
/// </summary>
public EnvelopeReceiverController(ILogger<EnvelopeReceiverController> logger, IMediator mediator, IMapper mapper, IEnvelopeExecutor envelopeExecutor, IEnvelopeReceiverExecutor erExecutor, IDocumentExecutor documentExecutor, IOptions<ConnectionString> csOpt)
{
_logger = logger;
_mediator = mediator;
_mapper = mapper;
_envelopeExecutor = envelopeExecutor;
_erExecutor = erExecutor;
_documentExecutor = documentExecutor;
_cnnStr = csOpt.Value.Value;
}
/// <summary>
/// Ruft eine Liste von Umschlagempfängern basierend auf den angegebenen Abfrageparametern ab.
/// </summary>
/// <param name="envelopeReceiver">Die Abfrageparameter für die Filterung von Umschlagempfängern.</param>
/// <returns>Eine HTTP-Antwort mit der Liste der gefundenen Umschlagempfänger oder einem Fehlerstatus.</returns>
/// <remarks>
/// Dieser Endpunkt ermöglicht es, Umschlagempfänger basierend auf dem Benutzernamen und optionalen Statusfiltern abzurufen.
/// Wenn der Benutzername nicht ermittelt werden kann, wird ein Serverfehler zurückgegeben.
/// </remarks>
/// <response code="200">Die Liste der Umschlagempfänger wurde erfolgreich abgerufen.</response>
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
[Authorize]
[HttpGet]
public async Task<IActionResult> GetEnvelopeReceiver([FromQuery] ReadEnvelopeReceiverQuery envelopeReceiver)
{
envelopeReceiver = envelopeReceiver with { Username = User.GetUsername() };
var result = await _mediator.Send(envelopeReceiver);
return Ok(result);
}
/// <summary>
///
/// </summary>
/// <param name="envelopeKey"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpGet("{envelopeKey}")]
public async Task<IActionResult> GetEnvelopeReceiverOfReceiver([FromRoute] string envelopeKey, CancellationToken cancel)
{
var er = await _mediator.Send(new ReadEnvelopeReceiverQuery()
{
Key = envelopeKey
}, cancel);
return Ok(er.SingleOrDefault());
}
/// <summary>
/// Ruft den Namen des zuletzt verwendeten Empfängers basierend auf der angegebenen E-Mail-Adresse ab.
/// </summary>
/// <param name="receiver">Abfrage, bei der nur eine der Angaben ID, Signatur oder E-Mail-Adresse des Empfängers eingegeben werden muss.</param>
/// <returns>Eine HTTP-Antwort mit dem Namen des Empfängers oder einem Fehlerstatus.</returns>
/// <remarks>
/// Dieser Endpunkt ermöglicht es, den Namen des zuletzt verwendeten Empfängers basierend auf der E-Mail-Adresse abzurufen.
/// </remarks>
/// <response code="200">Der Name des Empfängers wurde erfolgreich abgerufen.</response>
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
/// <response code="404">Kein Empfänger gefunden.</response>
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
[Authorize]
[HttpGet("salute")]
public async Task<IActionResult> GetReceiverName([FromQuery] ReadReceiverNameQuery receiver)
{
var name = await _mediator.Send(receiver);
return name is null ? NotFound() : Ok(name);
}
/// <summary>
/// Datenübertragungsobjekt mit Informationen zu Umschlägen, Empfängern und Unterschriften.
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns>HTTP-Antwort</returns>
/// <remarks>
/// Sample request:
///
/// POST /api/envelope
/// {
/// "title": "Vertragsdokument",
/// "message": "Bitte unterschreiben Sie dieses Dokument.",
/// "document": {
/// "dataAsBase64": "dGVzdC1iYXNlNjQtZGF0YQ=="
/// },
/// "receivers": [
/// {
/// "emailAddress": "example@example.com",
/// "signatures": [
/// {
/// "x": 100,
/// "y": 200,
/// "page": 1
/// }
/// ],
/// "name": "Max Mustermann",
/// "phoneNumber": "+49123456789"
/// }
/// ],
/// "tfaEnabled": false
/// }
///
/// </remarks>
/// <response code="202">Envelope-Erstellung und Sendeprozessbefehl erfolgreich</response>
/// <response code="400">Wenn ein Fehler im HTTP-Body auftritt</response>
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
/// <response code="500">Es handelt sich um einen unerwarteten Fehler. Die Protokolle sollten überprüft werden.</response>
[Authorize]
[HttpPost]
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeReceiverCommand request, CancellationToken cancel)
{
#region Create Envelope
var envelope = await _envelopeExecutor.CreateEnvelopeAsync(User.GetId(), request.Title, request.Message, request.TFAEnabled, cancel);
#endregion
#region Add receivers
List<EnvelopeReceiver> sentReceivers = new();
List<ReceiverGetOrCreateCommand> unsentReceivers = new();
foreach (var receiver in request.Receivers)
{
var envelopeReceiver = await _erExecutor.AddEnvelopeReceiverAsync(envelope.Uuid, receiver.EmailAddress, receiver.Salution, receiver.PhoneNumber, cancel);
if (envelopeReceiver is null)
unsentReceivers.Add(receiver);
else
sentReceivers.Add(envelopeReceiver);
}
var res = _mapper.Map<CreateEnvelopeReceiverResponse>(envelope);
res.UnsentReceivers = unsentReceivers;
res.SentReceiver = _mapper.Map<List<ReceiverDto>>(sentReceivers.Select(er => er.Receiver));
#endregion
#region Add document
var document = await _documentExecutor.CreateDocumentAsync(request.Document.DataAsBase64, envelope.Uuid, cancel);
if (document is null)
return StatusCode(StatusCodes.Status500InternalServerError, "Document creation is failed.");
#endregion
#region Add document element
// @DOC_ID, @RECEIVER_ID, @POSITION_X, @POSITION_Y, @PAGE
string sql = @"
DECLARE @OUT_SUCCESS bit;
EXEC [dbo].[PRSIG_API_ADD_DOC_RECEIVER_ELEM]
{0},
{1},
{2},
{3},
{4},
@OUT_SUCCESS OUTPUT;
SELECT @OUT_SUCCESS as [@OUT_SUCCESS];";
foreach (var rcv in res.SentReceiver)
foreach (var sign in request.Receivers.Where(r => r.EmailAddress == rcv.EmailAddress).FirstOrDefault()?.DocReceiverElements ?? Enumerable.Empty<Application.EnvelopeReceivers.Commands.DocReceiverElementCreateDto>())
{
using SqlConnection conn = new(_cnnStr);
conn.Open();
var formattedSQL = string.Format(sql, document.Id.ToSqlParam(), rcv.Id.ToSqlParam(), sign.X.ToSqlParam(), sign.Y.ToSqlParam(), sign.Page.ToSqlParam());
using SqlCommand cmd = new(formattedSQL, conn);
cmd.CommandType = CommandType.Text;
using SqlDataReader reader = cmd.ExecuteReader();
if (reader.Read())
{
bool outSuccess = reader.GetBoolean(0);
if (!outSuccess)
_logger.LogWarning(
"PRSIG_API_ADD_DOC_RECEIVER_ELEM returned OUT_SUCCESS=false. DOC_ID={DocId}, RECEIVER_ID={ReceiverId}, Page={Page}",
document.Id, rcv.Id, sign.Page);
}
}
#endregion
#region Create history
// ENV_UID, STATUS_ID, USER_ID,
string sql_hist = @"
DECLARE @OUT_SUCCESS bit;
EXEC [dbo].[PRSIG_API_ADD_HISTORY_STATE]
{0},
{1},
{2},
@OUT_SUCCESS OUTPUT;
SELECT @OUT_SUCCESS as [@OUT_SUCCESS];";
using (SqlConnection conn = new(_cnnStr))
{
conn.Open();
var formattedSQL_hist = string.Format(sql_hist, envelope.Uuid.ToSqlParam(), 1003.ToSqlParam(), User.GetId().ToSqlParam());
using SqlCommand cmd = new(formattedSQL_hist, conn);
cmd.CommandType = CommandType.Text;
using SqlDataReader reader = cmd.ExecuteReader();
if (reader.Read())
{
bool outSuccess = reader.GetBoolean(0);
if (!outSuccess)
_logger.LogWarning(
"PRSIG_API_ADD_HISTORY_STATE returned OUT_SUCCESS=false. EnvelopeUuid={EnvelopeUuid}",
envelope.Uuid);
}
}
#endregion
return Ok(res);
}
/// <summary>
///
/// </summary>
/// <param name="input"></param>
/// <returns></returns>
public static bool IsBase64String(string input)
{
if (string.IsNullOrWhiteSpace(input))
return false;
try
{
Convert.FromBase64String(input);
return true;
}
catch (FormatException)
{
return false;
}
}
}

View File

@@ -1,39 +0,0 @@
using MediatR;
using EnvelopeGenerator.Application.EnvelopeTypes.Queries;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.GeneratorAPI.Controllers;
/// <summary>
///
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
[Route("api/[controller]")]
[ApiController]
public class EnvelopeTypeController : ControllerBase
{
private readonly ILogger<EnvelopeTypeController> _logger;
private readonly IMediator _mediator;
/// <summary>
///
/// </summary>
/// <param name="logger"></param>
/// <param name="mediator"></param>
public EnvelopeTypeController(ILogger<EnvelopeTypeController> logger, IMediator mediator)
{
_logger = logger;
_mediator = mediator;
}
/// <summary>
///
/// </summary>
/// <returns></returns>
[HttpGet]
public async Task<IActionResult> GetAllAsync()
{
var result = await _mediator.Send(new ReadEnvelopeTypesQuery());
return Ok(result);
}
}

View File

@@ -1,118 +0,0 @@
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using EnvelopeGenerator.Application.Histories.Queries;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Application.Common.Extensions;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Dieser Controller stellt Endpunkte für den Zugriff auf die Umschlaghistorie bereit.
/// </summary>
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class HistoryController : ControllerBase
{
private readonly IMemoryCache _memoryCache;
private readonly IMediator _mediator;
/// <summary>
/// Konstruktor für den HistoryController.
/// </summary>
/// <param name="memoryCache"></param>
/// <param name="mediator"></param>
public HistoryController(IMemoryCache memoryCache, IMediator mediator)
{
_memoryCache = memoryCache;
_mediator = mediator;
}
/// <summary>
/// Gibt alle möglichen Verweise auf alle möglichen Include in einem Verlaufsdatensatz zurück. (z. B. DocumentSigned bezieht sich auf Receiver.)
/// Dies wird hinzugefügt, damit Client-Anwendungen sich selbst auf dem neuesten Stand halten können.
/// 1 - Sender:
/// Historische Datensätze über den Include der Empfänger. Diese haben Statuscodes, die mit 1* beginnen.
/// 2 - Receiver:
/// Historische Datensätze, die sich auf den Include des Absenders beziehen. Sie haben Statuscodes, die mit 2* beginnen.
/// 3 - System:
/// Historische Datensätze, die sich auf den allgemeinen Zustand des Umschlags beziehen. Diese haben Statuscodes, die mit 3* beginnen.
/// 4 - Unknown:
/// Ein unbekannter Datensatz weist auf einen möglichen Mangel oder eine Unstimmigkeit im Aktualisierungsprozess der Anwendung hin.
/// </summary>
/// <returns></returns>
/// <response code="200"></response>
[HttpGet("related")]
[Authorize]
public IActionResult GetReferenceTypes(ReferenceType? referenceType = null)
{
return referenceType is null
? Ok(_memoryCache.GetEnumAsDictionary<ReferenceType>("gen.api", ReferenceType.Unknown))
: Ok(referenceType.ToString());
}
/// <summary>
/// Gibt alle möglichen Include in einem Verlaufsdatensatz zurück.
/// Dies wird hinzugefügt, damit Client-Anwendungen sich selbst auf dem neuesten Stand halten können.
/// 1003: EnvelopeQueued
/// 1006: EnvelopeCompletelySigned
/// 1007: EnvelopeReportCreated
/// 1008: EnvelopeArchived
/// 1009: EnvelopeDeleted
/// 10007: EnvelopeRejected
/// 10009: EnvelopeWithdrawn
/// 2001: AccessCodeRequested
/// 2002: AccessCodeCorrect
/// 2003: AccessCodeIncorrect
/// 2004: DocumentOpened
/// 2005: DocumentSigned
/// 2006: DocumentForwarded
/// 2007: DocumentRejected
/// 2008: EnvelopeShared
/// 2009: EnvelopeViewed
/// 3001: MessageInvitationSent (Wird von Trigger verwendet)
/// 3002: MessageAccessCodeSent
/// 3003: MessageConfirmationSent
/// 3004: MessageDeletionSent
/// 3005: MessageCompletionSent
/// </summary>
/// <param name="status">
/// Abfrageparameter, der angibt, auf welche Referenz sich der Include bezieht.
/// 1 - Sender: Historische Datensätze, die sich auf den Include des Absenders beziehen. Sie haben Statuscodes, die mit 1* beginnen.
/// 2 - Receiver: Historische Datensätze über den Include der Empfänger. Diese haben Statuscodes, die mit 2* beginnen.
/// 3 - System: Diese werden durch Datenbank-Trigger aktualisiert und sind in den Tabellen EnvelopeHistory und EmailOut zu finden.Sie arbeiten
/// integriert mit der Anwendung EmailProfiler, um E-Mails zu versenden und haben die Codes, die mit 3* beginnen.
/// </param>
/// <returns>Gibt die HTTP-Antwort zurück.</returns>
/// <response code="200"></response>
[HttpGet("status")]
[Authorize]
public IActionResult GetEnvelopeStatus([FromQuery] EnvelopeStatus? status = null)
{
return status is null
? Ok(_memoryCache.GetEnumAsDictionary<EnvelopeStatus>("gen.api", Status.NonHist, Status.RelatedToFormApp))
: Ok(status.ToString());
}
/// <summary>
/// Ruft die gesamte Umschlaghistorie basierend auf den angegebenen Abfrageparametern ab.
/// </summary>
/// <param name="historyQuery">Die Abfrageparameter, die die Filterkriterien für die Umschlaghistorie definieren.</param>
/// <param name="cancel"></param>
/// <returns>Eine Liste von Historieneinträgen, die den angegebenen Kriterien entsprechen, oder nur der letzte Eintrag.</returns>
/// <response code="200">Die Anfrage war erfolgreich, und die Umschlaghistorie wird zurückgegeben.</response>
/// <response code="400">Die Anfrage war ungültig oder unvollständig.</response>
/// <response code="401">Der Benutzer ist nicht authentifiziert.</response>
/// <response code="403">Der Benutzer hat keine Berechtigung, auf die Ressource zuzugreifen.</response>
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
[HttpGet]
[Authorize]
public async Task<IActionResult> GetAllAsync([FromQuery] ReadHistoryQuery historyQuery, CancellationToken cancel)
{
var history = await _mediator.Send(historyQuery, cancel).ThrowIfEmpty(Exceptions.NotFound);
return Ok((historyQuery.OnlyLast) ? history.MaxBy(h => h.AddedWhen) : history);
}
}

View File

@@ -1,38 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
namespace EnvelopeGenerator.API.Controllers.Interfaces;
/// <summary>
///
/// </summary>
public interface IAuthController
{
/// <summary>
///
/// </summary>
IAuthorizationService AuthService { get; }
/// <summary>
///
/// </summary>
ClaimsPrincipal User { get; }
}
/// <summary>
///
/// </summary>
public static class AuthControllerExtensions
{
/// <summary>
///
/// </summary>
/// <param name="controller"></param>
/// <param name="policyName"></param>
/// <returns></returns>
public static async Task<bool> IsUserInPolicyAsync(this IAuthController controller, string policyName)
{
var result = await controller.AuthService.AuthorizeAsync(controller.User, policyName);
return result.Succeeded;
}
}

View File

@@ -1,121 +0,0 @@
using DigitalData.Core.API;
using EnvelopeGenerator.Application.Resources;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Localization;
using EnvelopeGenerator.Application.Resources;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Controller für die Verwaltung der Lokalisierung und Spracheinstellungen.
/// </summary>
[ApiExplorerSettings(IgnoreApi = true)]
[Route("api/[controller]")]
[ApiController]
public class LocalizationController : ControllerBase
{
private static readonly Guid L_KEY = Guid.NewGuid();
private readonly ILogger<LocalizationController> _logger;
private readonly IStringLocalizer<Resource> _mLocalizer;
private readonly IStringLocalizer<Resource> _localizer;
private readonly IMemoryCache _cache;
/// <summary>
/// Konstruktor für den <see cref="LocalizationController"/>.
/// </summary>
/// <param name="logger">Logger für die Protokollierung.</param>
/// <param name="localizer">Lokalisierungsdienst für Ressourcen.</param>
/// <param name="memoryCache">Speicher-Cache für die Zwischenspeicherung von Daten.</param>
/// <param name="_modelLocalizer">Lokalisierungsdienst für Modelle.</param>
public LocalizationController(
ILogger<LocalizationController> logger,
IStringLocalizer<Resource> localizer,
IMemoryCache memoryCache,
IStringLocalizer<Resource> _modelLocalizer)
{
_logger = logger;
_localizer = localizer;
_cache = memoryCache;
_mLocalizer = _modelLocalizer;
}
/// <summary>
/// Ruft alle lokalisierten Daten ab.
/// </summary>
/// <returns>Eine Liste aller lokalisierten Daten.</returns>
[HttpGet]
public IActionResult GetAll() => Ok(_cache.GetOrCreate(Language ?? string.Empty + L_KEY, _ => _mLocalizer.ToDictionary()));
/// <summary>
/// Ruft die aktuelle Sprache ab.
/// </summary>
/// <returns>Die aktuelle Sprache oder ein NotFound-Ergebnis, wenn keine Sprache gesetzt ist.</returns>
[HttpGet("lang")]
public IActionResult GetLanguage() => Language is null ? NotFound() : Ok(Language);
/// <summary>
/// Setzt die Sprache.
/// </summary>
/// <param name="language">Die zu setzende Sprache.</param>
/// <returns>Ein Ok-Ergebnis, wenn die Sprache erfolgreich gesetzt wurde, oder ein BadRequest-Ergebnis, wenn die Eingabe ungültig ist.</returns>
[HttpPost("lang")]
public IActionResult SetLanguage([FromQuery] string language)
{
if (string.IsNullOrEmpty(language))
return BadRequest();
Language = language;
return Ok();
}
/// <summary>
/// Löscht die aktuelle Sprache.
/// </summary>
/// <returns>Ein Ok-Ergebnis, wenn die Sprache erfolgreich gelöscht wurde.</returns>
[HttpDelete("lang")]
public IActionResult DeleteLanguage()
{
Language = null;
return Ok();
}
/// <summary>
/// Eigenschaft für die Verwaltung der aktuellen Sprache über Cookies.
/// </summary>
private string? Language
{
get
{
var cookieValue = Request.Cookies[CookieRequestCultureProvider.DefaultCookieName];
if (string.IsNullOrEmpty(cookieValue))
return null;
var culture = CookieRequestCultureProvider.ParseCookieValue(cookieValue)?.Cultures[0];
return culture?.Value ?? null;
}
set
{
if (value is null)
Response.Cookies.Delete(CookieRequestCultureProvider.DefaultCookieName);
else
{
var cookieOptions = new CookieOptions()
{
Expires = DateTimeOffset.UtcNow.AddYears(1),
Secure = false,
SameSite = SameSiteMode.Strict,
HttpOnly = true
};
Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(value)),
cookieOptions);
}
}
}
}

View File

@@ -1,91 +0,0 @@
using DigitalData.Core.Abstraction.Application.DTO;
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.API.Extensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Manages read-only envelope sharing flows.
/// </summary>
[Route("api/[controller]")]
[ApiController]
public class ReadOnlyController : ControllerBase
{
private readonly ILogger<ReadOnlyController> _logger;
private readonly IEnvelopeReceiverReadOnlyService _readOnlyService;
private readonly IEnvelopeMailService _mailService;
private readonly IEnvelopeHistoryService _historyService;
/// <summary>
/// Initializes a new instance of the <see cref="ReadOnlyController"/> class.
/// </summary>
public ReadOnlyController(ILogger<ReadOnlyController> logger, IEnvelopeReceiverReadOnlyService readOnlyService, IEnvelopeMailService mailService, IEnvelopeHistoryService historyService)
{
_logger = logger;
_readOnlyService = readOnlyService;
_mailService = mailService;
_historyService = historyService;
}
/// <summary>
/// Creates a new read-only receiver for the current envelope.
/// </summary>
/// <param name="createDto">Creation payload.</param>
[HttpPost]
[Authorize(Policy = AuthPolicy.Receiver)]
[Obsolete("Use MediatR")]
public async Task<IActionResult> CreateAsync([FromBody] EnvelopeReceiverReadOnlyCreateDto createDto)
{
var authReceiverMail = User.ReceiverMail();
if (authReceiverMail is null)
{
_logger.LogError("EmailAddress claim is not found in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
return Unauthorized();
}
var envelopeId = User.EnvelopeId();
createDto.AddedWho = authReceiverMail;
createDto.EnvelopeId = envelopeId;
var creationRes = await _readOnlyService.CreateAsync(createDto: createDto);
if (creationRes.IsFailed)
{
_logger.LogNotice(creationRes);
return StatusCode(StatusCodes.Status500InternalServerError);
}
var readRes = await _readOnlyService.ReadByIdAsync(creationRes.Data.Id);
if (readRes.IsFailed)
{
_logger.LogNotice(creationRes);
return StatusCode(StatusCodes.Status500InternalServerError);
}
var newReadOnly = readRes.Data;
return await _mailService.SendAsync(newReadOnly).ThenAsync<int, IActionResult>(SuccessAsync: async _ =>
{
var histRes = await _historyService.RecordAsync((int)createDto.EnvelopeId, createDto.AddedWho, EnvelopeStatus.EnvelopeShared);
if (histRes.IsFailed)
{
_logger.LogError("Although the envelope was sent as read-only, the EnvelopeShared history could not be saved. Create DTO:\n{createDto}", JsonConvert.SerializeObject(createDto));
_logger.LogNotice(histRes.Notices);
}
return Ok();
},
Fail: (msg, ntc) =>
{
_logger.LogNotice(ntc);
return StatusCode(StatusCodes.Status500InternalServerError);
});
}
}

View File

@@ -1,47 +0,0 @@
using MediatR;
using EnvelopeGenerator.Application.Receivers.Queries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.GeneratorAPI.Controllers;
/// <summary>
/// Controller für die Verwaltung von Empfängern.
/// </summary>
/// <remarks>
/// Dieser Controller bietet Endpunkte für das Abrufen von Empfängern basierend auf E-Mail-Adresse oder Signatur.
/// </remarks>
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class ReceiverController : ControllerBase
{
private readonly IMediator _mediator;
/// <summary>
/// Initialisiert eine neue Instanz des <see cref="ReceiverController"/>-Controllers.
/// </summary>
/// <param name="mediator">Mediator für Anfragen.</param>
public ReceiverController(IMediator mediator)
{
_mediator = mediator;
}
/// <summary>
/// Ruft eine Liste von Empfängern ab, basierend auf den angegebenen Abfrageparametern.
/// </summary>
/// <param name="receiver">Die Abfrageparameter, einschließlich E-Mail-Adresse und Signatur.</param>
/// <returns>Eine Liste von Empfängern oder ein Fehlerstatus.</returns>
[HttpGet]
public async Task<IActionResult> Get([FromQuery] ReadReceiverQuery receiver)
{
if (!receiver.HasAnyCriteria)
{
var all = await _mediator.Send(new ReadReceiverQuery());
return Ok(all);
}
var result = await _mediator.Send(receiver);
return result is null ? NotFound() : Ok(result);
}
}

View File

@@ -1,129 +0,0 @@
using DigitalData.Core.Abstraction.Application.DTO;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.Resources;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.API.Models;
using Ganss.Xss;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.API.Controllers;
/// <summary>
/// Exposes endpoints for registering and managing two-factor authentication for envelope receivers.
/// </summary>
[ApiController]
[Route("api/tfa")]
public class TfaRegistrationController : ControllerBase
{
private readonly ILogger<TfaRegistrationController> _logger;
private readonly IEnvelopeReceiverService _envelopeReceiverService;
private readonly IAuthenticator _authenticator;
private readonly IReceiverService _receiverService;
private readonly TFARegParams _parameters;
private readonly IStringLocalizer<Resource> _localizer;
/// <summary>
/// Initializes a new instance of the <see cref="TfaRegistrationController"/> class.
/// </summary>
public TfaRegistrationController(
ILogger<TfaRegistrationController> logger,
IEnvelopeReceiverService envelopeReceiverService,
IAuthenticator authenticator,
IReceiverService receiverService,
IOptions<TFARegParams> tfaRegParamsOptions,
IStringLocalizer<Resource> localizer)
{
_logger = logger;
_envelopeReceiverService = envelopeReceiverService;
_authenticator = authenticator;
_receiverService = receiverService;
_parameters = tfaRegParamsOptions.Value;
_localizer = localizer;
}
/// <summary>
/// Generates registration metadata (QR code and deadline) for a receiver.
/// </summary>
/// <param name="envelopeReceiverId">Encoded envelope receiver id.</param>
[Authorize]
[HttpGet("{envelopeReceiverId}")]
public async Task<IActionResult> RegisterAsync(string envelopeReceiverId)
{
try
{
var (uuid, signature) = envelopeReceiverId.DecodeEnvelopeReceiverId();
if (uuid is null || signature is null)
{
_logger.LogEnvelopeError(uuid: uuid, signature: signature, message: _localizer.WrongEnvelopeReceiverId());
return Unauthorized(new { message = _localizer.WrongEnvelopeReceiverId() });
}
var secretResult = await _envelopeReceiverService.ReadWithSecretByUuidSignatureAsync(uuid: uuid, signature: signature);
if (secretResult.IsFailed)
{
_logger.LogNotice(secretResult.Notices);
return NotFound(new { message = _localizer.WrongEnvelopeReceiverId() });
}
var envelopeReceiver = secretResult.Data;
if (!envelopeReceiver.Envelope!.TFAEnabled)
return Unauthorized(new { message = _localizer.WrongAccessCode() });
var receiver = envelopeReceiver.Receiver;
receiver!.TotpSecretkey = _authenticator.GenerateTotpSecretKey();
await _receiverService.UpdateAsync(receiver);
var totpQr64 = _authenticator.GenerateTotpQrCode(userEmail: receiver.EmailAddress, secretKey: receiver.TotpSecretkey).ToBase64String();
if (receiver.TfaRegDeadline is null)
{
receiver.TfaRegDeadline = _parameters.Deadline;
await _receiverService.UpdateAsync(receiver);
}
else if (receiver.TfaRegDeadline <= DateTime.Now)
{
return StatusCode(StatusCodes.Status410Gone, new { message = _localizer.WrongAccessCode() });
}
return Ok(new
{
envelopeReceiver.EnvelopeId,
envelopeReceiver.Envelope!.Uuid,
envelopeReceiver.Receiver!.Signature,
receiver.TfaRegDeadline,
TotpQR64 = totpQr64
});
}
catch (Exception ex)
{
_logger.LogEnvelopeError(envelopeReceiverId: envelopeReceiverId, exception: ex, message: _localizer.WrongEnvelopeReceiverId());
return StatusCode(StatusCodes.Status500InternalServerError, new { message = _localizer.UnexpectedError() });
}
}
/// <summary>
/// Logs out the envelope receiver from cookie authentication.
/// </summary>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("auth/logout")]
public async Task<IActionResult> LogOutAsync()
{
try
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "{message}", ex.Message);
return StatusCode(StatusCodes.Status500InternalServerError, new { message = _localizer.UnexpectedError() });
}
}
}

View File

@@ -1,123 +0,0 @@
using EnvelopeGenerator.API.Models;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace EnvelopeGenerator.API.Documentation;
/// <summary>
///
/// </summary>
public sealed class AuthProxyDocumentFilter : IDocumentFilter
{
/// <summary>
///
/// </summary>
/// <param name="swaggerDoc"></param>
/// <param name="context"></param>
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
AddLoginOperation(swaggerDoc, context);
AddEnvelopeReceiverLoginOperation(swaggerDoc, context);
}
private static void AddLoginOperation(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
const string path = "/api/auth";
var loginSchema = context.SchemaGenerator.GenerateSchema(typeof(Login), context.SchemaRepository);
var loginExample = new OpenApiObject
{
["password"] = new OpenApiString(""),
["username"] = new OpenApiString("")
};
var operation = new OpenApiOperation
{
Summary = "Proxy login (auth-hub)",
Description = "Proxies the request to the auth service. Add query parameter `cookie=true|false`.",
Tags = [new() { Name = "Auth" }],
Parameters =
{
new OpenApiParameter
{
Name = "cookie",
In = ParameterLocation.Query,
Required = false,
Schema = new OpenApiSchema { Type = "boolean", Default = new OpenApiBoolean(true) },
Example = new OpenApiBoolean(true),
Description = "If true, auth service sets the auth cookie."
}
},
RequestBody = new OpenApiRequestBody
{
Required = true,
Content =
{
["application/json"] = new OpenApiMediaType { Schema = loginSchema, Example = loginExample },
["multipart/form-data"] = new OpenApiMediaType { Schema = loginSchema, Example = loginExample }
}
},
Responses =
{
["200"] = new OpenApiResponse { Description = "OK (proxied response)" },
["401"] = new OpenApiResponse { Description = "Unauthorized" }
}
};
swaggerDoc.Paths[path] = new OpenApiPathItem
{
Operations =
{
[OperationType.Post] = operation
}
};
}
private static void AddEnvelopeReceiverLoginOperation(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
const string path = "/api/Auth/envelope-receiver/{key}";
var bodySchema = context.SchemaGenerator.GenerateSchema(typeof(EnvelopeReceiverLogin), context.SchemaRepository);
var operation = new OpenApiOperation
{
Summary = "Envelope receiver login (auth-hub proxy)",
Description = "Proxies the envelope receiver login to the auth service. " +
"The `cookie` query parameter is always forwarded as `true` so the auth service sets the per-envelope cookie automatically.",
Tags = [new() { Name = "Auth" }],
Parameters =
{
new OpenApiParameter
{
Name = "key",
In = ParameterLocation.Path,
Required = true,
Schema = new OpenApiSchema { Type = "string" },
Description = "The unique envelope receiver key."
}
},
RequestBody = new OpenApiRequestBody
{
Required = false,
Content =
{
["multipart/form-data"] = new OpenApiMediaType { Schema = bodySchema }
}
},
Responses =
{
["200"] = new OpenApiResponse { Description = "OK – per-envelope cookie set by auth service." },
["401"] = new OpenApiResponse { Description = "Unauthorized – invalid or missing access code." }
}
};
swaggerDoc.Paths[path] = new OpenApiPathItem
{
Operations =
{
[OperationType.Post] = operation
}
};
}
}

View File

@@ -1,84 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net8.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<PackageId>EnvelopeGenerator.GeneratorAPI</PackageId>
<Title></Title>
<Authors>Digital Data GmbH</Authors>
<Company>Digital Data GmbH</Company>
<Product>EnvelopeGenerator.GeneratorAPI</Product>
<Version>1.4.0</Version>
<FileVersion>1.4.0</FileVersion>
<AssemblyVersion>1.4.0</AssemblyVersion>
<PackageOutputPath>Copyright © 2025 Digital Data GmbH. All rights reserved.</PackageOutputPath>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Remove="ClientApp\**" />
<Content Remove="ClientApp\**" />
<EmbeddedResource Remove="ClientApp\**" />
<None Remove="ClientApp\**" />
</ItemGroup>
<ItemGroup>
<None Include="yarp.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AspNetCore.Scalar" Version="1.1.8" />
<PackageReference Include="DigitalData.Auth.Claims" Version="1.0.3" />
<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" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" Condition="'$(TargetFramework)' == 'net9.0'" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.6" Condition="'$(TargetFramework)' == 'net9.0'" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.17" Condition="'$(TargetFramework)' == 'net8.0'" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
<PackageReference Include="NLog" Version="5.2.5" />
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.2.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
<PackageReference Include="DigitalData.EmailProfilerDispatcher.Abstraction" Version="3.2.0" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.1.0" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net7.0'">
<PackageReference Include="System.DirectoryServices" Version="7.0.1" />
<PackageReference Include="System.DirectoryServices.AccountManagement" Version="7.0.1" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="7.0.1" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="System.DirectoryServices" Version="8.0.0" />
<PackageReference Include="System.DirectoryServices.AccountManagement" Version="8.0.1" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="8.0.1" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
<PackageReference Include="System.DirectoryServices" Version="9.0.4" />
<PackageReference Include="System.DirectoryServices.AccountManagement" Version="9.0.4" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="9.0.4" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
<ProjectReference Include="..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
<ProjectReference Include="..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
<ProjectReference Include="..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,96 +0,0 @@
using DigitalData.Auth.Claims;
using Microsoft.IdentityModel.JsonWebTokens;
using System.Security.Claims;
namespace EnvelopeGenerator.API.Extensions;
/// <summary>
/// Provides helper methods for working with envelope-specific authentication claims.
/// </summary>
public static class ReceiverClaimExtensions
{
/// <summary>
///
/// </summary>
/// <param name="user"></param>
/// <param name="claimType"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
private static string GetRequiredClaimValue(this ClaimsPrincipal user, string claimType)
{
var value = user.FindFirstValue(claimType);
if (value is not null)
{
return value;
}
var identity = user.Identity;
var principalName = identity?.Name ?? "(anonymous)";
var authType = identity?.AuthenticationType ?? "(none)";
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
var message = $"Required claim '{claimType}' is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
throw new InvalidOperationException(message);
}
private static string GetRequiredClaimValue(this ClaimsPrincipal user, params string[] claimTypes)
{
foreach (var claimType in claimTypes.Where(t => !string.IsNullOrWhiteSpace(t)).Distinct())
{
var value = user.FindFirstValue(claimType);
if (!string.IsNullOrWhiteSpace(value))
return value;
}
var identity = user.Identity;
var principalName = identity?.Name ?? "(anonymous)";
var authType = identity?.AuthenticationType ?? "(none)";
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
var message = $"Required claim(s) '{string.Join("', '", claimTypes)}' are missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
throw new InvalidOperationException(message);
}
/// <summary>
/// Gets the authenticated envelope UUID from the claims.
/// </summary>
public static string EnvelopeUuid(this ClaimsPrincipal user)
=> user.GetRequiredClaimValue(EnvelopeClaimNames.EnvelopeUuid);
/// <summary>
/// Gets the authenticated receiver signature from the claims.
/// </summary>
public static string ReceiverSignature(this ClaimsPrincipal user)
=> user.GetRequiredClaimValue(EnvelopeClaimNames.ReceiverSignature);
/// <summary>
/// Gets the authenticated receiver email address from the claims.
/// </summary>
public static string ReceiverMail(this ClaimsPrincipal user)
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email);
/// <summary>
/// Gets the authenticated envelope identifier from the claims.
/// </summary>
public static int EnvelopeId(this ClaimsPrincipal user)
{
var envIdStr = user.GetRequiredClaimValue(EnvelopeClaimNames.EnvelopeId);
if (int.TryParse(envIdStr, out var envId))
return envId;
else
throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.EnvelopeId}' is not a valid integer.");
}
/// <summary>
/// Gets the authenticated receiver identifier from the claims.
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public static int ReceiverId(this ClaimsPrincipal user)
{
var rcvIdStr = user.GetRequiredClaimValue(EnvelopeClaimNames.ReceiverId);
if (int.TryParse(rcvIdStr, out var rcvId))
return rcvId;
else
throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.ReceiverId}' is not a valid integer.");
}
}

View File

@@ -1,95 +0,0 @@
using System.Security.Claims;
namespace EnvelopeGenerator.API.Extensions
{
/// <summary>
/// Provides extension methods for extracting user information from a <see cref="ClaimsPrincipal"/>.
/// </summary>
public static class SenderClaimExtensions
{
private static string GetRequiredClaimOfSender(this ClaimsPrincipal user, string claimType)
{
var value = user.FindFirstValue(claimType);
if (value is not null)
{
return value;
}
var identity = user.Identity;
var principalName = identity?.Name ?? "(anonymous)";
var authType = identity?.AuthenticationType ?? "(none)";
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
var message = $"Required claim '{claimType}' is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
throw new InvalidOperationException(message);
}
private static string GetRequiredClaimOfSender(this ClaimsPrincipal user, params string[] claimTypes)
{
string? value = null;
foreach (var claimType in claimTypes)
{
value = user.FindFirstValue(claimType);
if (value is not null)
return value;
}
var identity = user.Identity;
var principalName = identity?.Name ?? "(anonymous)";
var authType = identity?.AuthenticationType ?? "(none)";
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
var message = $"Required claim among [{string.Join(", ", claimTypes)}] is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
throw new InvalidOperationException(message);
}
/// <summary>
/// Retrieves the user's ID from the claims. Throws an exception if the ID is missing or invalid.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The user's ID as an integer.</returns>
/// <exception cref="InvalidOperationException">Thrown if the user ID claim is missing or invalid.</exception>
public static int GetId(this ClaimsPrincipal user)
{
var idValue = user.GetRequiredClaimOfSender(ClaimTypes.NameIdentifier, "sub");
if (!int.TryParse(idValue, out var result))
{
throw new InvalidOperationException("User ID claim is missing or invalid. This may indicate a misconfigured or forged JWT token.");
}
return result;
}
/// <summary>
/// Retrieves the username from the claims.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The username as a string.</returns>
public static string GetUsername(this ClaimsPrincipal user)
=> user.GetRequiredClaimOfSender(ClaimTypes.Name);
/// <summary>
/// Retrieves the user's surname (last name) from the claims.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The surname as a string.</returns>
public static string GetName(this ClaimsPrincipal user)
=> user.GetRequiredClaimOfSender(ClaimTypes.Surname);
/// <summary>
/// Retrieves the user's given name (first name) from the claims.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The given name as a string.</returns>
public static string GetPrename(this ClaimsPrincipal user)
=> user.GetRequiredClaimOfSender(ClaimTypes.GivenName);
/// <summary>
/// Retrieves the user's email address from the claims.
/// </summary>
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
/// <returns>The email address as a string.</returns>
public static string GetEmail(this ClaimsPrincipal user)
=> user.GetRequiredClaimOfSender(ClaimTypes.Email);
}
}

View File

@@ -1,10 +0,0 @@
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'dotnet build'
}
}
}
}

View File

@@ -1,84 +0,0 @@
namespace EnvelopeGenerator.API.Middleware;
using DigitalData.Core.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Net;
using System.Text.Json;
/// <summary>
/// Middleware for handling exceptions globally in the application.
/// Captures exceptions thrown during the request pipeline execution,
/// logs them, and returns an appropriate HTTP response with a JSON error message.
/// </summary>
public class ExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ExceptionHandlingMiddleware"/> class.
/// </summary>
/// <param name="next">The next middleware in the request pipeline.</param>
/// <param name="logger">The logger instance for logging exceptions.</param>
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
/// <summary>
/// Invokes the middleware to handle the HTTP request.
/// </summary>
/// <param name="context">The HTTP context of the current request.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context); // Continue down the pipeline
}
catch (Exception ex)
{
await HandleExceptionAsync(context, ex, _logger);
}
}
/// <summary>
/// Handles exceptions by logging them and writing an appropriate JSON response.
/// </summary>
/// <param name="context">The HTTP context of the current request.</param>
/// <param name="exception">The exception that occurred.</param>
/// <param name="logger">The logger instance for logging the exception.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
private static async Task HandleExceptionAsync(HttpContext context, Exception exception, ILogger logger)
{
context.Response.ContentType = "application/json";
string message;
switch (exception)
{
case BadRequestException badRequestEx:
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
message = badRequestEx.Message;
break;
case NotFoundException notFoundEx:
context.Response.StatusCode = (int)HttpStatusCode.NotFound;
message = notFoundEx.Message;
break;
default:
logger.LogError(exception, "Unhandled exception occurred.");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
message = "An unexpected error occurred.";
break;
}
await context.Response.WriteAsync(JsonSerializer.Serialize(new
{
message
}));
}
}

View File

@@ -1,14 +0,0 @@
namespace EnvelopeGenerator.API.Models;
public record Auth(string? AccessCode = null, string? SmsCode = null, string? AuthenticatorCode = null, bool UserSelectSMS = default)
{
public bool HasAccessCode => AccessCode is not null;
public bool HasSmsCode => SmsCode is not null;
public bool HasAuthenticatorCode => AuthenticatorCode is not null;
public bool HasMulti => new[] { HasAccessCode, HasSmsCode, HasAuthenticatorCode }.Count(state => state) > 1;
public bool HasNone => !(HasAccessCode || HasSmsCode || HasAuthenticatorCode);
}

View File

@@ -1,28 +0,0 @@
namespace EnvelopeGenerator.API.Models;
/// <summary>
/// Represents the keys and default values used for authentication token handling
/// within the Envelope Generator API.
/// </summary>
public class AuthTokenKeys
{
/// <summary>
/// Gets the name of the cookie used to store the authentication token.
/// </summary>
public string Cookie { get; init; } = "AuthToken";
/// <summary>
/// Gets the name of the query string parameter used to pass the authentication token.
/// </summary>
public string QueryString { get; init; } = "AuthToken";
/// <summary>
/// Gets the expected issuer value for the authentication token.
/// </summary>
public string Issuer { get; init; } = "auth.digitaldata.works";
/// <summary>
/// Gets the expected audience value for the authentication token.
/// </summary>
public string Audience { get; init; } = "sign-flow.digitaldata.works";
}

View File

@@ -1,12 +0,0 @@
namespace EnvelopeGenerator.API.Models;
/// <summary>
///
/// </summary>
public class ConnectionString
{
/// <summary>
///
/// </summary>
public string Value { get; set; } = string.Empty;
}

View File

@@ -1,60 +0,0 @@
namespace EnvelopeGenerator.API.Models
{
/// <summary>
/// Represents a hyperlink for contact purposes with various HTML attributes.
/// </summary>
public class ContactLink
{
/// <summary>
/// Gets or sets the label of the hyperlink.
/// </summary>
public string Label { get; init; } = "Contact";
/// <summary>
/// Gets or sets the URL that the hyperlink points to.
/// </summary>
public string Href { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the target where the hyperlink should open.
/// Commonly used values are "_blank", "_self", "_parent", "_top".
/// </summary>
public string Target { get; set; } = "_blank";
/// <summary>
/// Gets or sets the relationship of the linked URL as space-separated link types.
/// Examples include "nofollow", "noopener", "noreferrer".
/// </summary>
public string Rel { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the filename that should be downloaded when clicking the hyperlink.
/// This attribute will only have an effect if the href attribute is set.
/// </summary>
public string Download { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the language of the linked resource. Useful when linking to
/// content in another language.
/// </summary>
public string HrefLang { get; set; } = "en";
/// <summary>
/// Gets or sets the MIME type of the linked URL. Helps browsers to handle
/// the type correctly when the link is clicked.
/// </summary>
public string Type { get; set; } = string.Empty;
/// <summary>
/// Gets or sets additional information about the hyperlink, typically viewed
/// as a tooltip when the mouse hovers over the link.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets an identifier for the hyperlink, unique within the HTML document.
/// </summary>
public string Id { get; set; } = string.Empty;
}
}

View File

@@ -1,17 +0,0 @@
using System.Globalization;
namespace EnvelopeGenerator.API.Models;
public class Culture
{
private string _language = string.Empty;
public string Language { get => _language;
init {
_language = value;
Info = new(value);
}
}
public string FIClass { get; init; } = string.Empty;
public CultureInfo? Info { get; init; }
}

View File

@@ -1,12 +0,0 @@
namespace EnvelopeGenerator.API.Models;
public class Cultures : List<Culture>
{
public IEnumerable<string> Languages => this.Select(c => c.Language);
public IEnumerable<string> FIClasses => this.Select(c => c.FIClass);
public Culture Default => this.First();
public Culture? this[string? language] => language is null ? null : this.Where(c => c.Language == language).FirstOrDefault();
}

View File

@@ -1,6 +0,0 @@
namespace EnvelopeGenerator.API.Models;
public class CustomImages : Dictionary<string, Image>
{
public new Image this[string key] => TryGetValue(key, out var img) && img is not null ? img : new();
}

View File

@@ -1,7 +0,0 @@
namespace EnvelopeGenerator.API.Models;
/// <summary>
/// Request body for the envelope-receiver login endpoint.
/// </summary>
/// <param name="AccessCode">The access code sent to the receiver.</param>
public record EnvelopeReceiverLogin(string? AccessCode = null);

View File

@@ -1,10 +0,0 @@
namespace EnvelopeGenerator.API.Models;
public class ErrorViewModel
{
public string Title { get; init; } = "404";
public string Subtitle { get; init; } = "Hmmm...";
public string Body { get; init; } = "It looks like one of the developers fell asleep";
}

View File

@@ -1,10 +0,0 @@
namespace EnvelopeGenerator.API.Models;
public class Image
{
public string Src { get; init; } = string.Empty;
public Dictionary<string, string> Classes { get; init; } = new();
public string GetClassIn(string page) => Classes.TryGetValue(page, out var cls) && cls is not null ? cls : string.Empty;
}

View File

@@ -1,13 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace EnvelopeGenerator.API.Models;
/// <summary>
/// Repräsentiert ein Login-Modell mit erforderlichem Passwort und optionaler ID und Benutzername.
/// </summary>
/// <param name="Password">Das erforderliche Passwort für das Login.</param>
/// <param name="UserId">Die optionale ID des Benutzers.</param>
/// <param name="Username">Der optionale Benutzername.</param>
public record Login([Required] string Password, int? UserId = null, string? Username = null)
{
}

View File

@@ -1,6 +0,0 @@
namespace EnvelopeGenerator.API.Models;
public class MainViewModel
{
public string? Title { get; init; }
}

View File

@@ -1,93 +0,0 @@
using EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
using System.Text.Json.Serialization;
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
public record Annotation : IAnnotation
{
public required string Name { get; init; }
#region Bound Annotation
[JsonIgnore]
public string? HorBoundAnnotName { get; init; }
[JsonIgnore]
public string? VerBoundAnnotName { get; init; }
#endregion
#region Layout
[JsonIgnore]
public double? MarginLeft { get; set; }
[JsonIgnore]
public double MarginLeftRatio { get; init; } = 1;
[JsonIgnore]
public double? MarginTop { get; set; }
[JsonIgnore]
public double MarginTopRatio { get; init; } = 1;
public double? Width { get; set; }
[JsonIgnore]
public double WidthRatio { get; init; } = 1;
public double? Height { get; set; }
[JsonIgnore]
public double HeightRatio { get; init; } = 1;
#endregion
#region Position
public double Left => (MarginLeft ?? 0) + (HorBoundAnnot?.HorBoundary ?? 0);
public double Top => (MarginTop ?? 0) + (VerBoundAnnot?.VerBoundary ?? 0);
#endregion
#region Boundary
[JsonIgnore]
public double HorBoundary => Left + (Width ?? 0);
[JsonIgnore]
public double VerBoundary => Top + (Height ?? 0);
#endregion
#region BoundAnnot
[JsonIgnore]
public Annotation? HorBoundAnnot { get; set; }
[JsonIgnore]
public Annotation? VerBoundAnnot { get; set; }
#endregion
public Color? BackgroundColor { get; init; }
#region Border
public Color? BorderColor { get; init; }
public string? BorderStyle { get; init; }
public int? BorderWidth { get; set; }
#endregion
[JsonIgnore]
internal Annotation Default
{
set
{
// To set null value, annotation must have null (0) value but null must has non-null value
if (MarginLeft == null && value.MarginLeft != null)
MarginLeft = value.MarginLeft * MarginLeftRatio;
if (MarginTop == null && value.MarginTop != null)
MarginTop = value.MarginTop * MarginTopRatio;
if (Width == null && value.Width != null)
Width = value.Width * WidthRatio;
if (Height == null && value.Height != null)
Height = value.Height * HeightRatio;
}
}
};

View File

@@ -1,80 +0,0 @@
using EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
using System.Text.Json.Serialization;
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
public class AnnotationParams
{
public AnnotationParams()
{
_AnnotationJSObjectInitor = new(CreateAnnotationJSObject);
}
public Background? Background { get; init; }
#region Annotation
[JsonIgnore]
public Annotation? DefaultAnnotation { get; init; }
private readonly List<Annotation> _annots = new List<Annotation>();
public bool TryGet(string name, out Annotation annotation)
{
#pragma warning disable CS8601 // Possible null reference assignment.
annotation = _annots.FirstOrDefault(a => a.Name == name);
#pragma warning restore CS8601 // Possible null reference assignment.
return annotation is not null;
}
public required IEnumerable<Annotation> Annotations
{
get => _annots;
init
{
_annots = value.ToList();
if (DefaultAnnotation is not null)
foreach (var annot in _annots)
annot.Default = DefaultAnnotation;
for (int i = 0; i < _annots.Count; i++)
{
#region set bound annotations
// horizontal
if (_annots[i].HorBoundAnnotName is string horBoundAnnotName)
if (TryGet(horBoundAnnotName, out var horBoundAnnot))
_annots[i].HorBoundAnnot = horBoundAnnot;
else
throw new InvalidOperationException($"{horBoundAnnotName} added as bound anotation. However, it is not defined.");
// vertical
if (_annots[i].VerBoundAnnotName is string verBoundAnnotName)
if (TryGet(verBoundAnnotName, out var verBoundAnnot))
_annots[i].VerBoundAnnot = verBoundAnnot;
else
throw new InvalidOperationException($"{verBoundAnnotName} added as bound anotation. However, it is not defined.");
#endregion
}
}
}
#endregion
#region AnnotationJSObject
private Dictionary<string, IAnnotation> CreateAnnotationJSObject()
{
var dict = _annots.ToDictionary(a => a.Name.ToLower(), a => a as IAnnotation);
if (Background is not null)
{
Background.Locate(_annots);
dict.Add(Background.Name.ToLower(), Background);
}
return dict;
}
private readonly Lazy<Dictionary<string, IAnnotation>> _AnnotationJSObjectInitor;
public Dictionary<string, IAnnotation> AnnotationJSObject => _AnnotationJSObjectInitor.Value;
#endregion
}

View File

@@ -1,58 +0,0 @@
using System.Text.Json.Serialization;
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
/// <summary>
/// The Background is an annotation for the PSPDF Kit. However, it has no function.
/// It is only the first annotation as a background for other annotations.
/// </summary>
public record Background : IAnnotation
{
[JsonIgnore]
public double Margin { get; init; }
public string Name { get; } = "Background";
public double? Width { get; set; }
public double? Height { get; set; }
public double Left { get; set; }
public double Top { get; set; }
public Color? BackgroundColor { get; init; }
#region Border
public Color? BorderColor { get; init; }
public string? BorderStyle { get; init; }
public int? BorderWidth { get; set; }
#endregion
public void Locate(IEnumerable<IAnnotation> annotations)
{
// set Top
if (annotations.MinBy(a => a.Top)?.Top is double minTop)
Top = minTop;
// set Left
if (annotations.MinBy(a => a.Left)?.Left is double minLeft)
Left = minLeft;
// set Width
if(annotations.MaxBy(a => a.GetRight())?.GetRight() is double maxRight)
Width = maxRight - Left;
// set Height
if (annotations.MaxBy(a => a.GetBottom())?.GetBottom() is double maxBottom)
Height = maxBottom - Top;
// add margins
Top -= Margin;
Left -= Margin;
Width += Margin * 2;
Height += Margin * 2;
}
}

View File

@@ -1,10 +0,0 @@
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
public record Color
{
public int R { get; init; } = 0;
public int G { get; init; } = 0;
public int B { get; init; } = 0;
}

View File

@@ -1,8 +0,0 @@
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
public static class Extensions
{
public static double GetRight(this IAnnotation annotation) => annotation.Left + annotation?.Width ?? 0;
public static double GetBottom(this IAnnotation annotation) => annotation.Top + annotation?.Height ?? 0;
}

View File

@@ -1,22 +0,0 @@
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
public interface IAnnotation
{
string Name { get; }
double? Width { get; }
double? Height { get; }
double Left { get; }
double Top { get; }
Color? BackgroundColor { get; }
Color? BorderColor { get; }
string? BorderStyle { get; }
int? BorderWidth { get; }
}

View File

@@ -1,17 +0,0 @@
namespace EnvelopeGenerator.API.Models;
/// <summary>
/// Represents the parameters for two-factor authentication (2FA) registration.
/// </summary>
public class TFARegParams
{
/// <summary>
/// The maximum allowed time for completing the registration process.
/// </summary>
public TimeSpan TimeLimit { get; init; } = new(0, 30, 0);
/// <summary>
/// The deadline for registration, calculated as the current time plus the <see cref="TimeLimit"/>.
/// </summary>
public DateTime Deadline => DateTime.Now.AddTicks(TimeLimit.Ticks);
}

View File

@@ -1,18 +0,0 @@
namespace EnvelopeGenerator.API.Options;
/// <summary>
/// Configuration options for distributed caching.
/// </summary>
public sealed class CacheOptions
{
/// <summary>
/// Configuration section name in appsettings.json.
/// </summary>
public const string SectionName = "Cache";
/// <summary>
/// Signature cache expiration time.
/// If null, signatures will not expire automatically.
/// </summary>
public TimeSpan? SignatureCacheExpiration { get; set; }
}

View File

@@ -1,355 +0,0 @@
using DigitalData.Core.API;
using DigitalData.Core.Application;
using EnvelopeGenerator.Infrastructure;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Localization;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using Scalar.AspNetCore;
using Microsoft.OpenApi.Models;
using DigitalData.UserManager.DependencyInjection;
using EnvelopeGenerator.Application;
using DigitalData.Auth.Client;
using DigitalData.Core.Abstractions;
using EnvelopeGenerator.API.Models;
using Microsoft.IdentityModel.Tokens;
using DigitalData.Core.Abstractions.Security.Extensions;
using EnvelopeGenerator.API.Middleware;
using EnvelopeGenerator.API.Options;
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!");
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("yarp.json", optional: true, reloadOnChange: true);
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace);
if (!builder.Environment.IsDevelopment())
{
builder.Logging.ClearProviders();
builder.Host.UseNLog();
}
var config = builder.Configuration;
var deferredProvider = new DeferredServiceProvider();
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"));
// CORS Policy
var allowedOrigins = config.GetSection("AllowedOrigins").Get<string[]>() ??
throw new InvalidOperationException("AllowedOrigins section is missing in the configuration.");
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowSpecificOriginsPolicy", builder =>
{
builder.WithOrigins(allowedOrigins)
.SetIsOriginAllowedToAllowWildcardSubdomains()
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
// Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Version = "v1",
Title = "signFLOW Absender-API",
Description = "Eine API zur Verwaltung der Erstellung, des Versands und der Nachverfolgung von Umschlägen in der signFLOW-Anwendung.",
Contact = new OpenApiContact
{
Name = "Digital Data GmbH",
Url = new Uri("https://digitaldata.works/digitale-signatur#kontakt"),
Email = "info-flow@digitaldata.works"
},
});
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "JWT-Autorisierungs-Header unter Verwendung des Bearer-Schemas.",
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
var xmlFiles = Directory.GetFiles(AppContext.BaseDirectory, "*.xml");
foreach (var xmlFile in xmlFiles)
{
options.IncludeXmlComments(xmlFile);
}
options.DocumentFilter<EnvelopeGenerator.API.Documentation.AuthProxyDocumentFilter>();
});
#if NET9_0_OR_GREATER
builder.Services.AddOpenApi();
#endif
//Add EF Core dbcontext
var useDbMigration = Environment.GetEnvironmentVariable("MIGRATION_TEST_MODE") == true.ToString() || config.GetValue<bool>("UseDbMigration");
var cnnStrName = useDbMigration ? "DbMigrationTest" : "Default";
var connStr = config.GetConnectionString(cnnStrName)
?? throw new InvalidOperationException($"Connection string '{cnnStrName}' is missing in the application configuration.");
builder.Services.Configure<ConnectionString>(cs => cs.Value = connStr);
builder.Services.AddDbContext<EGDbContext>(options => options.UseSqlServer(connStr));
builder.Services.AddAuthHubClient(config.GetSection("AuthClientParams"));
var authTokenKeys = config.GetOrDefault<AuthTokenKeys>();
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(AuthScheme.Sender, opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (token, securityToken, identifier, parameters) =>
{
var clientParams = deferredProvider.GetOptions<ClientParams>();
var publicKey = clientParams!.PublicKeys.Get(authTokenKeys.Issuer, authTokenKeys.Audience);
return [publicKey.SecurityKey];
},
ValidateIssuer = true,
ValidIssuer = authTokenKeys.Issuer,
ValidateAudience = true,
ValidAudience = authTokenKeys.Audience,
};
opt.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
// if there is no token read related cookie or query string
if (context.Token is null) // if there is no token
{
if (context.Request.Cookies.TryGetValue(authTokenKeys.Cookie, out var cookieToken) && cookieToken is not null)
context.Token = cookieToken;
else if (context.Request.Query.TryGetValue(authTokenKeys.QueryString, out var queryStrToken))
context.Token = queryStrToken;
}
return Task.CompletedTask;
}
};
})
// Per-envelope receiver scheme: reads the JWT from the cookie named
// AuthTokenSignFLOWReceiver.{envelope_key} where envelope_key is the
// last path segment of the request URL.
// This enables simultaneous authentication for multiple envelopes
// within the same browser session.
.AddJwtBearer(AuthScheme.Receiver, opt =>
{
opt.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKeyResolver = (token, securityToken, identifier, parameters) =>
{
var clientParams = deferredProvider.GetOptions<ClientParams>();
var publicKey = clientParams!.PublicKeys.Get(authTokenKeys.Issuer, authTokenKeys.Audience);
return [publicKey.SecurityKey];
},
ValidateIssuer = true,
ValidIssuer = authTokenKeys.Issuer,
ValidateAudience = true,
ValidAudience = authTokenKeys.Audience,
};
opt.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var paths = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
// Derive the envelope key from the last route segment: /{envelope_key}
var envelopeKey = paths?.LastOrDefault();
if (envelopeKey is not null)
{
var cookieName = CookieNames.GetEnvelopeReceiverCookieName(authTokenKeys.Cookie, envelopeKey);
if (context.Request.Cookies.TryGetValue(cookieName, out var cookieToken) && cookieToken is not null)
context.Token = cookieToken;
}
return Task.CompletedTask;
},
OnTokenValidated = context =>
{
var paths = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
var envelopeKey = paths?.LastOrDefault();
var sub = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? context.Principal?.FindFirst("sub")?.Value;
if (envelopeKey is null || sub != envelopeKey)
context.Fail("Envelope key in the path does not match the token subject.");
return Task.CompletedTask;
}
};
});
// Authentication
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.HttpOnly = true; // Makes the cookie inaccessible to client-side scripts for security
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; // Ensures cookies are sent over HTTPS only
options.Cookie.SameSite = SameSiteMode.Strict; // Protects against CSRF attacks by restricting how cookies are sent with requests from external sites
options.LoginPath = "/api/auth/login";
options.LogoutPath = "/api/auth/logout";
options.SlidingExpiration = true;
});
builder.Services.AddAuthorizationBuilder()
.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))
.AddPolicy(AuthPolicy.Receiver, policy => policy
.AddAuthenticationSchemes(AuthScheme.Receiver)
.RequireAuthenticatedUser()
.RequireRole(Role.Receiver.Full, "receiver"))
.AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA));
// User manager
#pragma warning disable CS0618 // Type or member is obsolete
builder.Services.AddUserManager<EGDbContext>();
#pragma warning restore CS0618 // Type or member is obsolete
// LDAP
builder.ConfigureBySection<DirectorySearchOptions>();
builder.Services.AddDirectorySearchService(config.GetSection("DirectorySearchOptions"));
// Localizer
builder.Services.AddCookieBasedLocalizer();
// Cache options
builder.Services.Configure<CacheOptions>(config.GetSection(CacheOptions.SectionName));
// Distributed Cache - SQL Server
builder.Services.AddDistributedSqlServerCache(options =>
{
config.GetSection("Cache:SqlServer").Bind(options);
if (string.IsNullOrWhiteSpace(options.ConnectionString))
{
options.ConnectionString = connStr;
}
});
// Envelope generator serives
#pragma warning disable CS0618 // Type or member is obsolete
builder.Services
.AddEnvelopeGeneratorInfrastructureServices(opt =>
{
opt.AddDbTriggerParams(config);
opt.AddDbContext((provider, options) =>
{
var logger = provider.GetRequiredService<ILogger<EGDbContext>>();
options.UseSqlServer(connStr)
.LogTo(log => logger.LogInformation("{log}", log), Microsoft.Extensions.Logging.LogLevel.Trace)
.EnableSensitiveDataLogging()
.EnableDetailedErrors();
});
opt.AddSQLExecutor(executor => executor.ConnectionString = connStr);
})
.AddEnvelopeGeneratorServices(config);
#pragma warning restore CS0618 // Type or member is obsolete
var app = builder.Build();
deferredProvider.Factory = () => app.Services;
app.UseMiddleware<ExceptionHandlingMiddleware>();
#if NET9_0_OR_GREATER
app.MapOpenApi();
#endif
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment() || (app.IsDevOrDiP() && config.GetValue<bool>("UseSwagger")))
{
app.UseSwagger();
app.UseSwaggerUI();
app.MapScalarApiReference();
}
// Set CORS policy
app.UseCors("AllowSpecificOriginsPolicy");
// Localizer
string[] supportedCultureNames = ["de-DE", "en-US"];
IList<CultureInfo> list = [.. supportedCultureNames.Select(cn => new CultureInfo(cn))];
var cultureInfo = list.FirstOrDefault() ?? throw new InvalidOperationException("There is no supported culture.");
var requestLocalizationOptions = new RequestLocalizationOptions
{
SupportedCultures = list,
SupportedUICultures = list
};
requestLocalizationOptions.RequestCultureProviders.Add(new QueryStringRequestCultureProvider());
app.UseRequestLocalization(requestLocalizationOptions);
app.UseHttpsRedirection();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
// Catch-all YARP proxy — only forward requests that are not swagger/scalar/openapi paths.
app.MapReverseProxy();
app.Run();
}
catch (Exception ex)
{
logger.Error(ex, "Stopped program because of exception");
throw;
}

View File

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

View File

@@ -1,51 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:4176",
"sslPort": 44300
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5131",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "sender",
"applicationUrl": "https://localhost:8088;http://localhost:5131",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"angular": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "",
"applicationUrl": "https://localhost:7174;http://localhost:5131",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,29 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ReverseProxy": {
"Clusters": {
"receiver-ui": {
"Destinations": {
"primary": {
"Address": "https://localhost:52936"
}
}
}
}
},
"AuthClientParams": {
"Url": "http://172.24.12.39:9090/auth-hub",
"PublicKeys": [
{
"Issuer": "auth.digitaldata.works",
"Audience": "sign-flow.digitaldata.works"
}
],
"RetryDelay": "00:00:05"
}
}

View File

@@ -1,256 +0,0 @@
{
"UseSwagger": true,
"UseDbMigration": false,
"DiPMode": true,
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"AllowedOrigins": [ "http://localhost:4200", "http://172.24.12.39:9090", "https://localhost:8088", "http://localhost:5131", "http://localhost:7192" ],
"ConnectionStrings": {
"Default": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;",
"DbMigrationTest": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM_DATA_MIGR_TEST;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
},
"DirectorySearchOptions": {
"ServerName": "DD-VMP01-DC01",
"Root": "DC=dd-gan,DC=local,DC=digitaldata,DC=works",
"UserCacheExpirationDays": 1,
"CustomSearchFilters": {
"User": "(&(objectClass=user)(sAMAccountName=*))",
"Group": "(&(objectClass=group)(samAccountName=*))"
}
},
"AuthClientParams": {
"Url": "http://172.24.12.39:9090/auth-hub",
"PublicKeys": [
{
"Issuer": "auth.digitaldata.works",
"Audience": "sign-flow.digitaldata.works"
}
],
"RetryDelay": "00:00:05"
},
"AuthTokenKeys": {
"Cookie": "AuthToken",
"QueryString": "AuthToken",
"Issuer": "auth.digitaldata.works",
"Audience": "sign-flow.digitaldata.works"
},
"PSPDFKitLicenseKey": "SXCtGGY9XA-31OGUXQK-r7c6AkdLGPm2ljuyDr1qu0kkhLvydg-Do-fxpNUF4Rq3fS_xAnZRNFRHbXpE6sQ2BMcCSVTcXVJO6tPviexjpiT-HnrDEySlUERJnnvh-tmeOWprxS6BySPnSILkmaVQtUfOIUS-cUbvvEYHTvQBKbSF8di4XHQFyfv49ihr51axm3NVV3AXwh2EiKL5C5XdqBZ4sQ4O7vXBjM2zvxdPxlxdcNYmiU83uAzw7B83O_jubPzya4CdUHh_YH7Nlp2gP56MeG1Sw2JhMtfG3Rj14Sg4ctaeL9p6AEWca5dDjJ2li5tFIV2fQSsw6A_cowLu0gtMm5i8IfJXeIcQbMC2-0wGv1oe9hZYJvFMdzhTM_FiejM0agemxt3lJyzuyP8zbBSOgp7Si6A85krLWPZptyZBTG7pp7IHboUHfPMxCXqi-zMsqewOJtQBE2mjntU-lPryKnssOpMPfswwQX7QSkJYV5EMqNmEhQX6mEkp2wcqFzMC7bJQew1aO4pOpvChUaMvb1vgRek0HxLag0nwQYX2YrYGh7F_xXJs-8HNwJe8H0-eW4x4faayCgM5rB5772CCCsD9ThZcvXFrjNHHLGJ8WuBUFm6LArvSfFQdii_7j-_sqHMpeKZt26NFgivj1A==",
"Content-Security-Policy": [ // The first format parameter {0} will be replaced by the nonce value.
"default-src 'self'",
"script-src 'self' 'nonce-{0}' 'unsafe-eval'",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com:*",
"img-src 'self' data: https: blob:",
"font-src 'self' https://fonts.gstatic.com:*",
"connect-src 'self' https://nominatim.openstreetmap.org:* http://localhost:* https://localhost:* ws://localhost:* wss://localhost:* blob:",
"frame-src 'self'",
"media-src 'self'",
"object-src 'self'"
],
"NLog": {
"throwConfigExceptions": true,
"variables": {
"logDirectory": "E:\\LogFiles\\Digital Data\\signFlow",
"logFileNamePrefix": "${shortdate}-ECM.EnvelopeGenerator.Web"
},
"targets": {
"infoLogs": {
"type": "File",
"fileName": "${logDirectory}\\${logFileNamePrefix}-Info.log",
"maxArchiveDays": 30
},
"errorLogs": {
"type": "File",
"fileName": "${logDirectory}\\${logFileNamePrefix}-Error.log",
"maxArchiveDays": 30
},
"criticalLogs": {
"type": "File",
"fileName": "${logDirectory}\\${logFileNamePrefix}-Critical.log",
"maxArchiveDays": 30
}
},
// Trace, Debug, Info, Warn, Error and *Fatal*
"rules": [
{
"logger": "*",
"minLevel": "Info",
"maxLevel": "Warn",
"writeTo": "infoLogs"
},
{
"logger": "*",
"level": "Error",
"writeTo": "errorLogs"
},
{
"logger": "*",
"level": "Fatal",
"writeTo": "criticalLogs"
}
]
},
"ContactLink": {
"Label": "Kontakt",
"Href": "https://digitaldata.works/",
"HrefLang": "de",
"Target": "_blank",
"Title": "Digital Data GmbH"
},
/* Resx naming format is -> Resource.language.resx (eg: Resource.de_DE.resx).
To add a new language, first you should write the required resx file.
first is the default culture name. */
"Cultures": [
{
"Language": "de-DE",
"FIClass": "fi-de"
},
{
"Language": "en-US",
"FIClass": "fi-us"
}
],
"DisableMultiLanguage": false,
"Regexes": [
{
"Pattern": "/^\\p{L}+(?:([\\ \\-\\']|(\\.\\ ))\\p{L}+)*$/u",
"Name": "City",
"Platforms": [ ".NET" ]
},
{
"Pattern": "/^[a-zA-Z\\u0080-\\u024F]+(?:([\\ \\-\\']|(\\.\\ ))[a-zA-Z\\u0080-\\u024F]+)*$/",
"Name": "City",
"Platforms": [ "javascript" ]
}
],
"CustomImages": {
"App": {
"Src": "/img/DD_signFLOW_LOGO.png",
"Classes": {
"Main": "signFlow-logo"
}
},
"Company": {
"Src": "/img/digital_data.svg",
"Classes": {
"Show": "dd-show-logo",
"Locked": "dd-locked-logo"
}
}
},
"DispatcherParams": {
"SendingProfile": 1,
"AddedWho": "DDEnvelopGenerator",
"ReminderTypeId": 202377,
"EmailAttmt1": ""
},
"MailParams": {
"Placeholders": {
"[NAME_PORTAL]": "signFlow",
"[SIGNATURE_TYPE]": "signieren",
"[REASON]": ""
}
},
"GtxMessagingParams": {
"Uri": "https://rest.gtx-messaging.net",
"Path": "smsc/sendsms/f566f7e5-bdf2-4a9a-bf52-ed88215a432e/json",
"Headers": {},
"QueryParams": {
"from": "signFlow"
}
},
"TFARegParams": {
"TimeLimit": "00:30:00"
},
"DbTriggerParams": {
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"EnvelopeHistory": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
"Receiver": [],
"EmailTemplate": [ "TBSIG_EMAIL_TEMPLATE_AFT_UPD" ]
},
"Cache": {
"SignatureCacheExpiration": null,
"SqlServer": {
"ConnectionString": null,
"SchemaName": "dbo",
"TableName": "TBDD_CACHE"
}
},
"MainPageTitle": null,
"AnnotationParams": {
"Background": {
"Margin": 0.20,
"BackgroundColor": {
"R": 222,
"G": 220,
"B": 215
},
"BorderColor": {
"R": 204,
"G": 202,
"B": 198
},
"BorderStyle": "underline",
"BorderWidth": 4
},
"DefaultAnnotation": {
"Width": 1,
"Height": 0.5,
"MarginTop": 1
},
"Annotations": [
{
"Name": "Signature",
"MarginTop": 0
},
{
"Name": "PositionLabel",
"VerBoundAnnotName": "Signature",
"WidthRatio": 1.2,
"HeightRatio": 0.5,
"MarginTopRatio": 0.22
},
{
"Name": "Position",
"VerBoundAnnotName": "PositionLabel",
"WidthRatio": 1.2,
"HeightRatio": 0.5,
"MarginTopRatio": -0.05
},
{
"Name": "CityLabel",
"VerBoundAnnotName": "Position",
"WidthRatio": 1.2,
"HeightRatio": 0.5,
"MarginTopRatio": 0.05
},
{
"Name": "City",
"VerBoundAnnotName": "CityLabel",
"WidthRatio": 1.2,
"HeightRatio": 0.5,
"MarginTopRatio": -0.05
},
{
"Name": "DateLabel",
"VerBoundAnnotName": "City",
"WidthRatio": 1.55,
"HeightRatio": 0.5,
"MarginTopRatio": 0.05
},
{
"Name": "Date",
"VerBoundAnnotName": "DateLabel",
"WidthRatio": 1.55,
"HeightRatio": 0.5,
"MarginTopRatio": -0.1
}
]
}
}

View File

@@ -1,185 +0,0 @@
{
"ReverseProxy": {
"Routes": {
"receiver-ui-root": {
"ClusterId": "receiver-ui",
"Order": 300,
"Match": {
"Path": "/",
"Methods": [ "GET", "HEAD" ]
},
"Transforms": [
{ "PathSet": "/index.html" }
]
},
"receiver-ui-sender": {
"ClusterId": "receiver-ui",
"Order": 100,
"Match": {
"Path": "/sender/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
},
"Transforms": [
{ "PathSet": "/index.html" }
]
},
"receiver-ui-envelope": {
"ClusterId": "receiver-ui",
"Order": 100,
"Match": {
"Path": "/envelope/{EnvelopeKey}",
"Methods": [ "GET", "HEAD" ]
},
"Transforms": [
{ "PathSet": "/index.html" }
]
},
"receiver-ui-envelope-dxreportviewer": {
"ClusterId": "receiver-ui",
"Order": 90,
"Match": {
"Path": "/envelope/{EnvelopeKey}/DxReportViewer",
"Methods": [ "GET", "HEAD" ]
},
"Transforms": [
{ "PathSet": "/index.html" }
]
},
"receiver-ui-blazor-framework": {
"ClusterId": "receiver-ui",
"Order": 50,
"Match": {
"Path": "/_framework/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-blazor-content": {
"ClusterId": "receiver-ui",
"Order": 50,
"Match": {
"Path": "/_content/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-static-css": {
"ClusterId": "receiver-ui",
"Order": 200,
"Match": {
"Path": "/css/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
},
"Transforms": [
{
"ResponseHeader": "Cache-Control",
"Set": "no-cache, no-store, must-revalidate",
"When": "Always"
}
]
},
"receiver-ui-static-js": {
"ClusterId": "receiver-ui",
"Order": 200,
"Match": {
"Path": "/js/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
},
"Transforms": [
{
"ResponseHeader": "Cache-Control",
"Set": "no-cache, no-store, must-revalidate",
"When": "Always"
}
]
},
"receiver-ui-fake-data": {
"ClusterId": "receiver-ui",
"Order": 200,
"Match": {
"Path": "/fake-data/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-appsettings": {
"ClusterId": "receiver-ui",
"Order": 50,
"Match": {
"Path": "/appsettings.json",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-appsettings-dev": {
"ClusterId": "receiver-ui",
"Order": 50,
"Match": {
"Path": "/appsettings.Development.json",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-styles": {
"ClusterId": "receiver-ui",
"Order": 50,
"Match": {
"Path": "/EnvelopeGenerator.ReceiverUI.styles.css",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-fonts": {
"ClusterId": "receiver-ui",
"Order": 200,
"Match": {
"Path": "/fonts/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
}
},
"receiver-ui-images": {
"ClusterId": "receiver-ui",
"Order": 200,
"Match": {
"Path": "/images/{**catch-all}",
"Methods": [ "GET", "HEAD" ]
}
},
"auth-login": {
"ClusterId": "auth-hub",
"Match": {
"Path": "/api/auth",
"Methods": [ "POST" ]
},
"Transforms": [
{ "PathSet": "/api/auth/sign-flow" }
]
},
"auth-envelope-receiver-login": {
"ClusterId": "auth-hub",
"Match": {
"Path": "/api/Auth/envelope-receiver/{key}",
"Methods": [ "POST" ]
},
"Transforms": [
{ "PathPattern": "/api/auth/envelope-receiver/{key}" },
{
"QueryValueParameter": "cookie",
"Set": "true"
}
]
}
},
"Clusters": {
"receiver-ui": {
"Destinations": {
"primary": {
"Address": "https://localhost:52936"
}
}
},
"auth-hub": {
"Destinations": {
"primary": {
"Address": "http://172.24.12.39:9090"
}
}
}
}
}
}

View File

@@ -60,6 +60,11 @@ public record EnvelopeReceiverDto
/// </summary>
public string? PrivateMessage { get; init; }
/// <summary>
///
/// </summary>
public string? AccessCode { get; init; }
/// <summary>
///
/// </summary>

View File

@@ -0,0 +1,21 @@
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
namespace EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
public record EnvelopeDeletedNotification : INotification, ISendMailNotification
{
public required Envelope Envelope { get; init; }
public required EnvelopeReceiver EnvelopeReceiver { get; init; }
public required string Reason { get; init; }
public required string SenderReference { get; init; }
public EmailTemplateType TemplateType => EmailTemplateType.DocumentDeleted;
public string EmailAddress => EnvelopeReceiver.Receiver?.EmailAddress
?? throw new InvalidOperationException("Receiver email is missing for EnvelopeDeletedNotification.");
}

View File

@@ -0,0 +1,42 @@
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted.Handlers;
public class SendDeletedMailHandler : SendMailHandler<EnvelopeDeletedNotification>
{
public SendDeletedMailHandler(
IRepository<EmailTemplate> tempRepo,
IRepository<EmailOut> emailOutRepo,
IOptions<MailParams> mailParamsOptions,
IOptions<DispatcherParams> dispatcherParamsOptions)
: base(tempRepo, emailOutRepo, mailParamsOptions, dispatcherParamsOptions)
{
}
protected override void ConfigureEmailOut(EnvelopeDeletedNotification notification, EmailOut emailOut)
{
emailOut.ReferenceString = notification.EmailAddress;
emailOut.ReferenceId = notification.EnvelopeReceiver.ReceiverId;
emailOut.WfId = (int)EnvelopeStatus.MessageDeletionSent;
}
protected override Dictionary<string, string> CreatePlaceHolders(EnvelopeDeletedNotification notification)
{
var senderName = notification.Envelope.User?.Email;
if (string.IsNullOrWhiteSpace(senderName))
senderName = notification.SenderReference;
return new Dictionary<string, string>
{
["[NAME_RECEIVER]"] = notification.EnvelopeReceiver.Name ?? string.Empty,
["[NAME_SENDER]"] = senderName ?? string.Empty,
["[DOCUMENT_TITLE]"] = notification.Envelope.Title ?? string.Empty,
["[REASON]"] = notification.Reason
};
}
}

View File

@@ -1,5 +1,4 @@
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using MediatR;
using System.ComponentModel.DataAnnotations;
@@ -13,23 +12,29 @@ namespace EnvelopeGenerator.Application.Envelopes.Commands;
/// </summary>
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
{
public int? EnvelopeId { get; init; }
/// <summary>
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
/// </summary>
[Required]
public required string Title { get; set; }
public string? Title { get; set; }
/// <summary>
/// Die Nachricht, die im Umschlag enthalten sein soll. Dies ist ein Pflichtfeld.
/// </summary>
[Required]
public required string Message { get; set; }
public string? Message { get; set; }
/// <summary>
/// Gibt an, ob die Zwei-Faktor-Authentifizierung für den Umschlag aktiviert ist. Standardmäßig false.
/// </summary>
public bool TFAEnabled { get; set; } = false;
public bool Send { get; init; }
public CreateEnvelopeDocumentDto? Document { get; init; }
public List<CreateEnvelopeReceiverDto> Receivers { get; init; } = new();
/// <summary>
/// ID des Absenders
/// </summary>
@@ -56,3 +61,13 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
[NotMapped]
public bool UseSQLExecutor { get; set; } = true;
}
public record CreateEnvelopeDocumentDto(string DataAsBase64);
public record CreateEnvelopeFieldDto(double X, double Y, int Page);
public record CreateEnvelopeReceiverDto(
string EmailAddress,
string? Name,
string? PhoneNumber,
List<CreateEnvelopeFieldDto> Fields);

View File

@@ -1,10 +1,20 @@
using AutoMapper;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.Application.Histories.Commands;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.Envelopes.Commands;
@@ -19,6 +29,26 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
private IRepository<Envelope> Repository => _provider.GetRequiredService<IRepository<Envelope>>();
private IRepository<Document> DocumentRepository => _provider.GetRequiredService<IRepository<Document>>();
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => _provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
private IRepository<Receiver> ReceiverRepository => _provider.GetRequiredService<IRepository<Receiver>>();
private IRepository<DocReceiverElement> DocReceiverElementRepository => _provider.GetRequiredService<IRepository<DocReceiverElement>>();
private IRepository<EmailTemplate> EmailTemplateRepository => _provider.GetRequiredService<IRepository<EmailTemplate>>();
private IRepository<EmailOut> EmailOutRepository => _provider.GetRequiredService<IRepository<EmailOut>>();
private MailParams MailParams => _provider.GetRequiredService<IOptions<MailParams>>().Value;
private DispatcherParams DispatcherParams => _provider.GetRequiredService<IOptions<DispatcherParams>>().Value;
private IConfigService ConfigService => _provider.GetRequiredService<IConfigService>();
private IMediator Mediator => _provider.GetRequiredService<IMediator>();
private readonly IMapper _mapper;
/// <summary>
@@ -40,10 +70,385 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
/// <returns></returns>
public async Task<EnvelopeDto?> Handle(CreateEnvelopeCommand request, CancellationToken cancel)
{
var envelope = request.UseSQLExecutor
? await Executor.CreateEnvelopeAsync(request.UserId, request.Title, request.Message, request.TFAEnabled, cancel)
: await Repository.CreateAsync(request, cancel);
var now = DateTime.Now;
var title = request.Title?.Trim() ?? string.Empty;
var message = request.Message?.Trim() ?? string.Empty;
Envelope envelope;
if (request.EnvelopeId is int envelopeId)
{
envelope = await Repository.Query
.Include(e => e.User)
.FirstOrDefaultAsync(e => e.Id == envelopeId, cancel)
?? throw new BadRequestException($"Envelope {envelopeId} not found.");
if (envelope.UserId != request.UserId)
throw new ForbiddenException();
await Repository.UpdateAsync(e =>
{
e.Title = title;
e.Message = message;
e.TfaEnabled = request.TFAEnabled;
e.Status = EnvelopeStatus.EnvelopeSaved;
e.ChangedWhen = now;
}, e => e.Id == envelopeId, cancel);
envelope.Title = title;
envelope.Message = message;
envelope.TfaEnabled = request.TFAEnabled;
envelope.Status = EnvelopeStatus.EnvelopeSaved;
envelope.ChangedWhen = now;
}
else
{
envelope = request.UseSQLExecutor
? await Executor.CreateEnvelopeAsync(request.UserId, title, message, request.TFAEnabled, cancel)
: await Repository.CreateAsync(new Envelope
{
UserId = request.UserId,
Status = EnvelopeStatus.EnvelopeCreated,
Uuid = Guid.NewGuid().ToString(),
Title = title,
Message = message,
AddedWhen = now,
ChangedWhen = now,
Comment = string.Empty,
Language = "de-DE",
TfaEnabled = request.TFAEnabled,
UseAccessCode = false,
SendReminderEmails = false
}, cancel);
await Mediator.Send(new CreateHistoryCommand
{
EnvelopeId = envelope.Id,
UserReference = await ResolveSenderReferenceAsync(envelope, cancel),
Status = EnvelopeStatus.EnvelopeCreated
}, cancel);
}
await UpsertDocumentAsync(envelope.Id, request.Document, now, cancel);
await UpsertReceiversAndFieldsAsync(envelope.Id, request.Receivers, now, cancel);
if (request.Send)
await SendEnvelopeAsync(envelope, cancel);
envelope = await Repository.Query
.Include(e => e.User)
.Include(e => e.EnvelopeReceivers)
.ThenInclude(er => er.Receiver)
.FirstAsync(e => e.Id == envelope.Id, cancel);
return _mapper.Map<EnvelopeDto>(envelope);
}
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
{
if (!string.IsNullOrWhiteSpace(envelope.User?.Email))
return envelope.User.Email;
var senderEmail = await Repository.Query
.Where(e => e.Id == envelope.Id)
.Select(e => e.User.Email)
.FirstOrDefaultAsync(cancel);
if (string.IsNullOrWhiteSpace(senderEmail))
throw new BadRequestException("Envelope sender email is missing.");
return senderEmail;
}
private async Task UpsertDocumentAsync(int envelopeId, CreateEnvelopeDocumentDto? document, DateTime now, CancellationToken cancel)
{
if (document is null || string.IsNullOrWhiteSpace(document.DataAsBase64))
return;
byte[] bytes;
try
{
bytes = Convert.FromBase64String(document.DataAsBase64);
}
catch (FormatException)
{
throw new BadRequestException("Document payload is not valid base64.");
}
var existingDocumentId = await DocumentRepository.Query
.Where(d => d.EnvelopeId == envelopeId)
.Select(d => (int?)d.Id)
.FirstOrDefaultAsync(cancel);
if (existingDocumentId is null)
{
await DocumentRepository.CreateAsync(new Document
{
EnvelopeId = envelopeId,
AddedWhen = now,
ByteData = bytes,
Filename = string.Empty,
Filepath = string.Empty,
FileNameOriginal = string.Empty
}, cancel);
return;
}
await DocumentRepository.UpdateAsync(d =>
{
d.ByteData = bytes;
}, d => d.Id == existingDocumentId.Value, cancel);
}
private async Task UpsertReceiversAndFieldsAsync(int envelopeId, IReadOnlyCollection<CreateEnvelopeReceiverDto> receivers, DateTime now, CancellationToken cancel)
{
var filteredReceivers = receivers
.Where(r => !string.IsNullOrWhiteSpace(r.EmailAddress))
.Select(r => r with
{
EmailAddress = r.EmailAddress.Trim().ToLowerInvariant(),
Name = r.Name?.Trim(),
PhoneNumber = string.IsNullOrWhiteSpace(r.PhoneNumber) ? null : r.PhoneNumber.Trim(),
Fields = r.Fields ?? new List<CreateEnvelopeFieldDto>()
})
.GroupBy(r => r.EmailAddress, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
if (filteredReceivers.Count == 0)
return;
var document = await DocumentRepository.Query
.FirstOrDefaultAsync(d => d.EnvelopeId == envelopeId, cancel);
if (document is null)
return;
var currentLinks = await EnvelopeReceiverRepository.Query
.Include(er => er.Receiver)
.Where(er => er.EnvelopeId == envelopeId)
.ToListAsync(cancel);
var linksByEmail = currentLinks
.Where(er => !string.IsNullOrWhiteSpace(er.Receiver?.EmailAddress))
.GroupBy(er => er.Receiver!.EmailAddress, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key.ToLowerInvariant(), g => g.First(), StringComparer.OrdinalIgnoreCase);
var sequence = currentLinks.Count > 0 ? currentLinks.Max(er => er.Sequence) : 0;
foreach (var receiverDraft in filteredReceivers)
{
var linkResult = await GetOrCreateEnvelopeReceiverAsync(envelopeId, receiverDraft, linksByEmail, now, sequence, cancel);
var link = linkResult.Link;
sequence = linkResult.NextSequence;
await DocReceiverElementRepository.DeleteAsync(query => query
.Where(e => e.DocumentId == document.Id)
.Where(e => e.ReceiverId == link.ReceiverId), cancel);
foreach (var field in receiverDraft.Fields.Where(f => f.Page > 0))
{
await DocReceiverElementRepository.CreateAsync(new DocReceiverElement
{
DocumentId = document.Id,
ReceiverId = link.ReceiverId,
ElementType = 1,
X = field.X,
Y = field.Y,
Width = 1.77,
Height = 1.96,
Page = field.Page,
Required = true,
ReadOnly = false,
AnnotationIndex = 0,
AddedWhen = now,
FullName = link.Name,
Position = string.Empty,
Place = string.Empty,
Tooltip = string.Empty
}, cancel);
}
}
}
private async Task<(EnvelopeReceiver Link, int NextSequence)> GetOrCreateEnvelopeReceiverAsync(
int envelopeId,
CreateEnvelopeReceiverDto receiverDraft,
IDictionary<string, EnvelopeReceiver> linksByEmail,
DateTime now,
int sequence,
CancellationToken cancel)
{
if (linksByEmail.TryGetValue(receiverDraft.EmailAddress, out var existingLink))
{
var nextName = string.IsNullOrWhiteSpace(receiverDraft.Name) ? existingLink.Name : receiverDraft.Name!;
var nextPhoneNumber = receiverDraft.PhoneNumber ?? existingLink.PhoneNumber;
await EnvelopeReceiverRepository.UpdateAsync(er =>
{
er.Name = nextName;
er.PhoneNumber = nextPhoneNumber;
er.ChangedWhen = now;
}, er => er.EnvelopeId == existingLink.EnvelopeId && er.ReceiverId == existingLink.ReceiverId, cancel);
existingLink.Name = nextName;
existingLink.PhoneNumber = nextPhoneNumber;
return (existingLink, sequence);
}
var receiver = await ReceiverRepository.Query
.FirstOrDefaultAsync(r => r.EmailAddress != null && r.EmailAddress.ToLower() == receiverDraft.EmailAddress, cancel);
if (receiver is null)
{
receiver = await ReceiverRepository.CreateAsync(new Receiver
{
EmailAddress = receiverDraft.EmailAddress,
Signature = receiverDraft.EmailAddress.ToUpperInvariant().GetChecksum(),
AddedWhen = now,
TotpSecretkey = string.Empty,
TfaRegDeadline = null
}, cancel);
}
sequence++;
var link = await EnvelopeReceiverRepository.CreateAsync(new EnvelopeReceiver
{
EnvelopeId = envelopeId,
ReceiverId = receiver.Id,
Sequence = sequence,
Name = receiverDraft.Name ?? receiverDraft.EmailAddress,
JobTitle = string.Empty,
CompanyName = string.Empty,
PrivateMessage = string.Empty,
AccessCode = string.Empty,
AddedWhen = now,
ChangedWhen = now,
PhoneNumber = receiverDraft.PhoneNumber ?? string.Empty
}, cancel);
link.Receiver = receiver;
linksByEmail[receiverDraft.EmailAddress] = link;
return (link, sequence);
}
private async Task SendEnvelopeAsync(Envelope envelope, CancellationToken cancel)
{
var hasDocument = await DocumentRepository.Query
.AnyAsync(d => d.EnvelopeId == envelope.Id && d.ByteData != null && d.ByteData.Length > 0, cancel);
if (!hasDocument)
throw new BadRequestException("Cannot send envelope without a document.");
var links = await EnvelopeReceiverRepository.Query
.Include(er => er.Receiver)
.Where(er => er.EnvelopeId == envelope.Id)
.ToListAsync(cancel);
if (links.Count == 0)
throw new BadRequestException("Cannot send envelope without receivers.");
var receiverIds = links.Select(l => l.ReceiverId).Distinct().ToList();
var documentIds = await DocumentRepository.Query
.Where(d => d.EnvelopeId == envelope.Id)
.Select(d => d.Id)
.ToListAsync(cancel);
var receiverFieldCounts = await DocReceiverElementRepository.Query
.Where(e => documentIds.Contains(e.DocumentId))
.Where(e => receiverIds.Contains(e.ReceiverId))
.GroupBy(e => e.ReceiverId)
.Select(g => new { ReceiverId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.ReceiverId, x => x.Count, cancel);
var receiversWithoutFields = links
.Where(link => !receiverFieldCounts.TryGetValue(link.ReceiverId, out var count) || count <= 0)
.Select(link => link.Name)
.ToList();
if (receiversWithoutFields.Count > 0)
throw new BadRequestException("Cannot send envelope because some receivers have no signature fields.");
var changedWhen = DateTime.Now;
await Repository.UpdateAsync(e =>
{
e.Status = EnvelopeStatus.EnvelopeQueued;
e.ChangedWhen = changedWhen;
}, e => e.Id == envelope.Id, cancel);
var userReference = await ResolveSenderReferenceAsync(envelope, cancel);
await Mediator.Send(new CreateHistoryCommand
{
EnvelopeId = envelope.Id,
UserReference = userReference,
Status = EnvelopeStatus.EnvelopeQueued
}, cancel);
await QueueInvitationEmailsAsync(envelope, links, userReference, cancel);
envelope.Status = EnvelopeStatus.EnvelopeQueued;
}
private async Task QueueInvitationEmailsAsync(
Envelope envelope,
IReadOnlyCollection<EnvelopeReceiver> links,
string senderReference,
CancellationToken cancel)
{
var template = await EmailTemplateRepository.Query
.SingleOrDefaultAsync(x => x.Name == EmailTemplateType.DocumentReceived.ToString(), cancel)
?? throw new InvalidOperationException($"Email template not found: {EmailTemplateType.DocumentReceived}");
var signatureHost = await ConfigService.ReadDefaultSignatureHost();
var senderName = senderReference;
foreach (var link in links)
{
var receiver = link.Receiver;
if (receiver is null || string.IsNullOrWhiteSpace(receiver.EmailAddress) || string.IsNullOrWhiteSpace(receiver.Signature))
continue;
var envelopeKey = (envelope.Uuid, receiver.Signature).ToEnvelopeKey();
var documentLink = $"{signatureHost.TrimEnd('/')}/envelope/login/{Uri.EscapeDataString(envelopeKey)}";
var placeholders = new Dictionary<string, string>(MailParams.Placeholders)
{
["[NAME_RECEIVER]"] = link.Name ?? string.Empty,
["[NAME_SENDER]"] = senderName ?? string.Empty,
["[DOCUMENT_TITLE]"] = envelope.Title ?? string.Empty,
["[MESSAGE]"] = envelope.Message ?? string.Empty,
["[LINK_TO_DOCUMENT]"] = documentLink,
["[LINK_TO_DOCUMENT_TEXT]"] = documentLink[..Math.Min(40, documentLink.Length)] + ".."
};
var subject = ReplacePlaceHolders(template.Subject, placeholders);
var body = ReplacePlaceHolders(template.Body, placeholders);
var emailOut = new EmailOut
{
EmailAddress = receiver.EmailAddress,
EmailSubj = subject,
EmailBody = body,
AddedWhen = DateTime.Now,
AddedWho = DispatcherParams.AddedWho,
SendingProfile = DispatcherParams.SendingProfile,
ReminderTypeId = DispatcherParams.ReminderTypeId,
EmailAttmt1 = DispatcherParams.EmailAttmt1,
WfId = (int)EnvelopeStatus.MessageInvitationSent,
ReferenceString = receiver.EmailAddress,
ReferenceId = link.ReceiverId
};
await EmailOutRepository.CreateAsync(emailOut, cancel);
}
}
private static string ReplacePlaceHolders(string text, Dictionary<string, string> placeHolders)
{
foreach (var placeHolder in placeHolders)
text = text.Replace(placeHolder.Key, placeHolder.Value);
return text;
}
}

View File

@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using EnvelopeGenerator.Domain.Constants;
using MediatR;
namespace EnvelopeGenerator.Application.Envelopes.Commands;
public record DeleteEnvelopeResultDto(int EnvelopeId, EnvelopeStatus AppliedStatus, bool IsNoOp);
public record DeleteEnvelopeCommand : IRequest<DeleteEnvelopeResultDto>
{
[Required]
public int EnvelopeId { get; init; }
[Required]
[MinLength(1)]
public string Reason { get; init; } = string.Empty;
internal int UserId { get; private set; }
public DeleteEnvelopeCommand WithAuth(int userId)
{
UserId = userId;
return this;
}
}

View File

@@ -0,0 +1,89 @@
using EnvelopeGenerator.Application.Common.Notifications.EnvelopeDeleted;
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Histories.Commands;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace EnvelopeGenerator.Application.Envelopes.Commands;
public class DeleteEnvelopeCommandHandler : IRequestHandler<DeleteEnvelopeCommand, DeleteEnvelopeResultDto>
{
private readonly IRepository<Envelope> _envelopeRepository;
private readonly IMediator _mediator;
public DeleteEnvelopeCommandHandler(
IRepository<Envelope> envelopeRepository,
IMediator mediator)
{
_envelopeRepository = envelopeRepository;
_mediator = mediator;
}
public async Task<DeleteEnvelopeResultDto> Handle(DeleteEnvelopeCommand request, CancellationToken cancel)
{
var reason = request.Reason?.Trim();
if (string.IsNullOrWhiteSpace(reason))
throw new BadRequestException("Please provide a reason.");
var envelope = await _envelopeRepository.Query
.Include(e => e.User)
.Include(e => e.EnvelopeReceivers)
.ThenInclude(er => er.Receiver)
.FirstOrDefaultAsync(e => e.Id == request.EnvelopeId, cancel)
?? throw new NotFoundException($"Envelope {request.EnvelopeId} not found.");
if (envelope.UserId != request.UserId)
throw new ForbiddenException();
if (envelope.Status is EnvelopeStatus.EnvelopeDeleted or EnvelopeStatus.EnvelopeWithdrawn)
return new DeleteEnvelopeResultDto(envelope.Id, envelope.Status, IsNoOp: true);
var targetStatus = envelope.Status > EnvelopeStatus.EnvelopeSaved
? EnvelopeStatus.EnvelopeWithdrawn
: EnvelopeStatus.EnvelopeDeleted;
var changedWhen = DateTime.Now;
envelope.Status = targetStatus;
envelope.Comment = reason;
envelope.ChangedWhen = changedWhen;
await _envelopeRepository.UpdateAsync(e =>
{
e.Status = targetStatus;
e.Comment = reason;
e.ChangedWhen = changedWhen;
}, e => e.Id == envelope.Id, cancel);
var userReference = envelope.User?.Email;
if (string.IsNullOrWhiteSpace(userReference))
throw new BadRequestException("Envelope sender email is missing.");
await _mediator.Send(new CreateHistoryCommand
{
EnvelopeId = envelope.Id,
UserReference = userReference,
Status = targetStatus,
Comment = reason
}, cancel);
foreach (var envelopeReceiver in envelope.EnvelopeReceivers ?? Enumerable.Empty<EnvelopeReceiver>())
{
if (envelopeReceiver.Receiver is null)
continue;
await _mediator.Publish(new EnvelopeDeletedNotification
{
Envelope = envelope,
EnvelopeReceiver = envelopeReceiver,
Reason = reason,
SenderReference = userReference
}, cancel);
}
return new DeleteEnvelopeResultDto(envelope.Id, targetStatus, IsNoOp: false);
}
}

View File

@@ -149,7 +149,9 @@ public class ReadEnvelopeQueryHandler : IRequestHandler<ReadEnvelopeQuery, IEnum
query = query.Where(e => Status.Completed.Contains(e.Status));
var envelopes = await query
.Include(e => e.Type)
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver)
.Include(e => e.Histories)
.ToListAsync(cancel);
return _mapper.Map<IEnumerable<EnvelopeDto>>(envelopes);

View File

@@ -651,4 +651,123 @@
<data name="Status" xml:space="preserve">
<value>Status</value>
</data>
<data name="ChangedAt" xml:space="preserve"><value>Geändert am</value></data>
<data name="Close" xml:space="preserve"><value>Schließen</value></data>
<data name="CloseLanguageMenu" xml:space="preserve"><value>Sprachmenü schließen</value></data>
<data name="ConfirmSignature" xml:space="preserve"><value>Unterschrift bestätigen</value></data>
<data name="Created" xml:space="preserve"><value>Erstellt</value></data>
<data name="Delete" xml:space="preserve"><value>Löschen</value></data>
<data name="Document" xml:space="preserve"><value>Dokument</value></data>
<data name="DocumentCouldNotBeLoaded" xml:space="preserve"><value>Dokument konnte nicht geladen werden</value></data>
<data name="DocumentLoading" xml:space="preserve"><value>Dokument wird geladen...</value></data>
<data name="EmailAddress" xml:space="preserve"><value>E-Mail-Adresse</value></data>
<data name="Envelope" xml:space="preserve"><value>Umschlag</value></data>
<data name="Error" xml:space="preserve"><value>Fehler</value></data>
<data name="ErrorLoadingDocument_WithReason" xml:space="preserve"><value>Fehler beim Laden des Dokuments: {0}</value></data>
<data name="ErrorPage_DevelopmentMode" xml:space="preserve"><value>Entwicklungsmodus</value></data>
<data name="ErrorPage_DevelopmentMode_Detail" xml:space="preserve"><value>Dies kann dazu führen, dass vertrauliche Informationen aus Ausnahmen Endbenutzern angezeigt werden. Für lokales Debugging setzen Sie ASPNETCORE_ENVIRONMENT auf Development und starten die App neu.</value></data>
<data name="ErrorPage_DevelopmentMode_Info" xml:space="preserve"><value>Im Entwicklungsmodus werden detailliertere Fehlerinformationen angezeigt.</value></data>
<data name="ErrorPage_DevelopmentMode_Warning" xml:space="preserve"><value>Der Entwicklungsmodus sollte in bereitgestellten Anwendungen nicht aktiviert sein.</value></data>
<data name="ErrorPage_ProcessingRequest" xml:space="preserve"><value>Bei der Verarbeitung Ihrer Anfrage ist ein Fehler aufgetreten.</value></data>
<data name="ErrorPage_RequestId" xml:space="preserve"><value>Anfrage-ID</value></data>
<data name="ErrorWithReason" xml:space="preserve"><value>Fehler: {0}</value></data>
<data name="From" xml:space="preserve"><value>Von</value></data>
<data name="FullName" xml:space="preserve"><value>Vor- und Nachname</value></data>
<data name="History" xml:space="preserve"><value>Historie</value></data>
<data name="Index_DigitalSignature" xml:space="preserve"><value>Digitale Unterschrift</value></data>
<data name="Index_PdfExport" xml:space="preserve"><value>PDF-Export</value></data>
<data name="Index_SecureAccess" xml:space="preserve"><value>Sicherer Zugang</value></data>
<data name="Index_Subtitle" xml:space="preserve"><value>Willkommen im eSign-Portal</value></data>
<data name="Index_ToEnvelopeOverview" xml:space="preserve"><value>Zur Umschlag-Übersicht</value></data>
<data name="Languages" xml:space="preserve"><value>Sprachen</value></data>
<data name="Loading" xml:space="preserve"><value>Lädt...</value></data>
<data name="Login" xml:space="preserve"><value>Anmelden</value></data>
<data name="LoginInProgress" xml:space="preserve"><value>Anmelden ...</value></data>
<data name="NotAuthenticatedPleaseLoginAgain" xml:space="preserve"><value>Nicht authentifiziert. Bitte melden Sie sich erneut an.</value></data>
<data name="Only" xml:space="preserve"><value>Nur</value></data>
<data name="OpenDocument" xml:space="preserve"><value>Dokument öffnen</value></data>
<data name="Optional" xml:space="preserve"><value>optional</value></data>
<data name="Pending" xml:space="preserve"><value>Ausstehend</value></data>
<data name="Preview" xml:space="preserve"><value>Vorschau</value></data>
<data name="PrivacyPolicy" xml:space="preserve"><value>Datenschutz</value></data>
<data name="Receiver" xml:space="preserve"><value>Empfänger</value></data>
<data name="ReceiverLogin_AccessCode_Placeholder" xml:space="preserve"><value>Zugangscode eingeben</value></data>
<data name="ReceiverLogin_Description" xml:space="preserve"><value>Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.</value></data>
<data name="ReceiverLogin_FooterHelp" xml:space="preserve"><value>Bei Problemen wenden Sie sich bitte an den Absender des Dokuments.</value></data>
<data name="ReceiverLogin_InvalidCode_Body" xml:space="preserve"><value>Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.</value></data>
<data name="ReceiverLogin_InvalidCode_Title" xml:space="preserve"><value>Ungültiger Zugangscode.</value></data>
<data name="ReceiverLogin_NotFound_Body" xml:space="preserve"><value>Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</value></data>
<data name="ReceiverLogin_NotFound_Title" xml:space="preserve"><value>Dokument nicht gefunden.</value></data>
<data name="ReceiverLogin_Subtitle" xml:space="preserve"><value>Sicherer Zugang mit Zugangscode</value></data>
<data name="ReceiverSigned_ConfirmBody" xml:space="preserve"><value>Diese Aktion kann nicht rückgängig gemacht werden. Mit der Bestätigung erklären Sie, das oben angezeigte Dokument elektronisch unterzeichnet zu haben. Das unterzeichnete Dokument wird anschließend an alle beteiligten Parteien übermittelt.</value></data>
<data name="ReceiverSigned_ConfirmQuestion" xml:space="preserve"><value>Möchten Sie das Dokument verbindlich unterschreiben?</value></data>
<data name="ReceiverSigned_EnvelopeKeyMissing" xml:space="preserve"><value>Envelope-Schlüssel fehlt.</value></data>
<data name="Refresh" xml:space="preserve"><value>Aktualisieren</value></data>
<data name="RequiredField" xml:space="preserve"><value>Pflichtfeld</value></data>
<data name="Selected" xml:space="preserve"><value>ausgewählte</value></data>
<data name="Send" xml:space="preserve"><value>Senden</value></data>
<data name="Sender_ActiveEnvelopes" xml:space="preserve"><value>Aktive Umschläge</value></data>
<data name="Sender_CompletedEnvelopes" xml:space="preserve"><value>Abgeschlossene Umschläge</value></data>
<data name="Sender_CreateEnvelope" xml:space="preserve"><value>Neuer Umschlag</value></data>
<data name="Sender_CreateEnvelope_Title" xml:space="preserve"><value>Neuen Umschlag erstellen</value></data>
<data name="Sender_DeleteEnvelope_Header" xml:space="preserve"><value>Umschlag löschen</value></data>
<data name="Sender_DeleteEnvelope_ReasonPlaceholder" xml:space="preserve"><value>Begründung eingeben...</value></data>
<data name="Sender_DeleteEnvelope_ReasonPrompt" xml:space="preserve"><value>Bitte geben Sie einen Grund an, warum der Umschlag gelöscht/zurückgezogen werden soll.</value></data>
<data name="Sender_DeleteEnvelope_ReasonRequired" xml:space="preserve"><value>Bitte geben Sie einen Löschgrund an.</value></data>
<data name="Sender_DeleteEnvelope_Title" xml:space="preserve"><value>Ausgewählten Umschlag löschen</value></data>
<data name="Sender_Edit" xml:space="preserve"><value>Bearbeiten</value></data>
<data name="Sender_EditEnvelope_Title" xml:space="preserve"><value>Ausgewählten Umschlag bearbeiten</value></data>
<data name="Sender_ExportCurrentView_Title" xml:space="preserve"><value>Aktuelle Ansicht exportieren</value></data>
<data name="Sender_ExportFormat_Aria" xml:space="preserve"><value>Exportformat auswählen</value></data>
<data name="Sender_ExportView_Title" xml:space="preserve"><value>Export der aktuellen Tabellenansicht</value></data>
<data name="Sender_LoadError_Title" xml:space="preserve"><value>Fehler beim Laden der Umschläge</value></data>
<data name="Sender_LoadingEnvelopes" xml:space="preserve"><value>Umschläge werden geladen...</value></data>
<data name="Sender_NoHistory" xml:space="preserve"><value>Keine Historie vorhanden</value></data>
<data name="Sender_NoReceivers" xml:space="preserve"><value>Keine Empfänger</value></data>
<data name="Sender_Preview_Title" xml:space="preserve"><value>Ausgewählten Umschlag als PDF anzeigen</value></data>
<data name="Sender_PreviewDocumentNotFound" xml:space="preserve"><value>Das Dokument für Umschlag #{0} wurde nicht gefunden oder Sie haben keinen Zugriff darauf.</value></data>
<data name="SenderEditor_AddSignature" xml:space="preserve"><value>Signatur hinzufügen</value></data>
<data name="SenderEditor_ClickDocumentFor" xml:space="preserve"><value>Klicken Sie auf die Stelle im Dokument für</value></data>
<data name="SenderEditor_EmailSuggestionsHint" xml:space="preserve"><value>Bereits verwendete E-Mail-Adressen werden bei der Eingabe vorgeschlagen.</value></data>
<data name="SenderEditor_EmailSuggestionsLoading" xml:space="preserve"><value>E-Mail-Vorschläge werden geladen...</value></data>
<data name="SenderEditor_ErrorCreatingEnvelope" xml:space="preserve"><value>Fehler beim Erstellen des Umschlags</value></data>
<data name="SenderEditor_LoadEnvelopeError" xml:space="preserve"><value>Fehler beim Laden des Umschlags #{0}: {1}</value></data>
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Fehler beim Laden der Datei: {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Bitte unterzeichnen Sie das beigefügte Dokument.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Kein Dokument geladen</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Noch keine Empfänger hinzugefügt.</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Signaturfeld platzieren</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Alle Signaturfelder entfernen</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Bitte wählen Sie eine PDF-Datei aus.</value></data>
<data name="SenderEditor_Sent_Body" xml:space="preserve"><value>Der bestehende Entwurf wurde versendet.</value></data>
<data name="SenderEditor_Sent_Header" xml:space="preserve"><value>Umschlag gesendet</value></data>
<data name="SenderEditor_Sent_Title" xml:space="preserve"><value>Umschlag wurde erfolgreich gesendet.</value></data>
<data name="SenderEditor_SignatureField" xml:space="preserve"><value>Signaturfeld</value></data>
<data name="SenderEditor_Title_Placeholder" xml:space="preserve"><value>z.B. Arbeitsvertrag 2025</value></data>
<data name="SenderEditor_ToOverview" xml:space="preserve"><value>Zur Übersicht</value></data>
<data name="SenderEditor_UploadPdfToPlaceFields" xml:space="preserve"><value>Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.</value></data>
<data name="SenderEditor_Validation_EmailDuplicate" xml:space="preserve"><value>Diese E-Mail-Adresse wurde bereits hinzugefügt.</value></data>
<data name="SenderEditor_Validation_EmailInvalid" xml:space="preserve"><value>Bitte geben Sie eine gültige E-Mail-Adresse ein.</value></data>
<data name="SenderEditor_Validation_EmailRequired" xml:space="preserve"><value>Bitte geben Sie eine E-Mail-Adresse ein.</value></data>
<data name="SenderEditor_Validation_FullNameRequired" xml:space="preserve"><value>Bitte geben Sie einen Vor- und Nachnamen ein.</value></data>
<data name="SenderEditor_Validation_ReceiverRequired" xml:space="preserve"><value>Bitte fügen Sie mindestens einen Empfänger hinzu.</value></data>
<data name="SenderEditor_Validation_SignatureFieldRequired" xml:space="preserve"><value>Bitte platzieren Sie mindestens ein Signaturfeld.</value></data>
<data name="SenderEditor_Validation_TitleRequired" xml:space="preserve"><value>Bitte geben Sie einen Titel ein.</value></data>
<data name="SenderEditor_Validation_UploadPdf" xml:space="preserve"><value>Bitte laden Sie zuerst ein PDF-Dokument hoch.</value></data>
<data name="SenderLogin_Description" xml:space="preserve"><value>Bitte melden Sie sich mit Ihren Zugangsdaten an, um auf das Sender-Dashboard zuzugreifen.</value></data>
<data name="SenderLogin_FooterHelp" xml:space="preserve"><value>Bei Problemen wenden Sie sich bitte an den Administrator.</value></data>
<data name="SenderLogin_InvalidCredentials_Body" xml:space="preserve"><value>Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.</value></data>
<data name="SenderLogin_InvalidCredentials_Title" xml:space="preserve"><value>Ungültige Anmeldedaten.</value></data>
<data name="SenderLogin_Password_Placeholder" xml:space="preserve"><value>Passwort eingeben</value></data>
<data name="SenderLogin_Subtitle" xml:space="preserve"><value>Sicherer Zugang zum Sender-Dashboard</value></data>
<data name="SenderLogin_Title" xml:space="preserve"><value>Sender Anmeldung</value></data>
<data name="SenderLogin_Username_Placeholder" xml:space="preserve"><value>Benutzername eingeben</value></data>
<data name="ServerError" xml:space="preserve"><value>Serverfehler.</value></data>
<data name="Signed" xml:space="preserve"><value>Unterschrieben</value></data>
<data name="SignedDocument" xml:space="preserve"><value>Signiertes Dokument</value></data>
<data name="System" xml:space="preserve"><value>System</value></data>
<data name="UnexpectedErrorTryAgainLater" xml:space="preserve"><value>Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</value></data>
<data name="UploadPdf" xml:space="preserve"><value>PDF hochladen</value></data>
<data name="UserName" xml:space="preserve"><value>Benutzername</value></data>
<data name="VerifyInProgress" xml:space="preserve"><value>Überprüfen ...</value></data>
</root>

View File

@@ -651,4 +651,123 @@
<data name="Status" xml:space="preserve">
<value>Status</value>
</data>
<data name="ChangedAt" xml:space="preserve"><value>Changed at</value></data>
<data name="Close" xml:space="preserve"><value>Close</value></data>
<data name="CloseLanguageMenu" xml:space="preserve"><value>Close language menu</value></data>
<data name="ConfirmSignature" xml:space="preserve"><value>Confirm signature</value></data>
<data name="Created" xml:space="preserve"><value>Created</value></data>
<data name="Delete" xml:space="preserve"><value>Delete</value></data>
<data name="Document" xml:space="preserve"><value>Document</value></data>
<data name="DocumentCouldNotBeLoaded" xml:space="preserve"><value>Document could not be loaded</value></data>
<data name="DocumentLoading" xml:space="preserve"><value>Document is loading...</value></data>
<data name="EmailAddress" xml:space="preserve"><value>Email address</value></data>
<data name="Envelope" xml:space="preserve"><value>Envelope</value></data>
<data name="Error" xml:space="preserve"><value>Error</value></data>
<data name="ErrorLoadingDocument_WithReason" xml:space="preserve"><value>Error loading document: {0}</value></data>
<data name="ErrorPage_DevelopmentMode" xml:space="preserve"><value>Development Mode</value></data>
<data name="ErrorPage_DevelopmentMode_Detail" xml:space="preserve"><value>This can result in sensitive exception information being shown to end users. For local debugging, set ASPNETCORE_ENVIRONMENT to Development and restart the app.</value></data>
<data name="ErrorPage_DevelopmentMode_Info" xml:space="preserve"><value>Switching to Development will display more detailed error information.</value></data>
<data name="ErrorPage_DevelopmentMode_Warning" xml:space="preserve"><value>The Development environment should not be enabled in deployed applications.</value></data>
<data name="ErrorPage_ProcessingRequest" xml:space="preserve"><value>An error occurred while processing your request.</value></data>
<data name="ErrorPage_RequestId" xml:space="preserve"><value>Request ID</value></data>
<data name="ErrorWithReason" xml:space="preserve"><value>Error: {0}</value></data>
<data name="From" xml:space="preserve"><value>From</value></data>
<data name="FullName" xml:space="preserve"><value>First and last name</value></data>
<data name="History" xml:space="preserve"><value>History</value></data>
<data name="Index_DigitalSignature" xml:space="preserve"><value>Digital signature</value></data>
<data name="Index_PdfExport" xml:space="preserve"><value>PDF export</value></data>
<data name="Index_SecureAccess" xml:space="preserve"><value>Secure access</value></data>
<data name="Index_Subtitle" xml:space="preserve"><value>Welcome to the eSign portal</value></data>
<data name="Index_ToEnvelopeOverview" xml:space="preserve"><value>Go to envelope overview</value></data>
<data name="Languages" xml:space="preserve"><value>Languages</value></data>
<data name="Loading" xml:space="preserve"><value>Loading...</value></data>
<data name="Login" xml:space="preserve"><value>Sign in</value></data>
<data name="LoginInProgress" xml:space="preserve"><value>Signing in ...</value></data>
<data name="NotAuthenticatedPleaseLoginAgain" xml:space="preserve"><value>Not authenticated. Please sign in again.</value></data>
<data name="Only" xml:space="preserve"><value>Only</value></data>
<data name="OpenDocument" xml:space="preserve"><value>Open document</value></data>
<data name="Optional" xml:space="preserve"><value>optional</value></data>
<data name="Pending" xml:space="preserve"><value>Pending</value></data>
<data name="Preview" xml:space="preserve"><value>Preview</value></data>
<data name="PrivacyPolicy" xml:space="preserve"><value>Privacy policy</value></data>
<data name="Receiver" xml:space="preserve"><value>Receiver</value></data>
<data name="ReceiverLogin_AccessCode_Placeholder" xml:space="preserve"><value>Enter access code</value></data>
<data name="ReceiverLogin_Description" xml:space="preserve"><value>Please enter the access code you received by email to securely open the document.</value></data>
<data name="ReceiverLogin_FooterHelp" xml:space="preserve"><value>If you have issues, please contact the document sender.</value></data>
<data name="ReceiverLogin_InvalidCode_Body" xml:space="preserve"><value>The entered code is incorrect. Please try again.</value></data>
<data name="ReceiverLogin_InvalidCode_Title" xml:space="preserve"><value>Invalid access code.</value></data>
<data name="ReceiverLogin_NotFound_Body" xml:space="preserve"><value>The provided access code could not be matched to a document. Please verify the link in your email.</value></data>
<data name="ReceiverLogin_NotFound_Title" xml:space="preserve"><value>Document not found.</value></data>
<data name="ReceiverLogin_Subtitle" xml:space="preserve"><value>Secure access with access code</value></data>
<data name="ReceiverSigned_ConfirmBody" xml:space="preserve"><value>This action cannot be undone. By confirming, you declare that you have electronically signed the document shown above. The signed document will then be delivered to all involved parties.</value></data>
<data name="ReceiverSigned_ConfirmQuestion" xml:space="preserve"><value>Do you want to sign this document as binding?</value></data>
<data name="ReceiverSigned_EnvelopeKeyMissing" xml:space="preserve"><value>Envelope key is missing.</value></data>
<data name="Refresh" xml:space="preserve"><value>Refresh</value></data>
<data name="RequiredField" xml:space="preserve"><value>Required field</value></data>
<data name="Selected" xml:space="preserve"><value>selected</value></data>
<data name="Send" xml:space="preserve"><value>Send</value></data>
<data name="Sender_ActiveEnvelopes" xml:space="preserve"><value>Active envelopes</value></data>
<data name="Sender_CompletedEnvelopes" xml:space="preserve"><value>Completed envelopes</value></data>
<data name="Sender_CreateEnvelope" xml:space="preserve"><value>New envelope</value></data>
<data name="Sender_CreateEnvelope_Title" xml:space="preserve"><value>Create new envelope</value></data>
<data name="Sender_DeleteEnvelope_Header" xml:space="preserve"><value>Delete envelope</value></data>
<data name="Sender_DeleteEnvelope_ReasonPlaceholder" xml:space="preserve"><value>Enter reason...</value></data>
<data name="Sender_DeleteEnvelope_ReasonPrompt" xml:space="preserve"><value>Please provide a reason why the envelope should be deleted/withdrawn.</value></data>
<data name="Sender_DeleteEnvelope_ReasonRequired" xml:space="preserve"><value>Please provide a deletion reason.</value></data>
<data name="Sender_DeleteEnvelope_Title" xml:space="preserve"><value>Delete selected envelope</value></data>
<data name="Sender_Edit" xml:space="preserve"><value>Edit</value></data>
<data name="Sender_EditEnvelope_Title" xml:space="preserve"><value>Edit selected envelope</value></data>
<data name="Sender_ExportCurrentView_Title" xml:space="preserve"><value>Export current view</value></data>
<data name="Sender_ExportFormat_Aria" xml:space="preserve"><value>Select export format</value></data>
<data name="Sender_ExportView_Title" xml:space="preserve"><value>Export current table view</value></data>
<data name="Sender_LoadError_Title" xml:space="preserve"><value>Error loading envelopes</value></data>
<data name="Sender_LoadingEnvelopes" xml:space="preserve"><value>Loading envelopes...</value></data>
<data name="Sender_NoHistory" xml:space="preserve"><value>No history available</value></data>
<data name="Sender_NoReceivers" xml:space="preserve"><value>No receivers</value></data>
<data name="Sender_Preview_Title" xml:space="preserve"><value>Show selected envelope as PDF</value></data>
<data name="Sender_PreviewDocumentNotFound" xml:space="preserve"><value>The document for envelope #{0} was not found or you do not have access.</value></data>
<data name="SenderEditor_AddSignature" xml:space="preserve"><value>Add signature</value></data>
<data name="SenderEditor_ClickDocumentFor" xml:space="preserve"><value>Click on the location in the document for</value></data>
<data name="SenderEditor_EmailSuggestionsHint" xml:space="preserve"><value>Previously used email addresses are suggested while typing.</value></data>
<data name="SenderEditor_EmailSuggestionsLoading" xml:space="preserve"><value>Loading email suggestions...</value></data>
<data name="SenderEditor_ErrorCreatingEnvelope" xml:space="preserve"><value>Error creating envelope</value></data>
<data name="SenderEditor_LoadEnvelopeError" xml:space="preserve"><value>Error loading envelope #{0}: {1}</value></data>
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Error loading file: {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Please sign the attached document.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>No document loaded</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>No receivers added yet.</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Place signature field</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Remove all signature fields</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Please select a PDF file.</value></data>
<data name="SenderEditor_Sent_Body" xml:space="preserve"><value>The existing draft has been sent.</value></data>
<data name="SenderEditor_Sent_Header" xml:space="preserve"><value>Envelope sent</value></data>
<data name="SenderEditor_Sent_Title" xml:space="preserve"><value>Envelope was sent successfully.</value></data>
<data name="SenderEditor_SignatureField" xml:space="preserve"><value>Signature field</value></data>
<data name="SenderEditor_Title_Placeholder" xml:space="preserve"><value>e.g. Employment contract 2025</value></data>
<data name="SenderEditor_ToOverview" xml:space="preserve"><value>To overview</value></data>
<data name="SenderEditor_UploadPdfToPlaceFields" xml:space="preserve"><value>Upload a PDF file to place signature fields.</value></data>
<data name="SenderEditor_Validation_EmailDuplicate" xml:space="preserve"><value>This email address has already been added.</value></data>
<data name="SenderEditor_Validation_EmailInvalid" xml:space="preserve"><value>Please enter a valid email address.</value></data>
<data name="SenderEditor_Validation_EmailRequired" xml:space="preserve"><value>Please enter an email address.</value></data>
<data name="SenderEditor_Validation_FullNameRequired" xml:space="preserve"><value>Please enter first and last name.</value></data>
<data name="SenderEditor_Validation_ReceiverRequired" xml:space="preserve"><value>Please add at least one receiver.</value></data>
<data name="SenderEditor_Validation_SignatureFieldRequired" xml:space="preserve"><value>Please place at least one signature field.</value></data>
<data name="SenderEditor_Validation_TitleRequired" xml:space="preserve"><value>Please enter a title.</value></data>
<data name="SenderEditor_Validation_UploadPdf" xml:space="preserve"><value>Please upload a PDF document first.</value></data>
<data name="SenderLogin_Description" xml:space="preserve"><value>Please sign in with your credentials to access the sender dashboard.</value></data>
<data name="SenderLogin_FooterHelp" xml:space="preserve"><value>If you have issues, please contact the administrator.</value></data>
<data name="SenderLogin_InvalidCredentials_Body" xml:space="preserve"><value>Username or password is incorrect. Please try again.</value></data>
<data name="SenderLogin_InvalidCredentials_Title" xml:space="preserve"><value>Invalid credentials.</value></data>
<data name="SenderLogin_Password_Placeholder" xml:space="preserve"><value>Enter password</value></data>
<data name="SenderLogin_Subtitle" xml:space="preserve"><value>Secure access to sender dashboard</value></data>
<data name="SenderLogin_Title" xml:space="preserve"><value>Sender login</value></data>
<data name="SenderLogin_Username_Placeholder" xml:space="preserve"><value>Enter username</value></data>
<data name="ServerError" xml:space="preserve"><value>Server error.</value></data>
<data name="Signed" xml:space="preserve"><value>Signed</value></data>
<data name="SignedDocument" xml:space="preserve"><value>Signed document</value></data>
<data name="System" xml:space="preserve"><value>System</value></data>
<data name="UnexpectedErrorTryAgainLater" xml:space="preserve"><value>An unexpected error occurred. Please try again later.</value></data>
<data name="UploadPdf" xml:space="preserve"><value>Upload PDF</value></data>
<data name="UserName" xml:space="preserve"><value>Username</value></data>
<data name="VerifyInProgress" xml:space="preserve"><value>Verifying ...</value></data>
</root>

View File

@@ -651,4 +651,123 @@
<data name="Status" xml:space="preserve">
<value>Statut</value>
</data>
<data name="ChangedAt" xml:space="preserve"><value>Modifié le</value></data>
<data name="Close" xml:space="preserve"><value>Fermer</value></data>
<data name="CloseLanguageMenu" xml:space="preserve"><value>Fermer le menu des langues</value></data>
<data name="ConfirmSignature" xml:space="preserve"><value>Confirmer la signature</value></data>
<data name="Created" xml:space="preserve"><value>Créé</value></data>
<data name="Delete" xml:space="preserve"><value>Supprimer</value></data>
<data name="Document" xml:space="preserve"><value>Document</value></data>
<data name="DocumentCouldNotBeLoaded" xml:space="preserve"><value>Le document n'a pas pu être chargé</value></data>
<data name="DocumentLoading" xml:space="preserve"><value>Chargement du document...</value></data>
<data name="EmailAddress" xml:space="preserve"><value>Adresse e-mail</value></data>
<data name="Envelope" xml:space="preserve"><value>Enveloppe</value></data>
<data name="Error" xml:space="preserve"><value>Erreur</value></data>
<data name="ErrorLoadingDocument_WithReason" xml:space="preserve"><value>Erreur lors du chargement du document : {0}</value></data>
<data name="ErrorPage_DevelopmentMode" xml:space="preserve"><value>Mode développement</value></data>
<data name="ErrorPage_DevelopmentMode_Detail" xml:space="preserve"><value>Cela peut afficher des informations sensibles d'exception aux utilisateurs finaux. Pour le débogage local, définissez ASPNETCORE_ENVIRONMENT sur Development et redémarrez l'application.</value></data>
<data name="ErrorPage_DevelopmentMode_Info" xml:space="preserve"><value>Le mode développement affiche des informations d'erreur plus détaillées.</value></data>
<data name="ErrorPage_DevelopmentMode_Warning" xml:space="preserve"><value>Le mode développement ne doit pas être activé dans les applications déployées.</value></data>
<data name="ErrorPage_ProcessingRequest" xml:space="preserve"><value>Une erreur s'est produite lors du traitement de votre demande.</value></data>
<data name="ErrorPage_RequestId" xml:space="preserve"><value>ID de requête</value></data>
<data name="ErrorWithReason" xml:space="preserve"><value>Erreur : {0}</value></data>
<data name="From" xml:space="preserve"><value>De</value></data>
<data name="FullName" xml:space="preserve"><value>Nom et prénom</value></data>
<data name="History" xml:space="preserve"><value>Historique</value></data>
<data name="Index_DigitalSignature" xml:space="preserve"><value>Signature numérique</value></data>
<data name="Index_PdfExport" xml:space="preserve"><value>Export PDF</value></data>
<data name="Index_SecureAccess" xml:space="preserve"><value>Accès sécurisé</value></data>
<data name="Index_Subtitle" xml:space="preserve"><value>Bienvenue sur le portail eSign</value></data>
<data name="Index_ToEnvelopeOverview" xml:space="preserve"><value>Aller à l'aperçu des enveloppes</value></data>
<data name="Languages" xml:space="preserve"><value>Langues</value></data>
<data name="Loading" xml:space="preserve"><value>Chargement...</value></data>
<data name="Login" xml:space="preserve"><value>Connexion</value></data>
<data name="LoginInProgress" xml:space="preserve"><value>Connexion ...</value></data>
<data name="NotAuthenticatedPleaseLoginAgain" xml:space="preserve"><value>Non authentifié. Veuillez vous reconnecter.</value></data>
<data name="Only" xml:space="preserve"><value>Uniquement</value></data>
<data name="OpenDocument" xml:space="preserve"><value>Ouvrir le document</value></data>
<data name="Optional" xml:space="preserve"><value>optionnel</value></data>
<data name="Pending" xml:space="preserve"><value>En attente</value></data>
<data name="Preview" xml:space="preserve"><value>Aperçu</value></data>
<data name="PrivacyPolicy" xml:space="preserve"><value>Politique de confidentialité</value></data>
<data name="Receiver" xml:space="preserve"><value>Destinataire</value></data>
<data name="ReceiverLogin_AccessCode_Placeholder" xml:space="preserve"><value>Saisir le code d'accès</value></data>
<data name="ReceiverLogin_Description" xml:space="preserve"><value>Veuillez saisir le code d'accès reçu par e-mail pour ouvrir le document en toute sécurité.</value></data>
<data name="ReceiverLogin_FooterHelp" xml:space="preserve"><value>En cas de problème, veuillez contacter l'expéditeur du document.</value></data>
<data name="ReceiverLogin_InvalidCode_Body" xml:space="preserve"><value>Le code saisi est incorrect. Veuillez réessayer.</value></data>
<data name="ReceiverLogin_InvalidCode_Title" xml:space="preserve"><value>Code d'accès invalide.</value></data>
<data name="ReceiverLogin_NotFound_Body" xml:space="preserve"><value>Le code d'accès indiqué ne correspond à aucun document. Veuillez vérifier le lien dans votre e-mail.</value></data>
<data name="ReceiverLogin_NotFound_Title" xml:space="preserve"><value>Document introuvable.</value></data>
<data name="ReceiverLogin_Subtitle" xml:space="preserve"><value>Accès sécurisé avec code d'accès</value></data>
<data name="ReceiverSigned_ConfirmBody" xml:space="preserve"><value>Cette action est irréversible. En confirmant, vous déclarez avoir signé électroniquement le document affiché ci-dessus. Le document signé sera ensuite transmis à toutes les parties concernées.</value></data>
<data name="ReceiverSigned_ConfirmQuestion" xml:space="preserve"><value>Voulez-vous signer ce document de manière contraignante ?</value></data>
<data name="ReceiverSigned_EnvelopeKeyMissing" xml:space="preserve"><value>La clé d'enveloppe est manquante.</value></data>
<data name="Refresh" xml:space="preserve"><value>Actualiser</value></data>
<data name="RequiredField" xml:space="preserve"><value>Champ obligatoire</value></data>
<data name="Selected" xml:space="preserve"><value>sélectionnées</value></data>
<data name="Send" xml:space="preserve"><value>Envoyer</value></data>
<data name="Sender_ActiveEnvelopes" xml:space="preserve"><value>Enveloppes actives</value></data>
<data name="Sender_CompletedEnvelopes" xml:space="preserve"><value>Enveloppes terminées</value></data>
<data name="Sender_CreateEnvelope" xml:space="preserve"><value>Nouvelle enveloppe</value></data>
<data name="Sender_CreateEnvelope_Title" xml:space="preserve"><value>Créer une nouvelle enveloppe</value></data>
<data name="Sender_DeleteEnvelope_Header" xml:space="preserve"><value>Supprimer l'enveloppe</value></data>
<data name="Sender_DeleteEnvelope_ReasonPlaceholder" xml:space="preserve"><value>Saisir la raison...</value></data>
<data name="Sender_DeleteEnvelope_ReasonPrompt" xml:space="preserve"><value>Veuillez indiquer la raison pour laquelle l'enveloppe doit être supprimée/retirée.</value></data>
<data name="Sender_DeleteEnvelope_ReasonRequired" xml:space="preserve"><value>Veuillez indiquer un motif de suppression.</value></data>
<data name="Sender_DeleteEnvelope_Title" xml:space="preserve"><value>Supprimer l'enveloppe sélectionnée</value></data>
<data name="Sender_Edit" xml:space="preserve"><value>Modifier</value></data>
<data name="Sender_EditEnvelope_Title" xml:space="preserve"><value>Modifier l'enveloppe sélectionnée</value></data>
<data name="Sender_ExportCurrentView_Title" xml:space="preserve"><value>Exporter la vue actuelle</value></data>
<data name="Sender_ExportFormat_Aria" xml:space="preserve"><value>Sélectionner le format d'export</value></data>
<data name="Sender_ExportView_Title" xml:space="preserve"><value>Exporter la vue actuelle du tableau</value></data>
<data name="Sender_LoadError_Title" xml:space="preserve"><value>Erreur lors du chargement des enveloppes</value></data>
<data name="Sender_LoadingEnvelopes" xml:space="preserve"><value>Chargement des enveloppes...</value></data>
<data name="Sender_NoHistory" xml:space="preserve"><value>Aucun historique disponible</value></data>
<data name="Sender_NoReceivers" xml:space="preserve"><value>Aucun destinataire</value></data>
<data name="Sender_Preview_Title" xml:space="preserve"><value>Afficher l'enveloppe sélectionnée en PDF</value></data>
<data name="Sender_PreviewDocumentNotFound" xml:space="preserve"><value>Le document de l'enveloppe #{0} est introuvable ou vous n'avez pas l'autorisation d'y accéder.</value></data>
<data name="SenderEditor_AddSignature" xml:space="preserve"><value>Ajouter une signature</value></data>
<data name="SenderEditor_ClickDocumentFor" xml:space="preserve"><value>Cliquez sur l'emplacement dans le document pour</value></data>
<data name="SenderEditor_EmailSuggestionsHint" xml:space="preserve"><value>Les adresses e-mail déjà utilisées sont proposées pendant la saisie.</value></data>
<data name="SenderEditor_EmailSuggestionsLoading" xml:space="preserve"><value>Chargement des suggestions d'e-mail...</value></data>
<data name="SenderEditor_ErrorCreatingEnvelope" xml:space="preserve"><value>Erreur lors de la création de l'enveloppe</value></data>
<data name="SenderEditor_LoadEnvelopeError" xml:space="preserve"><value>Erreur lors du chargement de l'enveloppe #{0} : {1}</value></data>
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Erreur lors du chargement du fichier : {0}</value></data>
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Veuillez signer le document ci-joint.</value></data>
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Aucun document chargé</value></data>
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Aucun destinataire ajouté pour le moment.</value></data>
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Placer un champ de signature</value></data>
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Supprimer tous les champs de signature</value></data>
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Veuillez sélectionner un fichier PDF.</value></data>
<data name="SenderEditor_Sent_Body" xml:space="preserve"><value>Le brouillon existant a été envoyé.</value></data>
<data name="SenderEditor_Sent_Header" xml:space="preserve"><value>Enveloppe envoyée</value></data>
<data name="SenderEditor_Sent_Title" xml:space="preserve"><value>L'enveloppe a été envoyée avec succès.</value></data>
<data name="SenderEditor_SignatureField" xml:space="preserve"><value>Champ de signature</value></data>
<data name="SenderEditor_Title_Placeholder" xml:space="preserve"><value>p. ex. Contrat de travail 2025</value></data>
<data name="SenderEditor_ToOverview" xml:space="preserve"><value>Vers l'aperçu</value></data>
<data name="SenderEditor_UploadPdfToPlaceFields" xml:space="preserve"><value>Téléversez un fichier PDF pour placer des champs de signature.</value></data>
<data name="SenderEditor_Validation_EmailDuplicate" xml:space="preserve"><value>Cette adresse e-mail a déjà été ajoutée.</value></data>
<data name="SenderEditor_Validation_EmailInvalid" xml:space="preserve"><value>Veuillez saisir une adresse e-mail valide.</value></data>
<data name="SenderEditor_Validation_EmailRequired" xml:space="preserve"><value>Veuillez saisir une adresse e-mail.</value></data>
<data name="SenderEditor_Validation_FullNameRequired" xml:space="preserve"><value>Veuillez saisir le nom et le prénom.</value></data>
<data name="SenderEditor_Validation_ReceiverRequired" xml:space="preserve"><value>Veuillez ajouter au moins un destinataire.</value></data>
<data name="SenderEditor_Validation_SignatureFieldRequired" xml:space="preserve"><value>Veuillez placer au moins un champ de signature.</value></data>
<data name="SenderEditor_Validation_TitleRequired" xml:space="preserve"><value>Veuillez saisir un titre.</value></data>
<data name="SenderEditor_Validation_UploadPdf" xml:space="preserve"><value>Veuillez d'abord téléverser un document PDF.</value></data>
<data name="SenderLogin_Description" xml:space="preserve"><value>Veuillez vous connecter avec vos identifiants pour accéder au tableau de bord expéditeur.</value></data>
<data name="SenderLogin_FooterHelp" xml:space="preserve"><value>En cas de problème, veuillez contacter l'administrateur.</value></data>
<data name="SenderLogin_InvalidCredentials_Body" xml:space="preserve"><value>Le nom d'utilisateur ou le mot de passe est incorrect. Veuillez réessayer.</value></data>
<data name="SenderLogin_InvalidCredentials_Title" xml:space="preserve"><value>Identifiants invalides.</value></data>
<data name="SenderLogin_Password_Placeholder" xml:space="preserve"><value>Saisir le mot de passe</value></data>
<data name="SenderLogin_Subtitle" xml:space="preserve"><value>Accès sécurisé au tableau de bord expéditeur</value></data>
<data name="SenderLogin_Title" xml:space="preserve"><value>Connexion expéditeur</value></data>
<data name="SenderLogin_Username_Placeholder" xml:space="preserve"><value>Saisir le nom d'utilisateur</value></data>
<data name="ServerError" xml:space="preserve"><value>Erreur serveur.</value></data>
<data name="Signed" xml:space="preserve"><value>Signé</value></data>
<data name="SignedDocument" xml:space="preserve"><value>Document signé</value></data>
<data name="System" xml:space="preserve"><value>Système</value></data>
<data name="UnexpectedErrorTryAgainLater" xml:space="preserve"><value>Une erreur inattendue s'est produite. Veuillez réessayer plus tard.</value></data>
<data name="UploadPdf" xml:space="preserve"><value>Téléverser un PDF</value></data>
<data name="UserName" xml:space="preserve"><value>Nom d'utilisateur</value></data>
<data name="VerifyInProgress" xml:space="preserve"><value>Vérification ...</value></data>
</root>

View File

@@ -0,0 +1,7 @@
namespace EnvelopeGenerator.Domain.Resources
{
// Marker type for IStringLocalizer lookup of Resources/Model.resx.
public class Model
{
}
}

View File

@@ -0,0 +1,116 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="AccessCodeIncorrect" xml:space="preserve">
<value>Code d'acces incorrect</value>
</data>
<data name="DocumentRejected" xml:space="preserve">
<value>Document rejete</value>
</data>
<data name="DocumentSigned" xml:space="preserve">
<value>Document signe</value>
</data>
<data name="Draft" xml:space="preserve">
<value>Brouillon</value>
</data>
<data name="EnvelopeCompletelySigned" xml:space="preserve">
<value>Entierement signe</value>
</data>
<data name="EnvelopeCreated" xml:space="preserve">
<value>Enveloppe creee</value>
</data>
<data name="EnvelopeDeleted" xml:space="preserve">
<value>Enveloppe supprimee</value>
</data>
<data name="EnvelopePartlySigned" xml:space="preserve">
<value>Partiellement signee</value>
</data>
<data name="EnvelopeQueued" xml:space="preserve">
<value>Enveloppe en file d'attente</value>
</data>
<data name="EnvelopeRejected" xml:space="preserve">
<value>Enveloppe rejetee</value>
</data>
<data name="EnvelopeSaved" xml:space="preserve">
<value>Enregistree</value>
</data>
<data name="EnvelopeSent" xml:space="preserve">
<value>Envoyee</value>
</data>
<data name="EnvelopeWithdrawn" xml:space="preserve">
<value>Enveloppe retiree</value>
</data>
<data name="MessageAccessCodeSent" xml:space="preserve">
<value>Code d'acces envoye</value>
</data>
<data name="MessageCompletionSent" xml:space="preserve">
<value>E-mail de cloture envoye</value>
</data>
<data name="MessageConfirmationSent" xml:space="preserve">
<value>Confirmation finale envoyee</value>
</data>
<data name="MessageDeletionSent" xml:space="preserve">
<value>Notification de suppression envoyee</value>
</data>
<data name="MessageInvitationSent" xml:space="preserve">
<value>Invitation envoyee</value>
</data>
</root>

View File

@@ -0,0 +1,93 @@
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Resources
@using Microsoft.Extensions.Localization
@rendermode InteractiveWebAssembly
@inject LocalizationService LocalizationService
@inject NavigationManager Navigation
@inject IStringLocalizer<Resource> UiLocalizer
<div class="sf-lang-combo">
<button type="button"
class="sf-lang-trigger"
@onclick="ToggleMenu"
aria-label="@L("Language", "Language")"
aria-expanded="@_isOpen">
<span class="fi @_selected.FlagClass"></span>
</button>
@if (_isOpen)
{
<button type="button"
class="sf-lang-backdrop"
aria-label="@L("CloseLanguageMenu", "Close language menu")"
@onclick="CloseMenu">
</button>
<div class="sf-lang-menu" role="listbox" aria-label="@L("Languages", "Languages")">
@foreach (var option in _options)
{
<button type="button"
class="sf-lang-option @(_selected.Code == option.Code ? "sf-lang-option--active" : string.Empty)"
role="option"
aria-selected="@(_selected.Code == option.Code)"
@onmousedown:preventDefault="true"
@onclick="() => SelectLanguageAsync(option)">
<span class="fi @option.FlagClass"></span>
<span>@option.ShortName</span>
</button>
}
</div>
}
</div>
@code {
private readonly IReadOnlyList<LanguageOption> _options =
[
new("de-DE", "fi-de", "DE"),
new("en-US", "fi-us", "EN"),
new("fr-FR", "fi-fr", "FR")
];
private LanguageOption _selected = new("de-DE", "fi-de", "DE");
private bool _isOpen;
protected override async Task OnInitializedAsync()
{
var currentLanguage = await LocalizationService.GetLanguageAsync();
_selected = _options.FirstOrDefault(x => x.Code.Equals(currentLanguage, StringComparison.OrdinalIgnoreCase))
?? _options[0];
}
private void ToggleMenu()
{
_isOpen = !_isOpen;
}
private void CloseMenu()
{
_isOpen = false;
}
private async Task SelectLanguageAsync(LanguageOption option)
{
if (_selected.Code.Equals(option.Code, StringComparison.OrdinalIgnoreCase))
{
_isOpen = false;
return;
}
_selected = option;
_isOpen = false;
await LocalizationService.SetLanguageAsync(option.Code);
Navigation.NavigateTo(Navigation.Uri, forceLoad: true);
}
private sealed record LanguageOption(string Code, string FlagClass, string ShortName);
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -1,8 +1,14 @@
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Resources
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.Extensions.Localization
@using System.Globalization
@inherits LayoutComponentBase
@implements IDisposable
@inject ThemeService ThemeService
@inject IStringLocalizer<Resource> UiLocalizer
@inject NavigationManager Navigation
<HeadContent>
<link href="@ThemeService.ThemeCssHref" rel="stylesheet" />
@@ -18,10 +24,13 @@
<div class="receiver-footer__left">
<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>
<a href="@GetPrivacyPolicyPath()" target="_blank" rel="noopener">@L("PrivacyPolicy", "Datenschutz")</a>
</div>
<div class="receiver-footer__right">
<LanguageControls />
<ThemeControls />
</div>
</footer>
</div>
@@ -32,6 +41,7 @@
protected override async Task OnInitializedAsync()
{
Navigation.LocationChanged += OnLocationChanged;
await FontLoader.LoadFonts(HttpClientFactory, ["opensans.ttf"]);
_themeChangeHandler = () => InvokeAsync(StateHasChanged);
ThemeService.OnChange += _themeChangeHandler;
@@ -39,5 +49,31 @@
}
public void Dispose()
=> ThemeService.OnChange -= _themeChangeHandler;
{
ThemeService.OnChange -= _themeChangeHandler;
Navigation.LocationChanged -= OnLocationChanged;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
=> _ = InvokeAsync(StateHasChanged);
private string GetPrivacyPolicyPath()
{
var current = CultureInfo.CurrentUICulture.Name;
var code = current switch
{
"de-DE" => "de-DE",
"en-US" => "en-US",
"fr-FR" => "fr-FR",
_ => "de-DE"
};
return $"docs/privacy-policy.{code}.html";
}
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -1,7 +1,10 @@
@page "/"
@using EnvelopeGenerator.Application.Resources
@using Microsoft.Extensions.Localization
@inject IJSRuntime JS
@inject NavigationManager Navigation
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
@inject IStringLocalizer<Resource> UiLocalizer
@rendermode InteractiveWebAssembly
<div class="home-page-wrapper">
@@ -24,7 +27,7 @@
}
<div>
<h1 class="home-hero-header__title">SignFlow</h1>
<p class="home-hero-header__subtitle">Willkommen im eSign-Portal</p>
<p class="home-hero-header__subtitle">@L("Index_Subtitle", "Willkommen im eSign-Portal")</p>
</div>
</div>
</div>
@@ -43,7 +46,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
</svg>
Zur Umschlag-Übersicht
@L("Index_ToEnvelopeOverview", "Zur Umschlag-Übersicht")
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"/>
</svg>
@@ -54,20 +57,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/>
</svg>
Sicherer Zugang
@L("Index_SecureAccess", "Sicherer Zugang")
</div>
<div class="home-feature-badge">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<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>
Digitale Unterschrift
@L("Index_DigitalSignature", "Digitale Unterschrift")
</div>
<div class="home-feature-badge">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
</svg>
PDF-Export
@L("Index_PdfExport", "PDF-Export")
</div>
</div>
@@ -80,10 +83,9 @@
</div>
@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.";
private string HomePageDescription => L(
"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.");
private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _appLogo;
@@ -99,4 +101,10 @@
if (!firstRender) return;
await JS.InvokeVoidAsync("receiverSignature.startTyped", "home-description", HomePageDescription, 15);
}
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -1,8 +1,11 @@
@page "/envelope/login/{EnvelopeKey}"
@rendermode InteractiveWebAssembly
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Resources
@using Microsoft.Extensions.Localization
@inject AuthService AuthService
@inject NavigationManager Navigation
@inject IStringLocalizer<Resource> UiLocalizer
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
@@ -13,14 +16,14 @@
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
</svg>
</div>
<h5 class="mb-0 fw-semibold">Dokument öffnen</h5>
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">Sicherer Zugang mit Zugangscode</p>
<h5 class="mb-0 fw-semibold">@L("OpenDocument", "Dokument öffnen")</h5>
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">@L("ReceiverLogin_Subtitle", "Sicherer Zugang mit Zugangscode")</p>
</div>
<div class="card-body p-4">
<p class="text-muted mb-4" style="font-size: 0.875rem; line-height: 1.5;">
Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.
@L("ReceiverLogin_Description", "Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.")
</p>
@if (LoginResult == EnvelopeLoginResult.NotFound)
@@ -30,8 +33,8 @@
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
</svg>
<div>
<strong>Dokument nicht gefunden.</strong><br />
<span style="font-size:0.85rem;">Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</span>
<strong>@L("ReceiverLogin_NotFound_Title", "Dokument nicht gefunden.")</strong><br />
<span style="font-size:0.85rem;">@L("ReceiverLogin_NotFound_Body", "Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.")</span>
</div>
</div>
}
@@ -42,8 +45,8 @@
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
<div>
<strong>Ungültiger Zugangscode.</strong><br />
<span style="font-size:0.85rem;">Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.</span>
<strong>@L("ReceiverLogin_InvalidCode_Title", "Ungültiger Zugangscode.")</strong><br />
<span style="font-size:0.85rem;">@L("ReceiverLogin_InvalidCode_Body", "Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.")</span>
</div>
</div>
}
@@ -55,15 +58,15 @@
<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>
<strong>Serverfehler.</strong><br />
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
<strong>@L("ServerError", "Serverfehler.")</strong><br />
<span style="font-size:0.85rem;">@L("UnexpectedErrorTryAgainLater", "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.")</span>
</div>
</div>
}
<div class="mb-4">
<label class="form-label fw-medium" for="login-access-code">
Zugangscode
@L("AccessCode", "Zugangscode")
<span class="text-danger ms-1">*</span>
</label>
<div class="input-group">
@@ -75,7 +78,7 @@
<input id="login-access-code"
type="@(ShowCode ? "text" : "password")"
class="form-control border-start-0 border-end-0 @(LoginResult == EnvelopeLoginResult.InvalidCode ? "is-invalid" : null)"
placeholder="Zugangscode eingeben"
placeholder="@L("ReceiverLogin_AccessCode_Placeholder", "Zugangscode eingeben")"
@bind="AccessCode"
@bind:event="oninput"
@onkeydown="OnKeyDownAsync"
@@ -113,21 +116,21 @@
@if (IsLoading)
{
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span>Überprüfen …</span>
<span>@L("VerifyInProgress", "Überprüfen …")</span>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
</svg>
<span>Dokument öffnen</span>
<span>@L("OpenDocument", "Dokument öffnen")</span>
}
</button>
</div>
<div class="card-footer text-center text-muted py-3 border-0 bg-transparent" style="font-size: 0.78rem;">
Bei Problemen wenden Sie sich bitte an den Absender des Dokuments.
@L("ReceiverLogin_FooterHelp", "Bei Problemen wenden Sie sich bitte an den Absender des Dokuments.")
</div>
</div>
@@ -167,5 +170,11 @@
IsLoading = false;
await InvokeAsync(StateHasChanged);
}
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -1,8 +1,11 @@
@page "/sender/login"
@rendermode InteractiveWebAssembly
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Resources
@using Microsoft.Extensions.Localization
@inject AuthService AuthService
@inject NavigationManager Navigation
@inject IStringLocalizer<Resource> UiLocalizer
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
@@ -14,14 +17,14 @@
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
</svg>
</div>
<h5 class="mb-0 fw-semibold">Sender Anmeldung</h5>
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">Sicherer Zugang zum Sender-Dashboard</p>
<h5 class="mb-0 fw-semibold">@L("SenderLogin_Title", "Sender Anmeldung")</h5>
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">@L("SenderLogin_Subtitle", "Sicherer Zugang zum Sender-Dashboard")</p>
</div>
<div class="card-body p-4">
<p class="text-muted mb-4" style="font-size: 0.875rem; line-height: 1.5;">
Bitte melden Sie sich mit Ihren Zugangsdaten an, um auf das Sender-Dashboard zuzugreifen.
@L("SenderLogin_Description", "Bitte melden Sie sich mit Ihren Zugangsdaten an, um auf das Sender-Dashboard zuzugreifen.")
</p>
@if (LoginResult == SenderLoginResult.InvalidCredentials) {
@@ -30,8 +33,8 @@
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>
<div>
<strong>Ungültige Anmeldedaten.</strong><br />
<span style="font-size:0.85rem;">Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.</span>
<strong>@L("SenderLogin_InvalidCredentials_Title", "Ungültige Anmeldedaten.")</strong><br />
<span style="font-size:0.85rem;">@L("SenderLogin_InvalidCredentials_Body", "Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.")</span>
</div>
</div>
} else if (LoginResult == SenderLoginResult.Error) {
@@ -41,15 +44,15 @@
<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>
<strong>Serverfehler.</strong><br />
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
<strong>@L("ServerError", "Serverfehler.")</strong><br />
<span style="font-size:0.85rem;">@L("UnexpectedErrorTryAgainLater", "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.")</span>
</div>
</div>
}
<div class="mb-3">
<label class="form-label fw-medium" for="login-username">
Benutzername
@L("UserName", "Benutzername")
<span class="text-danger ms-1">*</span>
</label>
<div class="input-group">
@@ -61,7 +64,7 @@
<input id="login-username"
type="text"
class="form-control @(LoginResult == SenderLoginResult.InvalidCredentials ? "is-invalid" : null)"
placeholder="Benutzername eingeben"
placeholder="@L("SenderLogin_Username_Placeholder", "Benutzername eingeben")"
@bind="Username"
@bind:event="oninput"
@onkeydown="OnKeyDownAsync"
@@ -72,7 +75,7 @@
<div class="mb-4">
<label class="form-label fw-medium" for="login-password">
Passwort
@L("Password", "Passwort")
<span class="text-danger ms-1">*</span>
</label>
<div class="input-group">
@@ -84,7 +87,7 @@
<input id="login-password"
type="@(ShowPassword ? "text" : "password")"
class="form-control border-start-0 border-end-0 @(LoginResult == SenderLoginResult.InvalidCredentials ? "is-invalid" : null)"
placeholder="Passwort eingeben"
placeholder="@L("SenderLogin_Password_Placeholder", "Passwort eingeben")"
@bind="Password"
@bind:event="oninput"
@onkeydown="OnKeyDownAsync"
@@ -118,20 +121,20 @@
disabled="@(IsLoading || string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))">
@if (IsLoading) {
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span>Anmelden …</span>
<span>@L("LoginInProgress", "Anmelden …")</span>
} else {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 3.5a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 1 1 0v2A1.5 1.5 0 0 1 9.5 14h-8A1.5 1.5 0 0 1 0 12.5v-9A1.5 1.5 0 0 1 1.5 2h8A1.5 1.5 0 0 1 11 3.5v2a.5.5 0 0 1-1 0v-2z"/>
<path fill-rule="evenodd" d="M4.146 8.354a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L5.707 7.5H14.5a.5.5 0 0 1 0 1H5.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3z"/>
</svg>
<span>Anmelden</span>
<span>@L("Login", "Anmelden")</span>
}
</button>
</div>
<div class="card-footer text-center text-muted py-3 border-0 bg-transparent" style="font-size: 0.78rem;">
Bei Problemen wenden Sie sich bitte an den Administrator.
@L("SenderLogin_FooterHelp", "Bei Problemen wenden Sie sich bitte an den Administrator.")
</div>
</div>
@@ -167,4 +170,10 @@
IsLoading = false;
await InvokeAsync(StateHasChanged);
}
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -5,9 +5,17 @@ using DevExpress.Blazor.Reporting;
using DevExpress.XtraReports.Web.Extensions;
using DevExpress.DataAccess.Web;
using DevExpress.XtraReports.Services;
using System.Globalization;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
var initialCulture = await ResolveInitialCultureAsync(builder.HostEnvironment.BaseAddress);
var cultureInfo = new CultureInfo(initialCulture);
CultureInfo.DefaultThreadCurrentCulture = cultureInfo;
CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
builder.Services.AddLocalization();
// Named HttpClient for API calls (both for services and DevExpress components)
builder.Services.AddHttpClient("EnvelopeGenerator.Server", client =>
{
@@ -33,6 +41,7 @@ builder.Services.AddScoped<SignatureService>();
builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddSingleton<AppVersionService>();
builder.Services.AddScoped<EnvelopeService>();
builder.Services.AddScoped<LocalizationService>();
// UI Preferences & Theme
builder.Services.AddScoped<UserPreferencesService>();
@@ -66,3 +75,23 @@ ReportStorageWebExtension.RegisterExtensionGlobal(new InMemoryReportStorageWebEx
var host = builder.Build();
await FontLoader.LoadFonts(host.Services.GetRequiredService<IHttpClientFactory>(), new List<string> { "opensans.ttf" });
await host.RunAsync();
static async Task<string> ResolveInitialCultureAsync(string baseAddress)
{
try
{
using var http = new HttpClient { BaseAddress = new Uri(baseAddress) };
var response = await http.GetAsync("/api/Localization/lang");
if (!response.IsSuccessStatusCode)
return "de-DE";
var value = await response.Content.ReadAsStringAsync();
var culture = value.Trim().Trim('"');
return string.IsNullOrWhiteSpace(culture) ? "de-DE" : culture;
}
catch
{
return "de-DE";
}
}

View File

@@ -1,4 +1,5 @@
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Envelopes.Commands;
using Microsoft.AspNetCore.WebUtilities;
using System.Net.Http;
using System.Net.Http.Json;
@@ -12,6 +13,8 @@ namespace EnvelopeGenerator.Server.Client.Services;
public class EnvelopeService(IHttpClientFactory clientFactory)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private sealed record DeleteEnvelopeRequest(string Reason);
private sealed record ErrorResponse(string? Message);
/// <summary>
/// Fetches envelopes from the API with optional filters.
@@ -61,4 +64,83 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
return await response.Content.ReadFromJsonAsync<IEnumerable<EnvelopeDto>>(_jsonOptions, cancel);
}
public async Task DeleteAsync(int envelopeId, string reason, CancellationToken cancel = default)
{
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/Envelope/{envelopeId}")
{
Content = JsonContent.Create(new DeleteEnvelopeRequest(reason), options: _jsonOptions)
};
var response = await httpClient.SendAsync(request, cancel);
if (response.IsSuccessStatusCode)
return;
string? message = null;
try
{
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>(_jsonOptions, cancel);
message = error?.Message;
}
catch
{
message = null;
}
var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
throw new HttpRequestException(
$"Failed to delete envelope. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(message) ? string.Empty : $" - {message}")}",
null,
response.StatusCode);
}
public async Task<EnvelopeDto?> UpsertAsync(
CreateEnvelopeCommand request,
CancellationToken cancel = default)
{
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
var payload = new
{
EnvelopeId = request.EnvelopeId,
Title = request.Title,
Message = request.Message,
TFAEnabled = request.TFAEnabled,
Send = request.Send,
Document = request.Document is null
? null
: new { DataAsBase64 = request.Document.DataAsBase64 },
Receivers = request.Receivers.Select(r => new
{
EmailAddress = r.EmailAddress,
Name = r.Name,
PhoneNumber = r.PhoneNumber,
Fields = r.Fields.Select(f => new { X = f.X, Y = f.Y, Page = f.Page }).ToList()
}).ToList()
};
var response = await httpClient.PostAsJsonAsync("/api/Envelope", payload, _jsonOptions, cancel);
if (response.IsSuccessStatusCode)
return await response.Content.ReadFromJsonAsync<EnvelopeDto>(_jsonOptions, cancel);
string? message = null;
try
{
var error = await response.Content.ReadFromJsonAsync<ErrorResponse>(_jsonOptions, cancel);
message = error?.Message;
}
catch
{
message = null;
}
var statusCode = (int)response.StatusCode;
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
throw new HttpRequestException(
$"Failed to save envelope. Status: {statusCode} ({reasonPhrase}){(string.IsNullOrWhiteSpace(message) ? string.Empty : $" - {message}")}",
null,
response.StatusCode);
}
}

View File

@@ -0,0 +1,30 @@
using System.Net;
namespace EnvelopeGenerator.Server.Client.Services;
public class LocalizationService(IHttpClientFactory httpClientFactory)
{
private HttpClient CreateDefaultClient() => httpClientFactory.CreateClient("EnvelopeGenerator.Server");
public async Task<string> GetLanguageAsync(CancellationToken cancel = default)
{
using var http = CreateDefaultClient();
var response = await http.GetAsync("/api/Localization/lang", cancel);
if (response.StatusCode == HttpStatusCode.NotFound)
return "de-DE";
if (!response.IsSuccessStatusCode)
return "de-DE";
var language = await response.Content.ReadAsStringAsync(cancel);
return string.IsNullOrWhiteSpace(language) ? "de-DE" : language.Trim('"');
}
public async Task SetLanguageAsync(string language, CancellationToken cancel = default)
{
using var http = CreateDefaultClient();
var response = await http.PostAsync($"/api/Localization/lang?language={Uri.EscapeDataString(language)}", null, cancel);
response.EnsureSuccessStatusCode();
}
}

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.2.3/css/flag-icons.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="stylesheet" href="css/envelope-viewer.css" />
<link rel="stylesheet" href="EnvelopeGenerator.Server.styles.css" />

View File

@@ -3,18 +3,24 @@
@using DevExpress.Blazor.PdfViewer
@using DevExpress.Blazor.Reporting.Models
@using DevExpress.Blazor
@using EnvelopeGenerator.Application.EnvelopeReceivers.Commands
@using EnvelopeGenerator.Application.Common.Dto
@using EnvelopeGenerator.Application.Envelopes.Commands
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Server.Services
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.Extensions.Caching.Memory
@using Microsoft.Extensions.Localization
@inject IJSRuntime JSRuntime
@inject NavigationManager NavigationManager
@inject AppVersionService AppVersion
@inject ILogger<EnvelopeSenderEditorPage> Logger
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
@inject EnvelopeReceiverService EnvelopeReceiverService
@inject EnvelopeService EnvelopeService
@inject IEnvelopeAuthService EnvelopeAuthService
@inject IMemoryCache MemoryCache
@inject IStringLocalizer<Resource> UiLocalizer
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="_content/DevExpress.Blazor.Viewer/css/dx-blazor-viewer-components.bs5.css" rel="stylesheet" />
@@ -38,12 +44,12 @@
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 1 1 180px; min-width: 140px;">
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.05em;">
Titel <span style="color:#dc2626;">*</span>
@L("Title", "Titel") <span style="color:#dc2626;">*</span>
</label>
<input type="text"
value="@_envelopeTitle"
@oninput="e => _envelopeTitle = e.Value?.ToString() ?? string.Empty"
placeholder="z.B. Arbeitsvertrag 2025"
placeholder='@L("SenderEditor_Title_Placeholder", "z.B. Arbeitsvertrag 2025")'
maxlength="120"
style="font-size: 0.78rem; font-weight: 500; color: #1f2937;
border: 1px solid #d1d5db; border-radius: 5px;
@@ -52,7 +58,7 @@
@(_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle) ? "border-color:#dc2626;" : "")" />
@if (_titleTouched && string.IsNullOrWhiteSpace(_envelopeTitle))
{
<span style="font-size:0.6rem;color:#dc2626;">Pflichtfeld</span>
<span style="font-size:0.6rem;color:#dc2626;">@L("RequiredField", "Pflichtfeld")</span>
}
</div>
@@ -60,12 +66,12 @@
<div style="display: flex; flex-direction: column; gap: 0.2rem; flex: 2 1 260px; min-width: 180px;">
<label style="font-size: 0.65rem; font-weight: 600; color: #6b7280;
text-transform: uppercase; letter-spacing: 0.05em;">
Nachricht <span style="color:#6b7280;font-weight:400;">(optional)</span>
@L("Message", "Nachricht") <span style="color:#6b7280;font-weight:400;">(@L("Optional", "optional"))</span>
</label>
<input type="text"
value="@_envelopeMessage"
@oninput="e => _envelopeMessage = e.Value?.ToString() ?? string.Empty"
placeholder="Bitte unterzeichnen Sie das beigefügte Dokument."
placeholder='@L("SenderEditor_Message_Placeholder", "Bitte unterzeichnen Sie das beigefügte Dokument.")'
maxlength="500"
style="font-size: 0.78rem; color: #1f2937;
border: 1px solid #d1d5db; border-radius: 5px;
@@ -83,7 +89,7 @@
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem;
background: #ede9fe; border-radius: 0.25rem; color: #6d28d9;
font-weight: 500; font-size: 0.7rem; white-space: nowrap;">
@_signatureFields.Count Signaturfeld@(_signatureFields.Count != 1 ? "er" : "")
@_signatureFields.Count @L("SenderEditor_SignatureField", "Signaturfeld")@(_signatureFields.Count != 1 ? "er" : "")
</span>
}
</div>
@@ -93,24 +99,24 @@
<div class="sender-receivers-panel">
<div class="sender-receivers-panel__header">
<div class="sender-receivers-panel__title-wrap">
<span class="sender-receivers-panel__title">Empfänger</span>
<span class="sender-receivers-panel__title">@L("Receiver", "Empfänger")</span>
<span class="sender-receivers-panel__count">@_receivers.Count</span>
</div>
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn"
@onclick="OpenAddReceiverPopup"
title="Empfänger hinzufügen">
title='@L("AddReceiver", "Empfänger hinzufügen")'>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 1a.5.5 0 0 1 .5.5v6h6a.5.5 0 0 1 0 1h-6v6a.5.5 0 0 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 8 1z" />
</svg>
<span class="pdf-toolbar__btn-text">Empfänger hinzufügen</span>
<span class="pdf-toolbar__btn-text">@L("AddReceiver", "Empfänger hinzufügen")</span>
</button>
</div>
@if (_receivers.Count == 0)
{
<div class="sender-receivers-panel__empty">
Noch keine Empfänger hinzugefügt.
@L("SenderEditor_NoReceiversAdded", "Noch keine Empfänger hinzugefügt.")
</div>
}
else
@@ -133,11 +139,11 @@
@(_pendingReceiverForPlacement?.Id == receiver.Id ? "pdf-toolbar__btn--signature-change-active" : "")"
@onclick="() => ActivatePlacementForReceiver(receiver)"
style="@(_pendingReceiverForPlacement?.Id == receiver.Id ? $"background:{receiver.Color};color:#fff;border-color:{receiver.Color};" : $"color:{receiver.Color};border-color:{receiver.Color};")"
title="Signaturfeld platzieren">
title='@L("SenderEditor_PlaceSignatureField", "Signaturfeld platzieren")'>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" />
</svg>
<span class="pdf-toolbar__btn-text">Signatur hinzufügen</span>
<span class="pdf-toolbar__btn-text">@L("SenderEditor_AddSignature", "Signatur hinzufügen")</span>
</button>
</div>
}
@@ -151,32 +157,29 @@
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
@onclick="Cancel"
title="Abbrechen">
Abbrechen
title='@L("Cancel", "Abbrechen")'>
@L("Cancel", "Abbrechen")
</button>
@* PDF Upload *@
<label class="pdf-toolbar__btn"
title="PDF hochladen"
title='@L("UploadPdf", "PDF hochladen")'
style="cursor: pointer; display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.75rem; font-size: 0.75rem; font-weight: 500;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
</svg>
PDF hochladen
@L("UploadPdf", "PDF hochladen")
<InputFile OnChange="OnPdfFileSelectedAsync"
accept=".pdf"
style="display: none;" />
</label>
@if (_pdfLoaded)
{
@* Clear all fields *@
@if (_signatureFields.Count > 0)
@if (_pdfLoaded && _signatureFields.Count > 0)
{
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
@onclick="ClearAllFieldsAsync"
title="Alle Signaturfelder entfernen">
title='@L("SenderEditor_RemoveAllSignatureFields", "Alle Signaturfelder entfernen")'>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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 0-2h3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h3a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3h11V2h-11v1z" />
@@ -184,11 +187,29 @@
</button>
}
@* Save *@
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
@onclick="SaveAsync"
<button class="pdf-toolbar__btn pdf-toolbar__btn--draft"
@onclick="SaveDraftAsync"
disabled="@_isSaving"
title="Speichern"
title='@L("Save", "Speichern")'>
@if (_isSaving)
{
<span class="spinner-border spinner-border-sm me-1" role="status"
style="width:0.8rem;height:0.8rem;border-width:2px;"></span>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M2 2a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V5.414A2 2 0 0 0 15.414 4L12 0.586A2 2 0 0 0 10.586 0H2zm9 1.5v2A1.5 1.5 0 0 1 9.5 7h-3A1.5 1.5 0 0 1 5 5.5v-4h5.586a1 1 0 0 1 .707.293L14.207 4.707A1 1 0 0 1 14.5 5.414V12a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5V4A.5.5 0 0 1 2 3.5h9z"/>
<path d="M5.5 1.5v4a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-4h-4zM4 9a1 1 0 0 0-1 1v3h10v-3a1 1 0 0 0-1-1H4z"/>
</svg>
}
<span class="pdf-toolbar__btn-text">@L("Save", "Speichern")</span>
</button>
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
@onclick="SendAsync"
disabled="@_isSaving"
title='@L("SendEnvelope", "Umschlag senden")'
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
@if (_isSaving)
{
@@ -201,9 +222,8 @@
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
</svg>
}
Speichern
@L("Send", "Senden")
</button>
}
</div>
</div>
@@ -212,10 +232,10 @@
{
<div style="background: @_pendingReceiverForPlacement.Color; color: white; font-size: 0.75rem; font-weight: 500;
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
📌 Klicken Sie auf die Stelle im Dokument für <strong>@_pendingReceiverForPlacement.FullName</strong>.
📌 @L("SenderEditor_ClickDocumentFor", "Klicken Sie auf die Stelle im Dokument für") <strong>@_pendingReceiverForPlacement.FullName</strong>.
&nbsp;<button @onclick="CancelPlacement"
style="background:none;border:none;color:white;cursor:pointer;font-size:0.75rem;text-decoration:underline;padding:0;">
Abbrechen
@L("Cancel", "Abbrechen")
</button>
</div>
}
@@ -235,16 +255,16 @@
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
</svg>
</div>
<h5 class="text-white mb-2">Kein Dokument geladen</h5>
<h5 class="text-white mb-2">@L("SenderEditor_NoDocumentLoaded", "Kein Dokument geladen")</h5>
<p class="text-white mb-4" style="opacity: 0.75; font-size: 0.85rem;">
Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.
@L("SenderEditor_UploadPdfToPlaceFields", "Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.")
</p>
<label class="btn btn-light btn-sm" style="cursor: pointer; font-weight: 500;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
</svg>
PDF hochladen
@L("UploadPdf", "PDF hochladen")
<InputFile OnChange="OnPdfFileSelectedAsync" accept=".pdf" style="display: none;" />
</label>
</div>
@@ -254,7 +274,7 @@
{
<div class="error-container">
<div class="alert alert-danger shadow-lg m-4">
<strong>Fehler:</strong> @_errorMessage
<strong>@L("Error", "Fehler"):</strong> @_errorMessage
</div>
</div>
}
@@ -273,7 +293,7 @@
</div>
<DxPopup @bind-Visible="_receiverPopupVisible"
HeaderText="Empfänger hinzufügen"
HeaderText='@L("AddReceiver", "Empfänger hinzufügen")'
ShowFooter="true"
CloseOnEscape="true"
Width="720px"
@@ -282,7 +302,7 @@
<div class="sender-receiver-popup__body">
<div class="sender-receiver-popup__form-grid">
<div class="sender-receiver-popup__field">
<div class="sender-receiver-popup__label">Vor- und Nachname</div>
<div class="sender-receiver-popup__label">@L("FullName", "Vor- und Nachname")</div>
<DxTextBox Text="@_receiverDraftName"
TextChanged="OnReceiverNameChanged"
NullText="Max Mustermann"
@@ -292,7 +312,7 @@
</div>
<div class="sender-receiver-popup__field">
<div class="sender-receiver-popup__label">Telefonnummer (optional)</div>
<div class="sender-receiver-popup__label">@L("PhoneNumber", "Telefonnummer") (@L("Optional", "optional"))</div>
<DxTextBox Text="@_receiverDraftPhoneNumber"
TextChanged="OnReceiverPhoneNumberChanged"
NullText="+49 170 1234567"
@@ -302,7 +322,7 @@
</div>
<div class="sender-receiver-popup__field">
<div class="sender-receiver-popup__label">E-Mail-Adresse</div>
<div class="sender-receiver-popup__label">@L("EmailAddress", "E-Mail-Adresse")</div>
<div @onkeydown="OnReceiverEmailKeyDownAsync">
<DxTextBox Text="@_receiverDraftEmail"
TextChanged="OnReceiverEmailTextChangedAsync"
@@ -330,12 +350,12 @@
</div>
<div class="sender-receiver-popup__hint">
Bereits verwendete E-Mail-Adressen werden bei der Eingabe vorgeschlagen.
@L("SenderEditor_EmailSuggestionsHint", "Bereits verwendete E-Mail-Adressen werden bei der Eingabe vorgeschlagen.")
</div>
@if (_isReceiverEmailSearchRunning)
{
<div class="sender-receiver-popup__loading">E-Mail-Vorschläge werden geladen…</div>
<div class="sender-receiver-popup__loading">@L("SenderEditor_EmailSuggestionsLoading", "E-Mail-Vorschläge werden geladen…")</div>
}
@if (!string.IsNullOrWhiteSpace(_receiverPopupValidationMessage))
@@ -346,10 +366,10 @@
</BodyContentTemplate>
<FooterContentTemplate>
<div class="sender-receiver-popup__footer">
<DxButton Text="Abbrechen"
<DxButton Text='@L("Cancel", "Abbrechen")'
RenderStyle="ButtonRenderStyle.Secondary"
Click="CloseAddReceiverPopup" />
<DxButton Text="Empfänger hinzufügen"
<DxButton Text='@L("AddReceiver", "Empfänger hinzufügen")'
RenderStyle="ButtonRenderStyle.Primary"
Click="SaveReceiverAsync" />
</div>
@@ -358,7 +378,7 @@
@* ── Save result popup (success + error) ── *@
<DxPopup @bind-Visible="_savePopupVisible"
HeaderText="@(_saveErrorMessage is null ? "Umschlag erstellt" : "Fehler")"
HeaderText='@(_saveErrorMessage is null ? _saveSuccessHeader : L("Error", "Fehler"))'
Width="460px"
MaxWidth="95vw"
ShowFooter="true"
@@ -377,10 +397,10 @@
</div>
<div>
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
Umschlag wurde erfolgreich erstellt.
@_saveSuccessTitle
</p>
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;">
Der Umschlag wurde gespeichert und die Empfänger wurden benachrichtigt.
@_saveSuccessBody
</p>
</div>
</div>
@@ -397,7 +417,7 @@
</div>
<div>
<p style="margin:0 0 0.4rem;font-weight:600;color:#1f2937;font-size:0.95rem;">
Fehler beim Erstellen des Umschlags
@L("SenderEditor_ErrorCreatingEnvelope", "Fehler beim Erstellen des Umschlags")
</p>
<p style="margin:0;color:#6b7280;font-size:0.85rem;line-height:1.5;word-break:break-word;">
@_saveErrorMessage
@@ -409,20 +429,29 @@
<FooterContentTemplate>
<div style="display:flex;gap:0.5rem;justify-content:flex-end;width:100%;padding:0.5rem 0;">
@if (_saveErrorMessage is null)
{
<button class="btn btn-outline-secondary"
@onclick="() => _savePopupVisible = false"
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
@L("Close", "Schließen")
</button>
@if (_saveNavigateToDashboard)
{
<button class="btn btn-primary"
@onclick="GoToDashboard"
style="background:linear-gradient(135deg,#059669,#047857);border:none;border-radius:6px;
padding:0.5rem 1.5rem;font-weight:600;color:#fff;">
Zur Übersicht
@L("SenderEditor_ToOverview", "Zur Übersicht")
</button>
}
}
else
{
<button class="btn btn-outline-secondary"
@onclick="() => _savePopupVisible = false"
style="border-radius:6px;padding:0.5rem 1.25rem;font-weight:500;">
Schließen
@L("Close", "Schließen")
</button>
}
</div>
@@ -434,6 +463,9 @@
[SupplyParameterFromQuery(Name = "esid")]
public string? Esid { get; set; }
[SupplyParameterFromQuery(Name = "envelopeId")]
public int? EnvelopeId { get; set; }
// ── Constants ──
// Signature field size in PDF points (fixed): 1.77" × 1.96"
const double SigWidthPt = 1.77 * 72; // 127.44 pt
@@ -452,6 +484,9 @@
string? _errorMessage;
byte[]? _pdfBytes; // Current rendered PDF (original + placeholders burned in)
byte[]? _originalPdfBytes; // Pristine upload — never modified, used as base for redraw
int? _loadedEnvelopeId;
int? _editingEnvelopeId;
bool _isLoadingEnvelopeForEdit;
List<SignatureFieldDraft> _signatureFields = [];
ReceiverDraft? _pendingReceiverForPlacement; // Set when user clicks "Signatur hinzufügen"
@@ -460,6 +495,10 @@
bool _isSaving = false;
bool _savePopupVisible = false;
string? _saveErrorMessage = null;
string _saveSuccessHeader = "Erfolg";
string _saveSuccessTitle = "Vorgang erfolgreich abgeschlossen.";
string _saveSuccessBody = string.Empty;
bool _saveNavigateToDashboard = false;
// ── Envelope metadata ──
string _envelopeTitle = string.Empty;
@@ -481,6 +520,7 @@
// ── Cache key helper ──
string SessionKey => $"sender-editor:{Esid}";
bool IsEditMode => EnvelopeId.HasValue && EnvelopeId.Value > 0;
// ── Lifecycle ──
@@ -500,12 +540,27 @@
if (string.IsNullOrWhiteSpace(Esid))
{
var sid = Guid.NewGuid().ToString("N");
NavigationManager.NavigateTo($"/sender/editor?esid={sid}", forceLoad: false);
var editQuery = EnvelopeId.HasValue && EnvelopeId.Value > 0
? $"&envelopeId={EnvelopeId.Value}"
: string.Empty;
NavigationManager.NavigateTo($"/sender/editor?esid={sid}{editQuery}", forceLoad: false);
}
}
protected override void OnParametersSet()
protected override async Task OnParametersSetAsync()
{
if (EnvelopeId.HasValue && EnvelopeId.Value > 0)
_editingEnvelopeId = EnvelopeId.Value;
if (IsEditMode && _loadedEnvelopeId != EnvelopeId)
{
await LoadEnvelopeForEditAsync(EnvelopeId!.Value);
return;
}
if (IsEditMode)
return;
// After the esid is set via query param, try to restore from cache.
if (!string.IsNullOrWhiteSpace(Esid)
&& MemoryCache.TryGetValue(SessionKey, out EditorSessionData? cached)
@@ -525,6 +580,93 @@
}
}
async Task LoadEnvelopeForEditAsync(int envelopeId)
{
if (_isLoadingEnvelopeForEdit)
return;
_isLoadingEnvelopeForEdit = true;
_errorMessage = null;
try
{
var user = EnvelopeAuthService.GetCurrentUser();
if (user?.Identity is null || !user.Identity.IsAuthenticated)
throw new InvalidOperationException("Sender is not authenticated.");
var envelopes = await EnvelopeService.GetAsync(id: envelopeId);
var envelope = envelopes?.FirstOrDefault()
?? throw new InvalidOperationException($"Envelope {envelopeId} not found.");
var document = await ReceiverPageDataService.GetDocumentDetailsOfSenderAsync(user, envelopeId);
_envelopeTitle = envelope.Title ?? string.Empty;
_envelopeMessage = envelope.Message ?? string.Empty;
var envelopeReceivers = envelope.EnvelopeReceivers?.ToList() ?? [];
_receivers = envelopeReceivers
.Select((er, index) => new ReceiverDraft(
Guid.NewGuid(),
er.Name ?? er.Receiver?.EmailAddress ?? $"Receiver {er.ReceiverId}",
er.Receiver?.EmailAddress ?? string.Empty,
string.Empty,
ReceiverPalette[index % ReceiverPalette.Length]))
.ToList();
var receiverById = envelopeReceivers.ToDictionary(er => er.ReceiverId, er => er);
var colorByEmail = _receivers
.Where(r => !string.IsNullOrWhiteSpace(r.Email))
.GroupBy(r => r.Email, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First().Color, StringComparer.OrdinalIgnoreCase);
_signatureFields = (document?.Elements ?? Enumerable.Empty<DocReceiverElementDto>())
.Select(element =>
{
receiverById.TryGetValue(element.ReceiverId, out var envelopeReceiver);
var receiverName = envelopeReceiver?.Name
?? envelopeReceiver?.Receiver?.EmailAddress
?? element.FullName
?? $"Receiver {element.ReceiverId}";
var receiverEmail = envelopeReceiver?.Receiver?.EmailAddress;
var color = (!string.IsNullOrWhiteSpace(receiverEmail) && colorByEmail.TryGetValue(receiverEmail, out var receiverColor))
? receiverColor
: ReceiverPalette[Math.Abs(element.ReceiverId) % ReceiverPalette.Length];
return new SignatureFieldDraft(
XPt: element.X * 72.0,
YPt: element.Y * 72.0,
Page: element.Page,
ReceiverName: receiverName,
Color: color);
})
.ToList();
_originalPdfBytes = document?.ByteData;
_pdfLoaded = _originalPdfBytes is { Length: > 0 };
_pdfBytes = _pdfLoaded
? DrawPlaceholders(_originalPdfBytes!, _signatureFields)
: null;
_fileName = $"Envelope-{envelopeId}.pdf";
_pendingReceiverForPlacement = null;
_loadedEnvelopeId = envelopeId;
_editingEnvelopeId = envelopeId;
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to load envelope {EnvelopeId} for edit mode", envelopeId);
_errorMessage = string.Format(
L("SenderEditor_LoadEnvelopeError", "Fehler beim Laden des Umschlags #{0}: {1}"),
envelopeId,
ex.Message);
}
finally
{
_isLoadingEnvelopeForEdit = false;
}
}
// ── PDF upload ──
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
{
@@ -534,7 +676,7 @@
if (!file.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
{
_errorMessage = "Bitte wählen Sie eine PDF-Datei aus.";
_errorMessage = L("SenderEditor_SelectPdf", "Bitte wählen Sie eine PDF-Datei aus.");
return;
}
@@ -559,7 +701,9 @@
}
catch (Exception ex)
{
_errorMessage = $"Fehler beim Laden der Datei: {ex.Message}";
_errorMessage = string.Format(
L("SenderEditor_LoadFileError", "Fehler beim Laden der Datei: {0}"),
ex.Message);
Logger.LogError(ex, "Failed to load PDF file");
}
}
@@ -670,32 +814,72 @@
NavigationManager.NavigateTo("/sender");
}
// ── Save — POST /api/EnvelopeReceiver ──
async Task SaveAsync()
async Task SaveDraftAsync()
{
_isSaving = true;
_saveErrorMessage = null;
await InvokeAsync(StateHasChanged);
try
{
var previousEnvelopeId = EnvelopeId;
var command = BuildCreateEnvelopeCommand(send: false);
Logger.LogInformation("[SenderEditor] Saving draft. EnvelopeId={EnvelopeId} IsEditMode={IsEditMode}", command.EnvelopeId, IsEditMode);
var result = await EnvelopeService.UpsertAsync(command);
if (result is null)
throw new InvalidOperationException("Draft save did not return a result.");
EnvelopeId = result.Id;
_loadedEnvelopeId = result.Id;
_editingEnvelopeId = result.Id;
if (previousEnvelopeId != result.Id)
{
var sid = string.IsNullOrWhiteSpace(Esid) ? Guid.NewGuid().ToString("N") : Esid;
NavigationManager.NavigateTo($"/sender/editor?esid={sid}&envelopeId={result.Id}", forceLoad: false);
}
NavigationManager.NavigateTo("/sender", forceLoad: false);
}
catch (Exception ex)
{
Logger.LogError(ex, "[SenderEditor] Failed to save draft");
_saveErrorMessage = ex.Message;
_savePopupVisible = true;
}
finally
{
_isSaving = false;
}
}
// ── Send envelope ──
async Task SendAsync()
{
// ── Validation ──
_titleTouched = true;
if (!_pdfLoaded || _originalPdfBytes is null)
{
_saveErrorMessage = "Bitte laden Sie zuerst ein PDF-Dokument hoch.";
_saveErrorMessage = L("SenderEditor_Validation_UploadPdf", "Bitte laden Sie zuerst ein PDF-Dokument hoch.");
_savePopupVisible = true;
return;
}
if (string.IsNullOrWhiteSpace(_envelopeTitle))
{
_saveErrorMessage = "Bitte geben Sie einen Titel ein.";
_saveErrorMessage = L("SenderEditor_Validation_TitleRequired", "Bitte geben Sie einen Titel ein.");
_savePopupVisible = true;
return;
}
if (_receivers.Count == 0)
{
_saveErrorMessage = "Bitte fügen Sie mindestens einen Empfänger hinzu.";
_saveErrorMessage = L("SenderEditor_Validation_ReceiverRequired", "Bitte fügen Sie mindestens einen Empfänger hinzu.");
_savePopupVisible = true;
return;
}
if (_signatureFields.Count == 0)
{
_saveErrorMessage = "Bitte platzieren Sie mindestens ein Signaturfeld.";
_saveErrorMessage = L("SenderEditor_Validation_SignatureFieldRequired", "Bitte platzieren Sie mindestens ein Signaturfeld.");
_savePopupVisible = true;
return;
}
@@ -717,57 +901,38 @@
try
{
// ── Build request ──
// Document: use the ORIGINAL pdf (without placeholder burn-in) as base64
var docBase64 = Convert.ToBase64String(_originalPdfBytes);
var envelopeId = _editingEnvelopeId
?? EnvelopeId
?? _loadedEnvelopeId
?? GetEnvelopeIdFromCurrentUri();
// Build receivers list — each receiver gets their own signature fields
// Coordinate conversion: PDF points → inches (DB stores inches)
var receiversCmd = _receivers.Select(receiver =>
if (!envelopeId.HasValue || envelopeId.Value <= 0)
{
var fields = _signatureFields
.Where(f => f.ReceiverName == receiver.FullName)
.Select(f => new DocReceiverElementCreateDto(
X: f.XPt / 72.0,
Y: f.YPt / 72.0,
Page: f.Page))
.ToList();
var saveResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: false))
?? throw new InvalidOperationException("Draft save did not return a result.");
return new ReceiverGetOrCreateCommand
{
EmailAddress = receiver.Email,
Salution = receiver.FullName,
PhoneNumber = string.IsNullOrWhiteSpace(receiver.PhoneNumber)
? null
: receiver.PhoneNumber,
DocReceiverElements = fields,
};
}).ToList();
envelopeId = saveResult.Id;
EnvelopeId = saveResult.Id;
_loadedEnvelopeId = saveResult.Id;
_editingEnvelopeId = saveResult.Id;
}
var command = new CreateEnvelopeReceiverCommand
{
Title = _envelopeTitle.Trim(),
Message = string.IsNullOrWhiteSpace(_envelopeMessage)
? "Bitte unterzeichnen Sie das beigefügte Dokument."
: _envelopeMessage.Trim(),
TFAEnabled = false,
Document = new DocumentCreateCommand { DataAsBase64 = docBase64 },
Receivers = receiversCmd,
};
var sendResult = await EnvelopeService.UpsertAsync(BuildCreateEnvelopeCommand(send: true) with { EnvelopeId = envelopeId })
?? throw new InvalidOperationException("Send did not return a result.");
var result = await EnvelopeReceiverService.CreateAsync(command);
Logger.LogInformation(
"[SenderEditor] Envelope created. Id={Id} SentReceivers={Count}",
result?.Id, result?.SentReceiver.Count());
Logger.LogInformation("[SenderEditor] Envelope sent from draft. Id={Id} Status={Status}", sendResult.Id, sendResult.Status);
// Success — show popup; GoToDashboard clears cache and navigates
_saveErrorMessage = null;
_saveSuccessHeader = L("SenderEditor_Sent_Header", "Umschlag gesendet");
_saveSuccessTitle = L("SenderEditor_Sent_Title", "Umschlag wurde erfolgreich gesendet.");
_saveSuccessBody = L("SenderEditor_Sent_Body", "Der bestehende Entwurf wurde versendet.");
_saveNavigateToDashboard = true;
_savePopupVisible = true;
}
catch (Exception ex)
{
Logger.LogError(ex, "[SenderEditor] Failed to create envelope");
Logger.LogError(ex, "[SenderEditor] Failed to send envelope draft");
_saveErrorMessage = ex.Message;
_savePopupVisible = true;
}
@@ -777,6 +942,71 @@
}
}
CreateEnvelopeCommand BuildCreateEnvelopeCommand(bool send)
{
var envelopeId = _editingEnvelopeId
?? EnvelopeId
?? _loadedEnvelopeId
?? GetEnvelopeIdFromCurrentUri();
if (IsEditMode && envelopeId is null)
throw new InvalidOperationException("Edit mode envelope id is missing.");
var docBase64 = _originalPdfBytes is { Length: > 0 }
? Convert.ToBase64String(_originalPdfBytes)
: null;
var receiverCommands = _receivers.Select(receiver =>
{
var fields = _signatureFields
.Where(f => f.ReceiverName == receiver.FullName)
.Select(f => new CreateEnvelopeFieldDto(
X: f.XPt / 72.0,
Y: f.YPt / 72.0,
Page: f.Page))
.ToList();
return new CreateEnvelopeReceiverDto(
EmailAddress: receiver.Email,
Name: receiver.FullName,
PhoneNumber: string.IsNullOrWhiteSpace(receiver.PhoneNumber) ? null : receiver.PhoneNumber,
Fields: fields);
}).ToList();
return new CreateEnvelopeCommand
{
EnvelopeId = envelopeId,
Title = _envelopeTitle,
Message = _envelopeMessage,
TFAEnabled = false,
Send = send,
Document = string.IsNullOrWhiteSpace(docBase64) ? null : new CreateEnvelopeDocumentDto(docBase64),
Receivers = receiverCommands
};
}
int? GetEnvelopeIdFromCurrentUri()
{
try
{
var uri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
if (string.IsNullOrWhiteSpace(uri.Query))
return null;
var query = QueryHelpers.ParseQuery(uri.Query);
if (!query.TryGetValue("envelopeId", out var raw))
return null;
return int.TryParse(raw.ToString(), out var id) && id > 0
? id
: null;
}
catch
{
return null;
}
}
// ── Cache persistence ──
void PersistSession()
{
@@ -1000,22 +1230,22 @@
if (string.IsNullOrWhiteSpace(fullName))
{
_receiverPopupValidationMessage = "Bitte geben Sie einen Vor- und Nachnamen ein.";
_receiverPopupValidationMessage = L("SenderEditor_Validation_FullNameRequired", "Bitte geben Sie einen Vor- und Nachnamen ein.");
return Task.CompletedTask;
}
if (string.IsNullOrWhiteSpace(email))
{
_receiverPopupValidationMessage = "Bitte geben Sie eine E-Mail-Adresse ein.";
_receiverPopupValidationMessage = L("SenderEditor_Validation_EmailRequired", "Bitte geben Sie eine E-Mail-Adresse ein.");
return Task.CompletedTask;
}
if (!ReceiverEmailValidator.IsValid(email))
{
_receiverPopupValidationMessage = "Bitte geben Sie eine gültige E-Mail-Adresse ein.";
_receiverPopupValidationMessage = L("SenderEditor_Validation_EmailInvalid", "Bitte geben Sie eine gültige E-Mail-Adresse ein.");
return Task.CompletedTask;
}
if (_receivers.Any(r => string.Equals(r.Email, email, StringComparison.OrdinalIgnoreCase)))
{
_receiverPopupValidationMessage = "Diese E-Mail-Adresse wurde bereits hinzugefügt.";
_receiverPopupValidationMessage = L("SenderEditor_Validation_EmailDuplicate", "Diese E-Mail-Adresse wurde bereits hinzugefügt.");
return Task.CompletedTask;
}
@@ -1053,4 +1283,10 @@
"#BE185D", // pink
"#65A30D", // lime
];
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -7,7 +7,11 @@
@using DevExpress.Blazor.PdfViewer
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Common.Dto
@using EnvelopeGenerator.Application.Common.Dto.History
@using EnvelopeGenerator.Application.Resources
@using EnvelopeGenerator.Domain.Resources
@using EnvelopeGenerator.Server.Services
@using Microsoft.Extensions.Localization
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
@@ -19,6 +23,8 @@
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
@inject IStringLocalizer<Model> ModelLocalizer
@inject IStringLocalizer<Resource> UiLocalizer
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/sender-page.css")" rel="stylesheet" />
@@ -66,29 +72,29 @@
@* ── Toolbar (rechts) ─────────────────────────────────────────── *@
<div class="sender-toolbar">
<button class="sender-btn sender-btn--primary" @onclick="CreateEnvelope" title="Neuen Umschlag erstellen">
<button class="sender-btn sender-btn--primary" @onclick="CreateEnvelope" title="@L("Sender_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
@L("Sender_CreateEnvelope", "Neuer Umschlag")
</button>
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || IsEnvelopeSent(_selectedEnvelope))" title="Ausgewählten Umschlag bearbeiten">
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || !IsEnvelopeEditable(_selectedEnvelope))" title="@L("Sender_EditEnvelope_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
@L("Sender_Edit", "Bearbeiten")
</button>
<button class="sender-btn sender-btn--danger" @onclick="DeleteEnvelope" disabled="@(_selectedEnvelope == null)" title="Ausgewählten Umschlag löschen">
<button class="sender-btn sender-btn--danger" @onclick="DeleteEnvelope" disabled="@(_selectedEnvelope == null)" title="@L("Sender_DeleteEnvelope_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
@L("Delete", "Löschen")
</button>
<button class="sender-btn" @onclick="RefreshEnvelopes" disabled="@_isLoading" title="Aktualisieren">
<button class="sender-btn" @onclick="RefreshEnvelopes" disabled="@_isLoading" title="@L("Refresh", "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" />
@@ -99,16 +105,16 @@
}
</button>
<button class="sender-btn" @onclick="ShowPreview" disabled="@(_selectedEnvelope == null)" title="Ausgewählten Umschlag als PDF anzeigen">
<button class="sender-btn" @onclick="ShowPreview" disabled="@(_selectedEnvelope == null)" title="@L("Sender_Preview_Title", "Ausgewählten Umschlag als PDF anzeigen")">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"/>
<path d="M0 8s3-5.5 8-5.5S16 8 16 8s-3 5.5-8 5.5S0 8 0 8zm8 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z"/>
</svg>
Vorschau
@L("Preview", "Vorschau")
</button>
<div class="sender-export-group" title="Export der aktuellen Tabellenansicht">
<select id="sender-export-format" class="sender-export-format" @bind="_selectedExportFormat" aria-label="Exportformat auswählen">
<div class="sender-export-group" title="@L("Sender_ExportView_Title", "Export der aktuellen Tabellenansicht")">
<select id="sender-export-format" class="sender-export-format" @bind="_selectedExportFormat" aria-label="@L("Sender_ExportFormat_Aria", "Exportformat auswählen")">
<option value="xlsx">XLSX</option>
<option value="csv">CSV</option>
<option value="pdf">PDF</option>
@@ -117,15 +123,15 @@
<label class="sender-export-selected">
<input type="checkbox" @bind="_exportSelectedRowsOnly" />
<span class="sender-export-selected-text">
<span>Nur</span>
<span>ausgewählte</span>
<span>@L("Only", "Nur")</span>
<span>@L("Selected", "ausgewählte")</span>
</span>
</label>
<button class="sender-btn sender-btn--export"
@onclick="ExportSelectedFormatAsync"
disabled="@IsExportDisabled()"
title="Aktuelle Ansicht exportieren">
title="@L("Sender_ExportCurrentView_Title", "Aktuelle Ansicht exportieren")">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5V13a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.6a.5.5 0 0 1 1 0V13a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.6a.5.5 0 0 1 .5-.5z" />
<path d="M5.354 6.146a.5.5 0 0 1 .707 0L7.5 7.586V.5a.5.5 0 0 1 1 0v7.086l1.439-1.44a.5.5 0 0 1 .707.708l-2.293 2.293a.5.5 0 0 1-.707 0L5.354 6.854a.5.5 0 0 1 0-.708z" />
@@ -136,12 +142,12 @@
}
else
{
<span>Exportieren</span>
<span>@L("Export", "Exportieren")</span>
}
</button>
</div>
<button class="sender-btn sender-btn--logout" @onclick="LogoutAsync" disabled="@_isLoggingOut" title="Abmelden">
<button class="sender-btn sender-btn--logout" @onclick="LogoutAsync" disabled="@_isLoggingOut" title="@L("Logout", "Abmelden")">
@if (_isLoggingOut)
{
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
@@ -164,9 +170,9 @@
<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>
<span class="visually-hidden">@L("Loading", "Lädt...")</span>
</div>
<p class="text-white fw-semibold">Umschläge werden geladen...</p>
<p class="text-white fw-semibold">@L("Sender_LoadingEnvelopes", "Umschläge werden geladen...")</p>
</div>
</div>
}
@@ -180,7 +186,7 @@
<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>
<h5 class="mb-2">@L("Sender_LoadError_Title", "Fehler beim Laden der Umschläge")</h5>
<p class="mb-0">@_errorMessage</p>
</div>
</div>
@@ -192,14 +198,14 @@
<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>
<span>@L("Sender_ActiveEnvelopes", "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>
<span>@L("Sender_CompletedEnvelopes", "Abgeschlossene Umschläge")</span>
@if (_completedEnvelopes != null)
{
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_completedEnvelopes.Count())</span>
@@ -208,15 +214,15 @@
</div>
<div class="sender-grid-wrapper">
@if (_activeTab == "active")
{
<DxGrid Data="@_activeEnvelopes"
@ref="_gridActive"
<DxGrid @key="_activeTab"
Data="@CurrentGridData"
@ref="_grid"
KeyFieldName="Id"
ShowFilterRow="true"
ShowFilterRow="false"
FilterMenuButtonDisplayMode="GridFilterMenuButtonDisplayMode.Always"
ShowSearchBox="true"
AllowColumnReorder="true"
AllowSort=true
AllowSort="true"
ColumnResizeMode="GridColumnResizeMode.ColumnsContainer"
PageSize="20"
PagerVisible="true"
@@ -227,22 +233,30 @@
RowClick="OnRowClick"
RowDoubleClick="OnRowDoubleClick"
CustomizeElement="OnCustomizeElement"
LayoutAutoLoading="OnActiveGridLayoutAutoLoading"
LayoutAutoSaving="OnActiveGridLayoutAutoSaving">
LayoutAutoLoading="OnGridLayoutAutoLoading"
LayoutAutoSaving="OnGridLayoutAutoSaving">
<Columns>
<DxGridDataColumn FieldName="Id" Caption="ID"
DisplayFormat="0"
SortIndex="0"
SortOrder="GridColumnSortOrder.Descending">
<CellDisplayTemplate Context="cellContext">
@((cellContext.DataItem as EnvelopeDto)?.Id)
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="Title" Caption="Titel">
<DxGridDataColumn FieldName="Title" Caption="@L("Title", "Titel")">
<CellDisplayTemplate Context="cellContext">
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="Status" Caption="Status" ExportEnabled="false">
<DxGridDataColumn FieldName="Status" Caption="@L("Status", "Status")" ExportEnabled="false">
<EditSettings>
<DxComboBoxSettings Data="@GetStatusFilterItems(CurrentGridData)"
ValueFieldName="Value"
TextFieldName="Text"
SearchMode="ListSearchMode.AutoSearch"
SearchFilterCondition="ListSearchFilterCondition.Contains" />
</EditSettings>
<CellDisplayTemplate Context="cellContext">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
@@ -257,18 +271,18 @@
}
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger" ExportEnabled="false">
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="@L("Receiver", "Empfänger")" ExportEnabled="false">
<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 signed = GetSignedReceiverCount(envelope);
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
@signed / @total @L("Signed", "unterschrieben")
</span>
@if (total > 0)
{
@@ -281,16 +295,16 @@
}
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="Typ"
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="@L("Type", "Typ")"
Width="120px" AllowSort="true" />
<DxGridDataColumn FieldName="AddedWhen" Caption="Erstellt"
<DxGridDataColumn FieldName="AddedWhen" Caption="@L("Created", "Erstellt")"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
<DxGridDataColumn FieldName="ChangedWhen" Caption="Geändert am"
<DxGridDataColumn FieldName="ChangedWhen" Caption="@L("ChangedAt", "Geändert am")"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
</Columns>
<DetailRowTemplate Context="detailContext">
<div style="padding: 1rem; background: #f9fafb;">
<h6 style="font-weight: 600; color: #374151; margin-bottom: 0.75rem;">Empfänger</h6>
<h6 style="font-weight: 600; color: #374151; margin-bottom: 0.75rem;">@L("Receiver", "Empfänger")</h6>
@{
var envelope = detailContext.DataItem as EnvelopeDto;
if (envelope?.EnvelopeReceivers?.Any() == true)
@@ -299,13 +313,18 @@
@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)
@{
var isSigned = IsReceiverSigned(envelope, receiver);
var accessCode = receiver.AccessCode;
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
}
<span class="receiver-badge receiver-badge--@(isSigned ? "signed" : "unsigned")" style="min-width: 100px;">
@if (isSigned)
{
<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>
<span>@L("Signed", "Unterschrieben")</span>
}
else
{
@@ -313,134 +332,62 @@
<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>@L("Pending", "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 class="sender-access-code">
<span class="sender-access-code__label">@L("AccessCode", "Access code")</span>
<div class="sender-access-code__row">
<input class="sender-access-code__value"
value="@(hasAccessCode ? accessCode : "-")"
readonly
title="@L("AccessCode", "Access code")" />
<button type="button"
class="sender-access-code__copy"
disabled="@(!hasAccessCode)"
@onclick='() => CopyAccessCodeAsync(accessCode)'
title="@L("Copy", "Copy")">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M10 1.5A1.5 1.5 0 0 1 11.5 3v1H13A1.5 1.5 0 0 1 14.5 5.5v8A1.5 1.5 0 0 1 13 15H5A1.5 1.5 0 0 1 3.5 13.5V12H3A1.5 1.5 0 0 1 1.5 10.5v-8A1.5 1.5 0 0 1 3 1h7zm.5 2A.5.5 0 0 0 10 3H3a.5.5 0 0 0-.5.5v7A.5.5 0 0 0 3 11h.5V5.5A1.5 1.5 0 0 1 5 4h5.5v-.5zM5 5a.5.5 0 0 0-.5.5v8a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-8A.5.5 0 0 0 13 5H5z"/>
</svg>
</button>
</div>
</div>
</div>
}
</div>
}
else
{
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">Keine Empfänger</p>
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">@L("Sender_NoReceivers", "Keine Empfänger")</p>
}
}
</div>
</DetailRowTemplate>
</DxGrid>
}
else
{
<DxGrid Data="@_completedEnvelopes"
@ref="_gridCompleted"
KeyFieldName="Id"
ShowFilterRow="true"
ShowSearchBox="true"
PageSize="20"
PagerVisible="true"
SelectionMode="GridSelectionMode.Single"
AllowSelectRowByClick="true"
SelectedDataItem="@_selectedEnvelope"
SelectedDataItemChanged="@((object o) => _selectedEnvelope = o as EnvelopeDto)"
RowClick="OnRowClick"
RowDoubleClick="OnRowDoubleClick"
CustomizeElement="OnCustomizeElement"
LayoutAutoLoading="OnCompletedGridLayoutAutoLoading"
LayoutAutoSaving="OnCompletedGridLayoutAutoSaving">
<Columns>
<DxGridDataColumn FieldName="Id" Caption="ID"
SortIndex="0"
SortOrder="GridColumnSortOrder.Descending">
<CellDisplayTemplate Context="cellContext">
@((cellContext.DataItem as EnvelopeDto)?.Id)
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="Title" Caption="Titel">
<CellDisplayTemplate Context="cellContext">
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="Status" Caption="Status" ExportEnabled="false">
<CellDisplayTemplate Context="cellContext">
<h6 style="font-weight: 600; color: #374151; margin: 1rem 0 0.75rem;">@L("History", "Historie")</h6>
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null)
var historyEntries = envelope is null
? Enumerable.Empty<HistoryDto>()
: GetOrderedHistories(envelope);
if (historyEntries.Any())
{
var statusInfo = GetStatusInfo(envelope.Status);
<div class="sender-history-list">
@foreach (var history in historyEntries)
{
var statusInfo = GetStatusInfo(history.Status);
<div class="sender-history-item">
<div class="sender-history-item__left">
<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" ExportEnabled="false">
<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>
<span class="sender-history-item__actor">@GetHistoryActor(history)</span>
</div>
}
</div>
}
}
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="EnvelopeTypeTitle" Caption="Typ"
Width="120px" AllowSort="true" />
<DxGridDataColumn FieldName="AddedWhen" Caption="Erstellt"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
<DxGridDataColumn FieldName="ChangedWhen" Caption="Geändert am"
DisplayFormat="{0:dd.MM.yyyy}" Width="110px" AllowSort="true" />
</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 class="sender-history-item__right">
@GetHistoryDate(history).ToString("dd.MM.yyyy HH:mm")
</div>
</div>
}
@@ -448,22 +395,22 @@
}
else
{
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">Keine Empfänger</p>
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">@L("Sender_NoHistory", "Keine Historie vorhanden")</p>
}
}
</div>
</DetailRowTemplate>
</DxGrid>
}
</div>
</div>
}
</div>
</div>
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
<DxPopup @bind-Visible="@_previewVisible"
HeaderText="@($"Vorschau — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
HeaderText="@($"{L("Preview", "Vorschau")} — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
Width="80vw"
MaxWidth="80vw"
Height="90vh"
@@ -486,9 +433,9 @@
<div class="sender-preview-state-wrap">
<div class="text-center">
<div class="spinner-border mb-3" style="width:3rem;height:3rem;" role="status">
<span class="visually-hidden">Lädt...</span>
<span class="visually-hidden">@L("Loading", "Lädt...")</span>
</div>
<p style="color:#6b7280;font-size:0.875rem;">Dokument wird geladen…</p>
<p style="color:#6b7280;font-size:0.875rem;">@L("DocumentLoading", "Dokument wird geladen…")</p>
</div>
</div>
}
@@ -503,7 +450,7 @@
</svg>
</div>
<div>
<p class="sender-preview-error-title">Dokument konnte nicht geladen werden</p>
<p class="sender-preview-error-title">@L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden")</p>
<p class="sender-preview-error-msg">@_previewErrorMessage</p>
</div>
</div>
@@ -521,6 +468,54 @@
</BodyContentTemplate>
</DxPopup>
<DxPopup @bind-Visible="@_deletePopupVisible"
HeaderText="@L("Sender_DeleteEnvelope_Header", "Umschlag löschen")"
Width="560px"
MaxWidth="95vw"
ShowFooter="true"
CloseOnOutsideClick="false"
ShowCloseButton="true"
CloseOnEscape="true"
ShowHeader="true"
CssClass="sender-delete-popup">
<BodyContentTemplate>
<div style="display:flex;flex-direction:column;gap:0.75rem;">
<p style="margin:0;color:#374151;font-size:0.9rem;line-height:1.45;">
@L("Sender_DeleteEnvelope_ReasonPrompt", "Bitte geben Sie einen Grund an, warum der Umschlag gelöscht/zurückgezogen werden soll.")
</p>
@if (_selectedEnvelope is not null)
{
<p style="margin:0;color:#6b7280;font-size:0.825rem;">
@L("Envelope", "Umschlag"): <strong>#@_selectedEnvelope.Id - @_selectedEnvelope.Title</strong>
</p>
}
<DxMemo @bind-Text="_deleteReason"
NullText="@L("Sender_DeleteEnvelope_ReasonPlaceholder", "Begründung eingeben...")"
Rows="5"
MaxLength="2000"
CssClass="w-100" />
@if (!string.IsNullOrWhiteSpace(_deleteErrorMessage))
{
<div class="alert alert-danger" style="margin:0;padding:0.5rem 0.75rem;font-size:0.85rem;">
@_deleteErrorMessage
</div>
}
</div>
</BodyContentTemplate>
<FooterContentTemplate>
<div style="display:flex;justify-content:flex-end;gap:0.5rem;width:100%;">
<DxButton Text="@L("Cancel", "Abbrechen")"
RenderStyle="ButtonRenderStyle.Secondary"
Click="CloseDeletePopup"
Enabled="@(!_isDeletingEnvelope)" />
<DxButton Text="@L("Delete", "Löschen")"
RenderStyle="ButtonRenderStyle.Danger"
Click="ConfirmDeleteEnvelopeAsync"
Enabled="@(!_isDeletingEnvelope)" />
</div>
</FooterContentTemplate>
</DxPopup>
@code {
private IEnumerable<EnvelopeDto>? _allEnvelopes;
private IEnumerable<EnvelopeDto>? _activeEnvelopes;
@@ -530,8 +525,7 @@
private bool _isLoading = true;
private bool _isLoggingOut = false;
private string? _errorMessage;
private DxGrid? _gridActive;
private DxGrid? _gridCompleted;
private DxGrid? _grid;
// Logo config loaded from /api/CustomImages after auth
private EnvelopeGenerator.Server.Client.Services.CustomImageDto? _appLogo;
@@ -549,10 +543,20 @@
private bool _exportSelectedRowsOnly = false;
private bool _isExporting = false;
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
private bool _deletePopupVisible = false;
private bool _isDeletingEnvelope = false;
private string _deleteReason = string.Empty;
private string? _deleteErrorMessage;
// Stable keys used to store/restore each tab's layout in UserPreferences.GridLayouts
private const string GridKeyActive = "sender.active-envelopes";
private const string GridKeyCompleted = "sender.completed-envelopes";
private IEnumerable<EnvelopeDto> CurrentGridData =>
(_activeTab == "completed" ? _completedEnvelopes : _activeEnvelopes) ?? Enumerable.Empty<EnvelopeDto>();
private string CurrentGridKey => _activeTab == "completed" ? GridKeyCompleted : GridKeyActive;
protected override async Task OnInitializedAsync()
{
var hasAccess = await AuthService.CheckSenderAccessAsync();
@@ -617,7 +621,7 @@
async Task ExportSelectedFormatAsync()
{
var grid = _activeTab == "active" ? _gridActive : _gridCompleted;
var grid = _grid;
if (grid is null)
return;
@@ -675,13 +679,59 @@
void EditEnvelope()
{
if (_selectedEnvelope == null) return;
// TODO: Navigate to envelope editor
if (!IsEnvelopeEditable(_selectedEnvelope)) return;
Navigation.NavigateTo($"/sender/editor?envelopeId={_selectedEnvelope.Id}");
}
void DeleteEnvelope()
{
if (_selectedEnvelope == null) return;
// TODO: Show delete confirmation dialog
_deleteReason = string.Empty;
_deleteErrorMessage = null;
_deletePopupVisible = true;
}
void CloseDeletePopup()
{
if (_isDeletingEnvelope)
return;
_deletePopupVisible = false;
_deleteErrorMessage = null;
}
async Task ConfirmDeleteEnvelopeAsync()
{
if (_selectedEnvelope is null)
return;
if (string.IsNullOrWhiteSpace(_deleteReason))
{
_deleteErrorMessage = L("Sender_DeleteEnvelope_ReasonRequired", "Bitte geben Sie einen Löschgrund an.");
return;
}
_isDeletingEnvelope = true;
_deleteErrorMessage = null;
await InvokeAsync(StateHasChanged);
try
{
await EnvelopeService.DeleteAsync(_selectedEnvelope.Id, _deleteReason.Trim());
_deletePopupVisible = false;
_selectedEnvelope = null;
await LoadEnvelopesAsync();
}
catch (Exception ex)
{
_deleteErrorMessage = ex.Message;
}
finally
{
_isDeletingEnvelope = false;
await InvokeAsync(StateHasChanged);
}
}
async Task LogoutAsync()
@@ -699,23 +749,132 @@
return status >= EnvelopeStatus.EnvelopeQueued;
}
bool IsEnvelopeEditable(EnvelopeDto envelope)
{
var status = (EnvelopeStatus)envelope.Status;
return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved;
}
(string Label, string CssClass, string DotColor) GetStatusInfo(EnvelopeStatus status)
{
var label = GetStatusLabel(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")
EnvelopeStatus.EnvelopePartlySigned => (label, "partly-signed", "green"),
EnvelopeStatus.EnvelopeCompletelySigned => (label, "completed", "green"),
EnvelopeStatus.DocumentSigned => (label, "partly-signed", "green"),
EnvelopeStatus.MessageCompletionSent => (label, "completed", "green"),
EnvelopeStatus.EnvelopeQueued => (label, "queued", "orange"),
EnvelopeStatus.EnvelopeSent => (label, "sent", "orange"),
EnvelopeStatus.MessageInvitationSent => (label, "queued", "orange"),
EnvelopeStatus.MessageAccessCodeSent => (label, "queued", "orange"),
EnvelopeStatus.MessageConfirmationSent => (label, "queued", "orange"),
EnvelopeStatus.EnvelopeDeleted => (label, "deleted", "red"),
EnvelopeStatus.EnvelopeRejected => (label, "rejected", "red"),
EnvelopeStatus.EnvelopeWithdrawn => (label, "withdrawn", "red"),
EnvelopeStatus.DocumentRejected => (label, "rejected", "red"),
EnvelopeStatus.AccessCodeIncorrect => (label, "rejected", "red"),
EnvelopeStatus.MessageDeletionSent => (label, "deleted", "red"),
_ => (label, "created", "blue")
};
}
string GetStatusLabel(EnvelopeStatus status)
{
var key = status.ToString();
var localized = ModelLocalizer[key];
return localized.ResourceNotFound ? key : localized.Value;
}
IEnumerable<HistoryDto> GetOrderedHistories(EnvelopeDto envelope)
{
return (envelope.Histories ?? Enumerable.Empty<HistoryDto>())
.OrderByDescending(GetHistoryDate)
.Take(20);
}
IReadOnlyList<StatusFilterItem> GetStatusFilterItems(IEnumerable<EnvelopeDto>? source)
{
return (source ?? Enumerable.Empty<EnvelopeDto>())
.Select(x => x.Status)
.Distinct()
.OrderBy(x => x)
.Select(x => new StatusFilterItem(x, GetStatusLabel(x)))
.ToList();
}
int GetSignedReceiverCount(EnvelopeDto envelope)
{
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
if (receivers.Count == 0)
return 0;
return receivers.Count(r => IsReceiverSigned(envelope, r));
}
bool IsReceiverSigned(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
{
var status = (EnvelopeStatus)envelope.Status;
if (status is EnvelopeStatus.EnvelopeCompletelySigned or EnvelopeStatus.MessageCompletionSent)
return true;
var histories = envelope.Histories?.Where(h => h.Status == EnvelopeStatus.DocumentSigned).ToList() ?? [];
if (histories.Count == 0)
return false;
if (histories.Any(h => h.Receiver?.Id == receiver.ReceiverId))
return true;
var receiverEmail = receiver.Receiver?.EmailAddress;
if (!string.IsNullOrWhiteSpace(receiverEmail) &&
histories.Any(h => string.Equals(h.UserReference, receiverEmail, StringComparison.OrdinalIgnoreCase)))
return true;
if (!string.IsNullOrWhiteSpace(receiver.Name) &&
histories.Any(h => string.Equals(h.UserReference, receiver.Name, StringComparison.OrdinalIgnoreCase)))
return true;
return false;
}
DateTime GetHistoryDate(HistoryDto history)
{
return history.ActionDate ?? history.AddedWhen;
}
string GetHistoryActor(HistoryDto history)
{
if (!string.IsNullOrWhiteSpace(history.UserReference))
return history.UserReference;
return L("System", "System");
}
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
async Task CopyAccessCodeAsync(string? accessCode)
{
if (string.IsNullOrWhiteSpace(accessCode))
return;
try
{
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", accessCode);
}
catch
{
await JSRuntime.InvokeVoidAsync("console.warn", "[Sender] Failed to copy access code to clipboard.");
}
}
void OnCustomizeElement(GridCustomizeElementEventArgs e)
{
// Future: Add custom row coloring based on status if needed
@@ -767,7 +926,7 @@
var user = EnvelopeAuthService.GetCurrentUser();
if (user?.Identity is null || !user.Identity.IsAuthenticated)
{
_previewErrorMessage = "Nicht authentifiziert. Bitte melden Sie sich erneut an.";
_previewErrorMessage = L("NotAuthenticatedPleaseLoginAgain", "Nicht authentifiziert. Bitte melden Sie sich erneut an.");
return;
}
@@ -778,12 +937,16 @@
}
catch (DigitalData.Core.Exceptions.NotFoundException)
{
_previewErrorMessage = $"Das Dokument für Umschlag #{envelope.Id} wurde nicht gefunden oder Sie haben keinen Zugriff darauf.";
_previewErrorMessage = string.Format(
L("Sender_PreviewDocumentNotFound", "Das Dokument für Umschlag #{0} wurde nicht gefunden oder Sie haben keinen Zugriff darauf."),
envelope.Id);
Logger.LogWarning("Preview: document not found for envelope {EnvelopeId}", envelope.Id);
}
catch (Exception ex)
{
_previewErrorMessage = $"Fehler beim Laden des Dokuments: {ex.Message}";
_previewErrorMessage = string.Format(
L("ErrorLoadingDocument_WithReason", "Fehler beim Laden des Dokuments: {0}"),
ex.Message);
Logger.LogError(ex, "Preview: failed to load document for envelope {EnvelopeId}", envelope.Id);
}
finally
@@ -814,21 +977,21 @@
// UserPreferencesService.GetAsync() uses the 1h in-memory cache → 0 HTTP on restore.
// UserPreferencesService.SaveGridLayoutAsync() merges the single key → 1 PUT.
async Task OnActiveGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
async Task OnGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
{
// Use cached preferences only — avoid HTTP during grid initialization.
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(GridKeyActive, out var json))
if (prefs is not null && prefs.GridLayouts.TryGetValue(CurrentGridKey, out var json))
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
async Task OnActiveGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
async Task OnGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
try
{
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(GridKeyActive, json);
await UserPreferencesService.SaveGridLayoutAsync(CurrentGridKey, json);
}
catch (Exception ex)
{
@@ -836,16 +999,5 @@
}
}
async Task OnCompletedGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
{
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(GridKeyCompleted, out var json))
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
async Task OnCompletedGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(GridKeyCompleted, json);
}
private sealed record StatusFilterItem(EnvelopeStatus Value, string Text);
}

View File

@@ -1,27 +1,28 @@
@page "/Error"
@using System.Diagnostics
@using EnvelopeGenerator.Application.Resources
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<Resource> UiLocalizer
<PageTitle>Error</PageTitle>
<PageTitle>@L("Error", "Error")</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<h1 class="text-danger">@L("Error", "Error").</h1>
<h2 class="text-danger">@L("ErrorPage_ProcessingRequest", "An error occurred while processing your request.")</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
<strong>@L("ErrorPage_RequestId", "Request ID"):</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<h3>@L("ErrorPage_DevelopmentMode", "Development Mode")</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
@L("ErrorPage_DevelopmentMode_Info", "Swapping to Development environment will display more detailed information about the error that occurred.")
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
<strong>@L("ErrorPage_DevelopmentMode_Warning", "The Development environment shouldn't be enabled for deployed applications.")</strong>
@L("ErrorPage_DevelopmentMode_Detail", "It can result in displaying sensitive information from exceptions to end users. For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development and restarting the app.")
</p>
@code{
@@ -33,4 +34,10 @@
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -5,7 +5,9 @@
@using EnvelopeGenerator.Server.Client.Models
@using EnvelopeGenerator.Server.Client.Services
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
@using EnvelopeGenerator.Application.Resources
@using Microsoft.Extensions.Caching.Memory
@using Microsoft.Extensions.Localization
@using System.Security.Claims
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@@ -15,6 +17,7 @@
@inject AppVersionService AppVersion
@inject IMemoryCache MemoryCache
@inject ILogger<ReceiverSignedPage> Logger
@inject IStringLocalizer<Resource> UiLocalizer
@implements IDisposable
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
@@ -29,18 +32,18 @@
@if (_envelopeReceiver is not null)
{
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
@(_envelopeReceiver.Envelope?.Title ?? "Dokument")
@(_envelopeReceiver.Envelope?.Title ?? L("Document", "Dokument"))
</div>
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName))
{
<span style="font-size: 0.7rem; color: #6b7280; white-space: nowrap;">
Von <span style="font-weight: 500; color: #374151;">@_envelopeReceiver.Envelope.User.FullName</span>
@L("From", "Von") <span style="font-weight: 500; color: #374151;">@_envelopeReceiver.Envelope.User.FullName</span>
</span>
}
}
else
{
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937;">Signiertes Dokument</div>
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937;">@L("SignedDocument", "Signiertes Dokument")</div>
}
</div>
@@ -49,12 +52,12 @@
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change pdf-toolbar__btn--signature-change-active"
@onclick="OpenSubmitConfirmPopup"
disabled="@_isLoggingOut"
title="Abschließen"
title="@L("Complete", "Abschließen")"
style="flex-shrink: 0;">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
</svg>
<span class="pdf-toolbar__btn-text">Abschließen</span>
<span class="pdf-toolbar__btn-text">@L("Complete", "Abschließen")</span>
</button>
</div>
</div>
@@ -67,9 +70,9 @@
<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>
<span class="visually-hidden">@L("Loading", "Lädt...")</span>
</div>
<p class="text-white fw-semibold">Dokument wird geladen...</p>
<p class="text-white fw-semibold">@L("DocumentLoading", "Dokument wird geladen...")</p>
</div>
</div>
}
@@ -83,7 +86,7 @@
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
</svg>
<div>
<h5 class="mb-2">Fehler</h5>
<h5 class="mb-2">@L("Error", "Fehler")</h5>
<p class="mb-0">@_errorMessage</p>
</div>
</div>
@@ -99,7 +102,7 @@
@* Submit confirmation popup *@
<DxPopup @bind-Visible="_submitConfirmVisible"
HeaderText="Unterschrift bestätigen"
HeaderText="@L("ConfirmSignature", "Unterschrift bestätigen")"
Width="440px"
MaxWidth="95vw"
ShowFooter="true"
@@ -115,10 +118,10 @@
</div>
<div>
<p style="margin: 0 0 0.4rem; font-weight: 600; color: #1f2937; font-size: 0.95rem;">
Möchten Sie das Dokument verbindlich unterschreiben?
@L("ReceiverSigned_ConfirmQuestion", "Möchten Sie das Dokument verbindlich unterschreiben?")
</p>
<p style="margin: 0; color: #6b7280; font-size: 0.85rem; line-height: 1.5;">
Diese Aktion kann nicht rückgängig gemacht werden. Mit der Bestätigung erklären Sie, das oben angezeigte Dokument elektronisch unterzeichnet zu haben. Das unterzeichnete Dokument wird anschließend an alle beteiligten Parteien übermittelt.
@L("ReceiverSigned_ConfirmBody", "Diese Aktion kann nicht rückgängig gemacht werden. Mit der Bestätigung erklären Sie, das oben angezeigte Dokument elektronisch unterzeichnet zu haben. Das unterzeichnete Dokument wird anschließend an alle beteiligten Parteien übermittelt.")
</p>
</div>
</div>
@@ -128,7 +131,7 @@
<button class="btn btn-outline-secondary"
@onclick="() => _submitConfirmVisible = false"
style="border-radius: 6px; padding: 0.5rem 1.25rem; font-weight: 500;">
Abbrechen
@L("Cancel", "Abbrechen")
</button>
<button class="btn btn-primary"
@onclick="SubmitAndLogoutAsync"
@@ -138,7 +141,7 @@
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
Abschließen
@L("Complete", "Abschließen")
</button>
</div>
</FooterContentTemplate>
@@ -178,7 +181,7 @@
{
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
_errorMessage = "Envelope-Schlüssel fehlt.";
_errorMessage = L("ReceiverSigned_EnvelopeKeyMissing", "Envelope-Schlüssel fehlt.");
_isLoading = false;
return;
}
@@ -215,7 +218,7 @@
var pdfBytes = await PageDataService.GetDocumentOfReceiverAsync(_receiverUser);
if (pdfBytes is not { Length: > 0 })
{
_errorMessage = "Dokument konnte nicht geladen werden.";
_errorMessage = L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden.");
_isLoading = false;
return;
}
@@ -244,7 +247,7 @@
}
catch (Exception ex)
{
_errorMessage = $"Fehler: {ex.Message}";
_errorMessage = string.Format(L("ErrorWithReason", "Fehler: {0}"), ex.Message);
Logger.LogError(ex, "Error loading signed page for {EnvelopeKey}", EnvelopeKey);
}
@@ -393,4 +396,10 @@
if (comma < 0) return null;
return Convert.FromBase64String(dataUrl[(comma + 1)..]);
}
string L(string key, string fallback)
{
var value = UiLocalizer[key];
return value.ResourceNotFound ? fallback : value.Value;
}
}

View File

@@ -27,6 +27,8 @@ namespace EnvelopeGenerator.Server.Controllers;
[Authorize]
public class EnvelopeController : ControllerBase
{
public record DeleteEnvelopeRequest(string Reason);
private readonly ILogger<EnvelopeController> _logger;
private readonly IMediator _mediator;
@@ -93,7 +95,6 @@ public class EnvelopeController : ControllerBase
/// </summary>
/// <param name="command"></param>
/// <returns></returns>
[NonAction]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpPost]
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
@@ -108,4 +109,18 @@ public class EnvelopeController : ControllerBase
else
return Ok(res);
}
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpDelete("{id:int}")]
public async Task<IActionResult> DeleteAsync([FromRoute] int id, [FromBody] DeleteEnvelopeRequest request)
{
var result = await _mediator.Send(new DeleteEnvelopeCommand
{
EnvelopeId = id,
Reason = request?.Reason ?? string.Empty
}.WithAuth(User.GetId()));
return Ok(result);
}
}

View File

@@ -4,7 +4,6 @@ using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Localization;
using EnvelopeGenerator.Application.Resources;
namespace EnvelopeGenerator.Server.Controllers;
@@ -20,7 +19,6 @@ public class LocalizationController : ControllerBase
private readonly ILogger<LocalizationController> _logger;
private readonly IStringLocalizer<Resource> _mLocalizer;
private readonly IStringLocalizer<Resource> _localizer;
private readonly IMemoryCache _cache;
/// <summary>
@@ -32,14 +30,12 @@ public class LocalizationController : ControllerBase
/// <param name="_modelLocalizer">Lokalisierungsdienst für Modelle.</param>
public LocalizationController(
ILogger<LocalizationController> logger,
IStringLocalizer<Resource> localizer,
IMemoryCache memoryCache,
IStringLocalizer<Resource> _modelLocalizer)
IStringLocalizer<Resource> modelLocalizer)
{
_logger = logger;
_localizer = localizer;
_cache = memoryCache;
_mLocalizer = _modelLocalizer;
_mLocalizer = modelLocalizer;
}
/// <summary>
@@ -92,10 +88,10 @@ public class LocalizationController : ControllerBase
var cookieValue = Request.Cookies[CookieRequestCultureProvider.DefaultCookieName];
if (string.IsNullOrEmpty(cookieValue))
return null;
return "de-DE";
var culture = CookieRequestCultureProvider.ParseCookieValue(cookieValue)?.Cultures[0];
return culture?.Value ?? null;
return culture?.Value ?? "de-DE";
}
set
{

View File

@@ -69,6 +69,11 @@ public class ExceptionHandlingMiddleware
message = notFoundEx.Message;
break;
case ForbiddenException forbiddenEx:
context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
message = forbiddenEx.Message;
break;
default:
logger.LogError(exception, "Unhandled exception occurred.");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

View File

@@ -337,6 +337,7 @@ try
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.LocalizationService>();
// Business Services (Server specific)
builder.Services.AddScoped<DocumentService>();
@@ -410,16 +411,22 @@ try
// Set CORS policy
app.UseCors("AllowSpecificOriginsPolicy");
// Localization
string[] supportedCultureNames = ["de-DE", "en-US"];
IList<CultureInfo> list = [.. supportedCultureNames.Select(cn => new CultureInfo(cn))];
var cultureInfo = list.FirstOrDefault() ?? throw new InvalidOperationException("There is no supported culture.");
// Localization (default German; can be switched via cookie)
var defaultCulture = new CultureInfo("de-DE");
IList<CultureInfo> list =
[
defaultCulture,
new CultureInfo("en-US"),
new CultureInfo("fr-FR")
];
var requestLocalizationOptions = new RequestLocalizationOptions
{
DefaultRequestCulture = new RequestCulture(defaultCulture),
SupportedCultures = list,
SupportedUICultures = list
SupportedUICultures = list,
RequestCultureProviders = [new CookieRequestCultureProvider()]
};
requestLocalizationOptions.RequestCultureProviders.Add(new QueryStringRequestCultureProvider());
requestLocalizationOptions.ApplyCurrentCultureToResponseHeaders = true;
app.UseRequestLocalization(requestLocalizationOptions);
app.UseHttpsRedirection();

View File

@@ -49,6 +49,14 @@ public class EnvelopeReceiverPageDataService(
: throw new NotFoundException($"Document for envelope {envelopeId} not found.");
}
/// <summary>
/// Loads the full document DTO (including receiver elements) for the authenticated sender.
/// </summary>
public async Task<DocumentDto?> GetDocumentDetailsOfSenderAsync(ClaimsPrincipal user, int envelopeId, CancellationToken cancellationToken = default)
{
return await mediator.Send(new ReadDocumentQuery(EnvelopeId: envelopeId, UserId: user.SenderId()), cancellationToken);
}
/// <summary>
/// Loads the current receiver's signature placeholders.
/// </summary>

View File

@@ -186,7 +186,8 @@
},
"DbTriggerParams": {
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"EnvelopeHistory": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ],
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
"Receiver": [],

View File

@@ -404,6 +404,89 @@ article {
opacity: 0.4;
}
.receiver-footer__right {
display: inline-flex;
align-items: center;
gap: 12px;
}
.sf-lang-combo {
position: relative;
display: inline-flex;
align-items: center;
}
.sf-lang-trigger {
width: 28px;
height: 20px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 5px;
background: rgba(255, 255, 255, 0.08);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.sf-lang-trigger:hover {
background: rgba(255, 255, 255, 0.16);
}
.sf-lang-menu {
position: absolute;
right: 0;
bottom: 28px;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 64px;
padding: 6px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(29, 38, 55, 0.98);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
z-index: 110;
}
.sf-lang-backdrop {
position: fixed;
inset: 0;
border: 0;
background: transparent;
padding: 0;
margin: 0;
cursor: default;
z-index: 105;
}
.sf-lang-option {
display: inline-flex;
align-items: center;
gap: 6px;
border: 0;
border-radius: 6px;
background: transparent;
color: rgba(255, 255, 255, 0.88);
font-size: 0.68rem;
font-weight: 600;
padding: 4px 6px;
cursor: pointer;
}
.sf-lang-option:hover,
.sf-lang-option--active {
background: rgba(255, 255, 255, 0.14);
}
.sf-lang-trigger .fi,
.sf-lang-option .fi {
width: 16px;
height: 12px;
border-radius: 2px;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25) inset;
}
/* ── Footer: Theme Slider ────────────────────────────────────────────────── */
/* 🌙 blazing-dark ──◇──·──◇── purple ☀️ */
/* Left group: copyright | datenschutz → Right group: slider */

View File

@@ -542,6 +542,40 @@ body.resizing {
color: white;
}
/* Draft save button for sender editor */
.pdf-toolbar__btn--draft {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: auto;
padding: 0.3rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
color: #0f4c81;
border: 1px solid rgba(14, 165, 233, 0.34);
background: linear-gradient(135deg, rgba(14, 165, 233, 0.12) 0%, rgba(2, 132, 199, 0.1) 100%);
}
.pdf-toolbar__btn--draft:hover:not(:disabled) {
color: #ffffff;
border-color: transparent;
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);
box-shadow: 0 4px 12px rgba(2, 132, 199, 0.28);
}
.pdf-toolbar__btn--draft:disabled {
color: #64748b;
}
.pdf-toolbar__btn--draft svg {
flex-shrink: 0;
transition: color 0.2s ease;
}
.pdf-toolbar__btn--draft .pdf-toolbar__btn-text {
color: inherit;
}
/* Signature Change Button */
.pdf-toolbar__btn--signature-change {
display: flex;

View File

@@ -238,6 +238,7 @@
overflow: auto;
}
.sender-grid-container {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(20px);
@@ -381,6 +382,109 @@
color: #c62828;
}
.sender-access-code {
min-width: 120px;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sender-access-code__label {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: #64748b;
}
.sender-access-code__row {
display: flex;
align-items: center;
gap: 0;
}
.sender-access-code__value {
width: 72px;
height: 28px;
border: 1px solid rgba(126, 34, 206, 0.22);
border-right: none;
border-radius: 6px 0 0 6px;
padding: 0 0.55rem;
font-size: 0.8rem;
font-weight: 600;
color: #1e293b;
background: #f8fafc;
}
.sender-access-code__value:focus {
outline: 2px solid rgba(126, 34, 206, 0.18);
border-color: rgba(126, 34, 206, 0.45);
}
.sender-access-code__copy {
height: 28px;
border: 1px solid rgba(126, 34, 206, 0.3);
border-radius: 0 6px 6px 0;
width: 30px;
min-width: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: #4c1d95;
background: rgba(126, 34, 206, 0.08);
cursor: pointer;
transition: all 0.2s ease;
}
.sender-access-code__copy:hover:not(:disabled) {
border-color: rgba(126, 34, 206, 0.45);
background: rgba(126, 34, 206, 0.16);
}
.sender-access-code__copy:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.sender-history-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sender-history-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
background: white;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.sender-history-item__left {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.sender-history-item__actor {
color: #4b5563;
font-size: 0.8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sender-history-item__right {
color: #6b7280;
font-size: 0.8rem;
white-space: nowrap;
}
@media (max-width: 1320px) {
.sender-action-bar__inner {
flex-wrap: wrap;
@@ -482,6 +586,10 @@
padding: 0.875rem 1rem;
font-size: 0.813rem;
}
.sender-access-code {
min-width: 100%;
}
}
@media (max-width: 560px) {
@@ -652,6 +760,7 @@
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06);
}
[data-sf-theme="blazing-dark"] .sender-grid-container::before {
background: linear-gradient(90deg, #6366f1 0%, #4338ca 100%);
}

View File

@@ -0,0 +1,109 @@
using DigitalData.Core.Exceptions;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
using EnvelopeGenerator.Application.Envelopes.Commands;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace EnvelopeGenerator.Tests.Application;
[TestFixture]
public class DeleteEnvelopeCommandTests : TestBase
{
protected override void ConfigureServices(IServiceCollection services)
{
}
[Test]
public async Task DeleteEnvelope_DraftEnvelope_ShouldSetDeleted_CreateHistory_AndQueueDeletionEmail()
{
CancellationToken cancel = default;
var reason = "Invalid receiver data";
var envelope = this.CreateEnvelope(User.Id);
envelope.Status = EnvelopeStatus.EnvelopeCreated;
envelope = await GetRepository<Envelope>().CreateAsync(envelope, cancel);
var createReceiverCmd = this.CreateReceiverCommand();
(var receiver, _) = await Mediator.Send(createReceiverCmd, cancel);
var envReceiver = this.CreateEnvelopeReceiver(envelope.Id, receiver.Id);
await GetRepository<EnvelopeReceiver>().CreateAsync(envReceiver, cancel);
var result = await Mediator.Send(new DeleteEnvelopeCommand
{
EnvelopeId = envelope.Id,
Reason = reason
}.WithAuth(User.Id), cancel);
Assert.That(result.EnvelopeId, Is.EqualTo(envelope.Id));
Assert.That(result.AppliedStatus, Is.EqualTo(EnvelopeStatus.EnvelopeDeleted));
Assert.That(result.IsNoOp, Is.False);
var histories = await GetRepository<History>().Query
.Where(h => h.EnvelopeId == envelope.Id && h.Status == EnvelopeStatus.EnvelopeDeleted)
.ToListAsync(cancel);
Assert.That(histories, Has.Count.EqualTo(1));
Assert.That(histories[0].Comment, Is.EqualTo(reason));
var emails = await GetRepository<EmailOut>().Query
.Where(e => e.WfId == (int)EnvelopeStatus.MessageDeletionSent && e.ReferenceId == receiver.Id)
.ToListAsync(cancel);
Assert.That(emails, Has.Count.EqualTo(1));
Assert.That(emails[0].EmailAddress, Is.EqualTo(receiver.EmailAddress));
}
[Test]
public async Task DeleteEnvelope_SentEnvelope_ShouldSetWithdrawn()
{
CancellationToken cancel = default;
var envelope = this.CreateEnvelope(User.Id);
envelope.Status = EnvelopeStatus.EnvelopeQueued;
envelope = await GetRepository<Envelope>().CreateAsync(envelope, cancel);
var result = await Mediator.Send(new DeleteEnvelopeCommand
{
EnvelopeId = envelope.Id,
Reason = "Sender requested cancellation"
}.WithAuth(User.Id), cancel);
Assert.That(result.AppliedStatus, Is.EqualTo(EnvelopeStatus.EnvelopeWithdrawn));
var histories = await GetRepository<History>().Query
.Where(h => h.EnvelopeId == envelope.Id && h.Status == EnvelopeStatus.EnvelopeWithdrawn)
.ToListAsync(cancel);
Assert.That(histories, Has.Count.EqualTo(1));
}
[Test]
public void DeleteEnvelope_MissingReason_ShouldThrowBadRequest()
{
Assert.ThrowsAsync<BadRequestException>(async () =>
await Mediator.Send(new DeleteEnvelopeCommand
{
EnvelopeId = 123,
Reason = " "
}.WithAuth(User.Id)));
}
[Test]
public async Task DeleteEnvelope_WrongOwner_ShouldThrowForbidden()
{
CancellationToken cancel = default;
var envelope = this.CreateEnvelope(User.Id);
envelope = await GetRepository<Envelope>().CreateAsync(envelope, cancel);
var otherUserCmd = this.CreateUserCommand();
var otherUser = await GetRepository<DigitalData.UserManager.Domain.Entities.User>().CreateAsync(otherUserCmd, cancel);
Assert.ThrowsAsync<ForbiddenException>(async () =>
await Mediator.Send(new DeleteEnvelopeCommand
{
EnvelopeId = envelope.Id,
Reason = "Not allowed"
}.WithAuth(otherUser.Id), cancel));
}
}

View File

@@ -52,7 +52,7 @@ public class Fake
services.AddEnvelopeGeneratorInfrastructureServices(opt =>
{
opt.AddDbContext(dbCtxOpt => dbCtxOpt.UseInMemoryDatabase("EnvelopeGeneratorTestDb"));
opt.AddDbContext(dbCtxOpt => dbCtxOpt.UseInMemoryDatabase($"EnvelopeGeneratorTestDb_{Guid.NewGuid():N}"));
opt.AddDbTriggerParams(context.Configuration);

View File

@@ -38,8 +38,11 @@ public abstract class TestBase : Faker
// Add seed email templates
foreach (var temp in SeedEmailTemplates)
{
temp.LangCode ??= "de";
await repo.CreateAsync(temp);
}
}
[TearDown]
public virtual void TearDown()

View File

@@ -7,6 +7,7 @@
"DbTriggerParams": {
"Envelope": [ "TBSIG_ENVELOPE_AFT_INS" ],
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
"DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ],
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
"Receiver": [],

View File

@@ -36,8 +36,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.PdfEditor
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Tests", "EnvelopeGenerator.Tests\EnvelopeGenerator.Tests.csproj", "{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.API", "EnvelopeGenerator.API\EnvelopeGenerator.API.csproj", "{EC768913-6270-14F4-1DD3-69C87A659462}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.DependencyInjection", "EnvelopeGenerator.DependencyInjection\EnvelopeGenerator.DependencyInjection.csproj", "{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
@@ -95,10 +93,6 @@ Global
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Release|Any CPU.Build.0 = Release|Any CPU
{EC768913-6270-14F4-1DD3-69C87A659462}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EC768913-6270-14F4-1DD3-69C87A659462}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.Build.0 = Release|Any CPU
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -136,7 +130,6 @@ Global
{6D56C01F-D6CB-4D8A-BD3D-4FD34326998C} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{211619F5-AE25-4BA5-A552-BACAFE0632D3} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8} = {0CBC2432-A561-4440-89BC-671B66A24146}
{EC768913-6270-14F4-1DD3-69C87A659462} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{BF1700D5-592E-4FFA-84E8-5480E289A1F0} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{4E6C54DA-576D-0955-2564-9EC890BB8279} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}

View File

@@ -1,7 +1,7 @@
# EnvelopeGenerator.Form — VB.NET Desktop Application Context
## Purpose
**Legacy Windows Forms application** for envelope creation, management, and signature field placement. Built with **DevExpress components** and **GdPicture14** for PDF manipulation. This application is being **migrated to EnvelopeGenerator.Server + API** architecture.
**Legacy Windows Forms application** for envelope creation, management, and signature field placement. Built with **DevExpress components** and **GdPicture14** for PDF manipulation. This application is being **migrated to `EnvelopeGenerator.Server` architecture** (merged Blazor + Web API host).
**Primary Libraries:** DevExpress XtraGrid/XtraEditors, GdPicture14, VB.NET (.NET Framework 4.6.2)
@@ -368,7 +368,7 @@ End Sub
---
## Migration to EnvelopeGenerator.Server + API
## Migration to EnvelopeGenerator.Server
### Mapping Table

View File

@@ -0,0 +1,281 @@
# Sender Save/Edit Migration Plan (Legacy Form -> Blazor Server)
## Objective
Migrate the legacy `frmEnvelopeEditor` save/edit workflow into current architecture so that:
1. Sender can **save incomplete envelope as draft** (`EnvelopeSaved = 1002`) from `EnvelopeSenderEditorPage.razor`.
2. Sender can **resume editing** saved/created envelopes from `EnvelopeSenderPage.razor` via **Bearbeiten** and continue where they left off.
This plan introduces a distinct draft flow and a same-ID send-from-draft flow, aligned with legacy Form app semantics.
---
## Scope
### In scope
- Add separate **Save** and **Send** actions in sender editor.
- Persist draft data server-side with partial/incomplete payload support.
- Re-open existing draft from dashboard and restore editor state.
- Preserve existing delete/withdraw logic and status model.
- Add endpoints and application commands required for progressive sender workflow.
### Out of scope (this ticket)
- Full redesign of editor UX.
- Multi-document support parity with `frmOrderFiles`.
- New DB schema changes (unless separately approved).
---
## Current Gap Analysis
## Current behavior (problem)
- `EnvelopeSenderEditorPage.razor` has only one save path (`SaveAsync`) that behaves like "send":
- requires title + PDF + receivers + fields.
- calls `EnvelopeReceiverService.CreateAsync(...)` -> `/api/EnvelopeReceiver` monolithic create/send pipeline.
- `EnvelopeSenderPage.razor` `EditEnvelope()` is TODO and does not navigate to editable persisted draft data.
## Legacy behavior (target)
- `frmEnvelopeEditor` has both:
- **Save**: persists envelope without full validation/sending.
- **Send**: validates required business data then sends invitations.
- Dashboard "Bearbeiten" opens selected unsent envelope and restores work state.
---
## Architectural Decisions
1. **Split command model by intent**
- Draft persistence must not reuse monolithic `/api/EnvelopeReceiver` create+send endpoint.
- Introduce dedicated sender workflow commands/endpoints for draft lifecycle.
2. **Progressive persistence**
- Persist envelope metadata, document, receivers, and fields incrementally.
- Draft save accepts partial data and updates only provided segments.
3. **Status ownership**
- Draft operations set status to `EnvelopeSaved` (1002).
- Send operation transitions to queued/sent path (existing 1003+ behavior).
4. **Editor route strategy**
- Keep `/sender/editor` for new envelope.
- Support edit mode via query: `/sender/editor?envelopeId={id}` (or route variant in follow-up if preferred).
5. **No DB schema migration by default**
- Use existing tables (`TBSIG_ENVELOPE`, `TBSIG_DOCUMENT`, `TBSIG_ENVELOPE_RECEIVER`, `TBSIG_DOC_RECEIVER_ELEMENT`).
- If a field is missing for a requirement, handle in application logic first and escalate only if unavoidable.
---
## Target Workflow
## A) New envelope (draft first)
1. User opens `/sender/editor`.
2. User enters any subset of data.
3. User clicks **Save**.
4. System creates or updates draft envelope and sets status `EnvelopeSaved`.
5. User gets confirmation and remains in editor (or optional return to dashboard).
## B) New envelope (direct send)
1. User fills required data.
2. User clicks **Send**.
3. System validates complete data and sends invitations.
4. Status transitions to queued/sent flow.
## C) Edit existing draft/created
1. User selects row in `/sender` with `EnvelopeCreated` or `EnvelopeSaved`.
2. User clicks **Bearbeiten**.
3. App navigates to `/sender/editor?envelopeId={id}`.
4. Editor loads persisted data (metadata, PDF, receivers, fields).
5. User can save again (draft) or send.
---
## Detailed Implementation Plan
## Phase 1 - Contracts and Application Use Cases
### 1.1 Add sender draft commands (Application)
- `CreateEnvelopeCommand` (create/update draft with partial data)
- `ReadEnvelopeDraftQuery` (load full editable projection by envelope id)
- `CreateEnvelopeCommand` (same request, `Send=true` for final send)
Each command should enforce sender ownership (`UserId`) and return structured result DTOs.
### 1.2 Validation policy split
- **Draft Save validation (minimal):**
- title optional at first save? choose one policy and keep consistent.
- no requirement for receivers/fields/document.
- **Send validation (strict):**
- title required
- document required
- at least one receiver
- required signature field mapping per receiver (business rule)
### 1.3 History policy
- On draft save/update: optional lightweight history entry (`EnvelopeSaved`) if legacy parity requires it.
- On send: keep current history/send pipeline behavior.
---
## Phase 2 - Server Endpoints
Implement in `EnvelopeGenerator.Server` only.
### 2.1 Draft endpoints
- `POST /api/Envelope`
- Create/update draft envelope (partial payload allowed), and send when `Send=true`.
- `GET /api/Envelope/{id}/draft`
- Read full editable aggregate for editor hydration.
### 2.2 Authorization + errors
- 400 validation issues
- 403 sender mismatch
- 404 envelope not found
- 409 invalid state transition (optional, if needed)
---
## Phase 3 - Sender Editor UI (`EnvelopeSenderEditorPage.razor`)
### 3.1 Action buttons
- Keep current action as **Send** (rename from "Speichern" to "Senden").
- Add separate **Save** button next to Send.
### 3.2 Editor mode state
- Add `_editingEnvelopeId` from query param `envelopeId`.
- `null` => create mode, value => edit mode.
### 3.3 Load existing draft
- On parameter set / first render in edit mode:
- call draft read endpoint.
- hydrate title, message, PDF bytes, receiver list, field list.
- map DB inches <-> PDF points correctly:
- DB inches -> UI points: `pt = inches * 72`
- UI points -> DB inches: `inches = pt / 72`
### 3.4 Save behavior (new)
- Save button calls `SaveDraftAsync()`:
- no strict send validations.
- create draft if no id yet, otherwise update.
- force status `EnvelopeSaved`.
- keep user in editor and show non-blocking success feedback.
### 3.5 Send behavior (existing path replacement)
- Send button calls `SendAsync()`:
- strict validation.
- if no draft id: create draft first then send, or direct send command.
- if edit mode: send current draft id.
### 3.6 Session cache coexistence
- Keep `esid` memory cache for transient UX resilience.
- In edit mode, server data is source of truth; cache is fallback only.
---
## Phase 4 - Dashboard Edit Resume (`EnvelopeSenderPage.razor`)
### 4.1 Edit button enablement
- Enable **Bearbeiten** for statuses:
- `EnvelopeCreated`
- `EnvelopeSaved`
- Keep disabled for sent/queued/completed/deleted/withdrawn/rejected.
### 4.2 Navigation
- Implement `EditEnvelope()`:
- `Navigation.NavigateTo($"/sender/editor?envelopeId={_selectedEnvelope.Id}")`.
### 4.3 Optional UX
- Double-click on editable rows can open editor instead of preview (or keep current preview behavior per product decision).
---
## Phase 5 - Data Mapping Rules
1. **Document**
- Persist original PDF bytes (no visual placeholder burn-in).
2. **Receivers**
- Draft can contain receivers with/without fields.
3. **Fields**
- Store in DB inches with fixed signature size semantics.
4. **Ownership**
- Sender can read/update/send only own envelopes.
---
## Suggested DTO Shape (Draft)
Use one aggregate DTO for editor hydration/save/update:
- `EnvelopeDraftDto`
- `EnvelopeId`
- `Status`
- `Title`
- `Message`
- `DocumentBase64` (or document id + fetch endpoint)
- `Receivers[]`
- `ReceiverId?`
- `Name`
- `Email`
- `Phone`
- `Fields[] { XInches, YInches, Page }`
---
## Migration Strategy
## Step A (safe vertical slice)
- Add read/edit route query handling and dashboard navigation first.
- Hydrate editor from existing envelope data where possible.
## Step B (draft save)
- Implement draft create/update endpoints + app commands.
- Wire Save button.
## Step C (send from draft)
- Move Send to explicit command/endpoint.
- Ensure existing email/history behavior remains intact.
## Step D (hardening)
- Add tests + manual QA checklist.
---
## Testing Plan
## Automated (Application)
- Save draft with partial data -> status `EnvelopeSaved`.
- Edit draft updates existing records, no duplicate orphan data.
- Send draft with missing required data -> validation error.
- Send draft complete -> queued/sent flow success.
- Ownership checks -> 403 behavior.
## Manual (UI)
1. New editor -> save with only title.
2. Reopen from dashboard -> state restored.
3. Add PDF only -> save -> reopen -> PDF still present.
4. Add receivers/fields incrementally -> save repeatedly.
5. Final send -> status transitions and receiver notifications.
---
## Risks and Mitigations
1. **Monolithic endpoint coupling risk**
- Mitigation: draft/send dedicated endpoints.
2. **Data duplication on repeated save**
- Mitigation: idempotent upsert logic for receivers/fields.
3. **Coordinate regression**
- Mitigation: centralize inches/points conversion helpers.
4. **Trigger side effects/history duplication**
- Mitigation: verify DB-trigger interactions per status events.
---
## Definition of Done
- Editor has two distinct actions: **Save** and **Send**.
- Save allows incomplete payload and persists with `EnvelopeSaved` status.
- Dashboard **Bearbeiten** opens editor for `EnvelopeCreated`/`EnvelopeSaved` and restores state.
- Sender can continue from last saved point and eventually send.
- Server endpoints support save/edit/send draft workflow with same envelope id.
- No DB schema changes introduced without explicit approval.