Compare commits

...

28 Commits

Author SHA1 Message Date
ad9250c7b1 Merge branch 'master' of http://git.dd:3000/AppStd/EnvelopeGenerator
# Conflicts:
#	EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverSignedPage.razor
#	EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json
2026-09-28 13:53:40 +02:00
5cc47ca9c7 Add signed result page and update navigation flow
Introduced a new `/envelope/signed-result` page to confirm
successful document signing with localized messages and
a visually appealing UI. Updated `ReceiverSignedPage.razor`
to redirect users to the new page after signing.

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

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

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

These changes ensure a seamless and secure process for envelope
receivers to request access codes, enhancing the user experience.
2026-09-28 13:35:32 +02:00
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
46 changed files with 3079 additions and 456 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

@@ -60,6 +60,11 @@ public record EnvelopeReceiverDto
/// </summary>
public string? PrivateMessage { get; init; }
/// <summary>
///
/// </summary>
public string? AccessCode { get; init; }
/// <summary>
///
/// </summary>
@@ -83,4 +88,4 @@ public record EnvelopeReceiverDto
h.Receiver?.Id == ReceiverId &&
h.Status == EnvelopeStatus.DocumentSigned
) ?? false;
}
}

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>
@@ -55,4 +60,14 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
[JsonIgnore]
[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,9 +149,11 @@ 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>
</root>
<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>
</root>
<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>
</root>
<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

@@ -214,4 +214,4 @@ namespace EnvelopeGenerator.Infrastructure
#endif
}
}
}
}

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>
<ThemeControls />
<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

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

View File

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

@@ -33,6 +33,20 @@ public class AuthService(IHttpClientFactory httpClientFactory)
return response.StatusCode == HttpStatusCode.OK;
}
/// <summary>
/// Triggers access code e-mail request flow for the receiver login page.
/// Calls POST /api/Auth/envelope-receiver/{key}/request-access-code.
/// </summary>
public async Task<bool> RequestEnvelopeReceiverAccessCodeAsync(string envelopeKey, CancellationToken cancel = default)
{
using var http = CreateDefaultClient();
var response = await http.PostAsync(
$"/api/Auth/envelope-receiver/{Uri.EscapeDataString(envelopeKey)}/request-access-code",
null,
cancel);
return response.IsSuccessStatusCode;
}
/// <summary>
/// Submits the access code for the given envelope key.
/// Calls POST /api/Auth/envelope-receiver/{key} with multipart/form-data.

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,59 +157,73 @@
<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)
@if (_pdfLoaded && _signatureFields.Count > 0)
{
@* Clear all fields *@
@if (_signatureFields.Count > 0)
{
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
@onclick="ClearAllFieldsAsync"
title="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" />
</svg>
</button>
}
@* Save *@
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
@onclick="SaveAsync"
disabled="@_isSaving"
title="Speichern"
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)
{
<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="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
</svg>
}
Speichern
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
@onclick="ClearAllFieldsAsync"
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" />
</svg>
</button>
}
<button class="pdf-toolbar__btn pdf-toolbar__btn--draft"
@onclick="SaveDraftAsync"
disabled="@_isSaving"
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)
{
<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="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>
}
@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
@@ -410,19 +430,28 @@
<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-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
<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;">
@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">
@{
var envelope = cellContext.DataItem as EnvelopeDto;
if (envelope != null)
{
var statusInfo = GetStatusInfo(envelope.Status);
<div class="status-badge status-badge--@statusInfo.CssClass">
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
@statusInfo.Label
</div>
}
}
</CellDisplayTemplate>
</DxGridDataColumn>
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger" 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>
</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>
<h6 style="font-weight: 600; color: #374151; margin: 1rem 0 0.75rem;">@L("History", "Historie")</h6>
@{
var envelope = detailContext.DataItem as EnvelopeDto;
if (envelope?.EnvelopeReceivers?.Any() == true)
var historyEntries = envelope is null
? Enumerable.Empty<HistoryDto>()
: GetOrderedHistories(envelope);
if (historyEntries.Any())
{
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
@foreach (var receiver in envelope.EnvelopeReceivers)
<div class="sender-history-list">
@foreach (var history in historyEntries)
{
<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>
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>
<span class="sender-history-item__actor">@GetHistoryActor(history)</span>
</div>
<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
@@ -16,6 +18,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" />
@@ -30,18 +33,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>
@@ -50,12 +53,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>
@@ -68,9 +71,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>
}
@@ -84,7 +87,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>
@@ -100,7 +103,7 @@
@* Submit confirmation popup *@
<DxPopup @bind-Visible="_submitConfirmVisible"
HeaderText="Unterschrift bestätigen"
HeaderText="@L("ConfirmSignature", "Unterschrift bestätigen")"
Width="440px"
MaxWidth="95vw"
ShowFooter="true"
@@ -116,10 +119,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>
@@ -135,7 +138,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"
@@ -145,7 +148,7 @@
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
}
Abschließen
@L("Complete", "Abschließen")
</button>
</div>
</FooterContentTemplate>
@@ -201,7 +204,7 @@
await SignatureService.SubmitAsync(EnvelopeKey, payload);
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
Navigation.NavigateTo("/", forceLoad: true);
Navigation.NavigateTo("/envelope/signed-result", forceLoad: true);
}
catch (Exception ex)
{
@@ -216,7 +219,7 @@
{
if (string.IsNullOrWhiteSpace(EnvelopeKey))
{
_errorMessage = "Envelope-Schlüssel fehlt.";
_errorMessage = L("ReceiverSigned_EnvelopeKeyMissing", "Envelope-Schlüssel fehlt.");
_isLoading = false;
return;
}
@@ -253,7 +256,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;
}
@@ -282,7 +285,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);
}
@@ -431,4 +434,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

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

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
{
@@ -118,4 +114,4 @@ public class LocalizationController : ControllerBase
}
}
}
}
}

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

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

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

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);
@@ -308,4 +308,4 @@ public static class Extensions
.Select(_ => fake.CreateUserCommand())
.ToList();
#endregion
}
}

View File

@@ -38,7 +38,10 @@ public abstract class TestBase : Faker
// Add seed email templates
foreach (var temp in SeedEmailTemplates)
{
temp.LangCode ??= "de";
await repo.CreateAsync(temp);
}
}
[TearDown]
@@ -140,4 +143,4 @@ public abstract class TestBase : Faker
ChangedWhen = DateTime.Parse("2025-05-12 10:43:44.290")
}
};
}
}

View File

@@ -7,9 +7,10 @@
"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": [],
"EmailTemplate": [ "TBSIG_EMAIL_TEMPLATE_AFT_UPD" ]
}
}
}

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.