Compare commits
11 Commits
11c8886d6f
...
feat/serve
| Author | SHA1 | Date | |
|---|---|---|---|
| 380a5f4c0c | |||
| 0f53d0509e | |||
| 435be882c9 | |||
| 09257d7766 | |||
| 16fa4b460f | |||
| f4aae8c787 | |||
| ed1468194d | |||
| f9f94c900e | |||
| 18df6221c9 | |||
| b15f95755c | |||
| 4be9e38a9e |
40
AGENTS.md
40
AGENTS.md
@@ -50,19 +50,6 @@ This section explains every markdown file in the repo: what it contains, why it
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Ticket / Feature Plans (Active Work Items)
|
|
||||||
|
|
||||||
#### `EnvelopeGenerator.Server/SF-74-PLAN.md`
|
|
||||||
**What:** Implementation plan for SF-74 ticket — Theme selection, Dark Mode, Grid Layout Persistence, signFLOW logo, and customer logo area on the sender dashboard. Includes the original ticket requirements, component breakdown, and implementation steps.
|
|
||||||
**Why it exists:** Captures the agreed scope and implementation decisions for SF-74 so work can be resumed or reviewed without re-reading the ticket system.
|
|
||||||
**When you need it:**
|
|
||||||
- Resuming work on theme, dark mode, or grid persistence features
|
|
||||||
- Checking what was agreed for logo positioning and size
|
|
||||||
- Verifying which features of SF-74 are complete vs. pending
|
|
||||||
**Maintenance:** Mark items as complete as they are implemented. Archive when the ticket is closed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Deployment Docs
|
### Deployment Docs
|
||||||
|
|
||||||
#### `EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md`
|
#### `EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md`
|
||||||
@@ -76,33 +63,6 @@ This section explains every markdown file in the repo: what it contains, why it
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Fix Reports (Historical — Read-Only)
|
|
||||||
|
|
||||||
These files document completed bug fixes. They exist as audit trails and as references if a similar issue resurfaces. Do not modify them.
|
|
||||||
|
|
||||||
#### `fix-report-label-read-and-confirmed.md`
|
|
||||||
**What:** Documents the fix for an incorrect label in the Signature Certificate report (`Signierungszertifikat`). For "Read and Sign" envelope type (`EnvelopeTypeId = 2`), the history event showed "Document signed" instead of "Read and confirmed" / "Gelesen und bestätigt".
|
|
||||||
**Branch:** `fix/report-label-read-and-confirmed` — **COMPLETED**
|
|
||||||
**When you need it:** If a similar report label regression appears, or when adding new envelope type-specific label logic to the certificate report.
|
|
||||||
|
|
||||||
#### `fix-signature-field-formatting.md`
|
|
||||||
**What:** Documents a signature stamp layout fix triggered by WISAG customer feedback (email 3/7/2026). The finalized PDF's signature stamp had incorrect spacing: certificate hash too close to name, position text wrapping, no spacing before date. Fix was applied in the GdPicture14 PDF burning pipeline — not in the Blazor UI.
|
|
||||||
**When you need it:** If signature stamp formatting regresses, or when modifying the PDF stamping/burning pipeline in the API or infrastructure layer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Service Migration Docs
|
|
||||||
|
|
||||||
#### `EnvelopeGenerator.Service/MIGRATION_PLAN.md`
|
|
||||||
**What:** Migration plan (v3) for porting `EnvelopeGenerator.Service_legacy` (VB.NET Windows Service) to `EnvelopeGenerator.Service` (C# Worker Service, .NET Framework 4.6.2). Documents connection string handling, job scheduling (Quartz → IHostedService), and deep analysis of legacy code.
|
|
||||||
**Why it exists:** The Windows Service handles background jobs (email dispatch, envelope finalization). This plan ensures the port is complete and no job logic is silently dropped.
|
|
||||||
**When you need it:**
|
|
||||||
- Modifying background jobs (email sending, PDF finalization, access code dispatch)
|
|
||||||
- Debugging service startup or job execution issues
|
|
||||||
- Checking if a specific legacy VB.NET job has been ported
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Active Architecture (Post-Migration)
|
## Active Architecture (Post-Migration)
|
||||||
|
|
||||||
**Frontend:** Blazor Auto (Server+WASM hybrid)
|
**Frontend:** Blazor Auto (Server+WASM hybrid)
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
public static class AuthScheme
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Scheme name used for per-envelope receiver JWT authentication.
|
|
||||||
/// </summary>
|
|
||||||
public const string Receiver = "EnvelopeGenerator.API.ReceiverJWT";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Scheme name used for per-envelope sender JWT authentication.
|
|
||||||
/// </summary>
|
|
||||||
public const string Sender = "EnvelopeGenerator.API.SenderJWT";
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
using DigitalData.Core.Abstraction.Application.DTO;
|
|
||||||
using DigitalData.Core.Exceptions;
|
|
||||||
using EnvelopeGenerator.API.Extensions;
|
|
||||||
using EnvelopeGenerator.Application.Common.Dto;
|
|
||||||
using EnvelopeGenerator.Application.Common.Extensions;
|
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
|
||||||
using EnvelopeGenerator.Application.Common.Notifications.DocSigned;
|
|
||||||
using EnvelopeGenerator.Application.Common.Notifications.RemoveSignature;
|
|
||||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
|
||||||
using EnvelopeGenerator.Application.Histories.Queries;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Manages annotations and signature lifecycle for envelopes.
|
|
||||||
/// </summary>
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class AnnotationController : ControllerBase
|
|
||||||
{
|
|
||||||
[Obsolete("Use MediatR")]
|
|
||||||
private readonly IEnvelopeHistoryService _historyService;
|
|
||||||
|
|
||||||
[Obsolete("Use MediatR")]
|
|
||||||
private readonly IEnvelopeReceiverService _envelopeReceiverService;
|
|
||||||
|
|
||||||
private readonly IMediator _mediator;
|
|
||||||
|
|
||||||
private readonly ILogger<AnnotationController> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of <see cref="AnnotationController"/>.
|
|
||||||
/// </summary>
|
|
||||||
[Obsolete("Use MediatR")]
|
|
||||||
public AnnotationController(
|
|
||||||
ILogger<AnnotationController> logger,
|
|
||||||
IEnvelopeHistoryService envelopeHistoryService,
|
|
||||||
IEnvelopeReceiverService envelopeReceiverService,
|
|
||||||
IMediator mediator)
|
|
||||||
{
|
|
||||||
_historyService = envelopeHistoryService;
|
|
||||||
_envelopeReceiverService = envelopeReceiverService;
|
|
||||||
_mediator = mediator;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates or updates annotations for the authenticated envelope receiver.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="psPdfKitAnnotation">Annotation payload.</param>
|
|
||||||
/// <param name="cancel">Cancellation token.</param>
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
[HttpPost]
|
|
||||||
[Obsolete("PSPDF Kit will no longer be used.")]
|
|
||||||
public async Task<IActionResult> CreateOrUpdate([FromBody] PsPdfKitAnnotation? psPdfKitAnnotation = null, CancellationToken cancel = default)
|
|
||||||
{
|
|
||||||
var signature = User.ReceiverSignature();
|
|
||||||
var uuid = User.EnvelopeUuid();
|
|
||||||
|
|
||||||
var envelopeReceiver = await _mediator.ReadEnvelopeReceiverAsync(uuid, signature, cancel).ThrowIfNull(Exceptions.NotFound);
|
|
||||||
|
|
||||||
if (!envelopeReceiver.Envelope!.ReadOnly && psPdfKitAnnotation is null)
|
|
||||||
return BadRequest();
|
|
||||||
|
|
||||||
if (await _mediator.IsSignedAsync(uuid, signature, cancel))
|
|
||||||
return Problem(statusCode: StatusCodes.Status409Conflict);
|
|
||||||
else if (await _mediator.AnyHistoryAsync(uuid, new[] { EnvelopeStatus.EnvelopeRejected, EnvelopeStatus.DocumentRejected }, cancel))
|
|
||||||
return Problem(statusCode: StatusCodes.Status423Locked);
|
|
||||||
|
|
||||||
var envelopeReceiverDto = await _mediator.ReadEnvelopeReceiverAsync(uuid, signature, cancel);
|
|
||||||
var docSignedNotification = envelopeReceiverDto is not null
|
|
||||||
? new DocSignedNotification { EnvelopeReceiver = envelopeReceiverDto, PsPdfKitAnnotation = psPdfKitAnnotation }
|
|
||||||
: throw new NotFoundException("Envelope receiver is not found.");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _mediator.Publish(docSignedNotification, cancel);
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
await _mediator.Publish(new RemoveSignatureNotification()
|
|
||||||
{
|
|
||||||
EnvelopeId = docSignedNotification.EnvelopeReceiver.EnvelopeId,
|
|
||||||
ReceiverId = docSignedNotification.EnvelopeReceiver.ReceiverId
|
|
||||||
}, cancel);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
||||||
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Rejects the document for the current receiver.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="reason">Optional rejection reason.</param>
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
[HttpPost("reject")]
|
|
||||||
[Obsolete("Use MediatR")]
|
|
||||||
public async Task<IActionResult> Reject([FromBody] string? reason = null)
|
|
||||||
{
|
|
||||||
var signature = User.ReceiverSignature();
|
|
||||||
var uuid = User.EnvelopeUuid();
|
|
||||||
var mail = User.ReceiverMail();
|
|
||||||
|
|
||||||
var envRcvRes = await _envelopeReceiverService.ReadByUuidSignatureAsync(uuid: uuid, signature: signature);
|
|
||||||
|
|
||||||
if (envRcvRes.IsFailed)
|
|
||||||
{
|
|
||||||
_logger.LogNotice(envRcvRes.Notices);
|
|
||||||
return Unauthorized("you are not authorized");
|
|
||||||
}
|
|
||||||
|
|
||||||
var histRes = await _historyService.RecordAsync(envRcvRes.Data.EnvelopeId, userReference: mail, EnvelopeStatus.DocumentRejected, comment: reason);
|
|
||||||
if (histRes.IsSuccess)
|
|
||||||
{
|
|
||||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogEnvelopeError(uuid: uuid, signature: signature, message: "Unexpected error happened in api/envelope/reject");
|
|
||||||
_logger.LogNotice(histRes.Notices);
|
|
||||||
return StatusCode(500, histRes.Messages);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
using DigitalData.Auth.Claims;
|
|
||||||
using EnvelopeGenerator.API.Controllers.Interfaces;
|
|
||||||
using EnvelopeGenerator.API.Models;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Controller verantwortlich für die Benutzer-Authentifizierung, einschließlich Anmelden, Abmelden und Überprüfung des Authentifizierungsstatus.
|
|
||||||
/// </summary>
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[ApiController]
|
|
||||||
public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions, IAuthorizationService authService) : ControllerBase, IAuthController
|
|
||||||
{
|
|
||||||
private readonly AuthTokenKeys authTokenKeys = authTokenKeyOptions.Value;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
public IAuthorizationService AuthService { get; } = authService;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entfernt das Authentifizierungs-Cookie des Benutzers (AuthCookie)
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>
|
|
||||||
/// Gibt eine HTTP 200 oder 401.
|
|
||||||
/// </returns>
|
|
||||||
/// <remarks>
|
|
||||||
/// Sample request:
|
|
||||||
///
|
|
||||||
/// POST /api/auth/logout
|
|
||||||
///
|
|
||||||
/// </remarks>
|
|
||||||
/// <response code="200">Erfolgreich gelöscht, wenn der Benutzer ein berechtigtes Cookie hat.</response>
|
|
||||||
/// <response code="401">Wenn es kein zugelassenes Cookie gibt, wird „nicht zugelassen“ zurückgegeben.</response>
|
|
||||||
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
|
|
||||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
|
||||||
[HttpPost("logout")]
|
|
||||||
public async Task<IActionResult> Logout()
|
|
||||||
{
|
|
||||||
if (await this.IsUserInPolicyAsync(AuthPolicy.Sender))
|
|
||||||
Response.Cookies.Delete(authTokenKeys.Cookie);
|
|
||||||
else if (await this.IsUserInPolicyAsync(AuthPolicy.ReceiverOrReceiverTFA))
|
|
||||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
||||||
else
|
|
||||||
return Unauthorized();
|
|
||||||
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Prüft, ob der Benutzer ein autorisiertes Token hat.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Wenn ein autorisiertes Token vorhanden ist HTTP 200 asynchron 401</returns>
|
|
||||||
/// <remarks>
|
|
||||||
/// Sample request:
|
|
||||||
///
|
|
||||||
/// GET /api/auth
|
|
||||||
///
|
|
||||||
/// </remarks>
|
|
||||||
/// <response code="200">Wenn es einen autorisierten Cookie gibt.</response>
|
|
||||||
/// <response code="401">Wenn kein Cookie vorhanden ist oder nicht autorisierte.</response>
|
|
||||||
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
|
|
||||||
[HttpGet("check")]
|
|
||||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
|
||||||
public IActionResult Check(string? role = null)
|
|
||||||
=> role is not null && !User.IsInRole(role)
|
|
||||||
? Unauthorized()
|
|
||||||
: Ok();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks whether the caller holds a valid per-envelope receiver token for the given envelope key.
|
|
||||||
/// The request must carry a cookie named <c>AuthTokenSignFLOWReceiver.{envelopeKey}</c>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="envelopeKey">The unique envelope key extracted from the route.</param>
|
|
||||||
/// <response code="200">Valid per-envelope token found.</response>
|
|
||||||
/// <response code="401">Token is missing, expired or invalid.</response>
|
|
||||||
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
[HttpGet("check/envelope/{envelopeKey}")]
|
|
||||||
public IActionResult CheckEnvelopeReceiver([FromRoute] string envelopeKey) => Ok();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes the per-envelope receiver cookie for the given envelope key.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="envelopeKey">The unique envelope key whose cookie should be deleted.</param>
|
|
||||||
/// <response code="200">Cookie successfully deleted.</response>
|
|
||||||
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
|
|
||||||
[HttpPost("logout/envelope/{envelopeKey}")]
|
|
||||||
public IActionResult LogoutEnvelopeReceiver([FromRoute] string envelopeKey)
|
|
||||||
{
|
|
||||||
var cookieName = CookieNames.GetEnvelopeReceiverCookieName(authTokenKeys.Cookie, envelopeKey);
|
|
||||||
Response.Cookies.Delete(cookieName);
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes all per-envelope receiver cookies from the current request.
|
|
||||||
/// </summary>
|
|
||||||
/// <response code="200">All envelope receiver cookies successfully deleted.</response>
|
|
||||||
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
|
|
||||||
[HttpPost("logout/envelope")]
|
|
||||||
public IActionResult LogoutAllEnvelopeReceivers()
|
|
||||||
{
|
|
||||||
foreach (var cookieName in Request.Cookies.Keys.Where(k => CookieNames.IsEnvelopeReceiverCookie(k, authTokenKeys.Cookie)))
|
|
||||||
Response.Cookies.Delete(cookieName);
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Caching.Distributed;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using System.Text.Json;
|
|
||||||
using EnvelopeGenerator.API.Options;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using EnvelopeGenerator.API.Extensions;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Manages cached data for receivers using distributed cache.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
public class CacheController(
|
|
||||||
IDistributedCache cache,
|
|
||||||
IOptions<CacheOptions> cacheOptions) : ControllerBase
|
|
||||||
{
|
|
||||||
private const string SignatureCacheKeyPrefix = "envelope-generator.receiver-ui.signature:";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stores a receiver's signature in cache for the specified envelope.
|
|
||||||
/// </summary>
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
[HttpPost("SignatureCapture/{envelopeKey}")]
|
|
||||||
public async Task<IActionResult> SaveSignature(
|
|
||||||
[FromRoute] string envelopeKey,
|
|
||||||
[FromBody] SignatureCacheRequest request,
|
|
||||||
CancellationToken cancel)
|
|
||||||
{
|
|
||||||
var cacheKey = $"{SignatureCacheKeyPrefix}{User.ReceiverSignature()}";
|
|
||||||
var json = JsonSerializer.Serialize(request);
|
|
||||||
|
|
||||||
var options = cacheOptions.Value.SignatureCacheExpiration.HasValue
|
|
||||||
? new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = cacheOptions.Value.SignatureCacheExpiration.Value }
|
|
||||||
: null;
|
|
||||||
|
|
||||||
await cache.SetStringAsync(cacheKey, json, options ?? new DistributedCacheEntryOptions(), cancel);
|
|
||||||
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves a cached signature for the specified envelope.
|
|
||||||
/// </summary>
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
[HttpGet("SignatureCapture/{envelopeKey}")]
|
|
||||||
public async Task<IActionResult> GetSignature([FromRoute] string envelopeKey, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
var cacheKey = $"{SignatureCacheKeyPrefix}{User.ReceiverSignature()}";
|
|
||||||
var json = await cache.GetStringAsync(cacheKey, cancel);
|
|
||||||
|
|
||||||
if (json is null)
|
|
||||||
return NotFound();
|
|
||||||
|
|
||||||
var signature = JsonSerializer.Deserialize<SignatureCacheRequest>(json);
|
|
||||||
return Ok(signature);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a cached signature for the specified envelope.
|
|
||||||
/// </summary>
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
[HttpDelete("SignatureCapture/{envelopeKey}")]
|
|
||||||
public async Task<IActionResult> DeleteSignature([FromRoute] string envelopeKey, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
var cacheKey = $"{SignatureCacheKeyPrefix}{User.ReceiverSignature()}";
|
|
||||||
await cache.RemoveAsync(cacheKey, cancel);
|
|
||||||
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request model for caching signature data.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record SignatureCacheRequest(
|
|
||||||
string DataUrl,
|
|
||||||
string FullName,
|
|
||||||
string Place,
|
|
||||||
string? Position = null);
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exposes configuration data required by the client applications.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Initializes a new instance of <see cref="ConfigController"/>.
|
|
||||||
/// </remarks>
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[ApiController]
|
|
||||||
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
|
|
||||||
public class ConfigController(IOptionsMonitor<AnnotationParams> annotationParamsOptions) : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly AnnotationParams _annotationParams = annotationParamsOptions.CurrentValue;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns annotation configuration that was previously rendered by MVC.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("Annotations")]
|
|
||||||
[Obsolete("PSPDF Kit will no longer be used.")]
|
|
||||||
public IActionResult GetAnnotationParams()
|
|
||||||
{
|
|
||||||
return Ok(_annotationParams.AnnotationJSObject);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
using EnvelopeGenerator.API.Extensions;
|
|
||||||
using EnvelopeGenerator.Application.Common.Dto;
|
|
||||||
using EnvelopeGenerator.Application.Common.Extensions;
|
|
||||||
using EnvelopeGenerator.Application.Documents.Queries;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class DocReceiverElementController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IMediator _mediator;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of <see cref="DocReceiverElementController"/>.
|
|
||||||
/// </summary>
|
|
||||||
public DocReceiverElementController(IMediator mediator)
|
|
||||||
{
|
|
||||||
_mediator = mediator;
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: update to use signature query
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="envelopeKey"></param>
|
|
||||||
/// <param name="cancel"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
[HttpGet("{envelopeKey}")]
|
|
||||||
public async Task<IActionResult> Get(string envelopeKey, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
int envelopeId = User.EnvelopeId();
|
|
||||||
|
|
||||||
int receiverId = User.ReceiverId();
|
|
||||||
|
|
||||||
var doc = await _mediator.Send(new ReadDocumentQuery() { EnvelopeId = envelopeId }, cancel);
|
|
||||||
|
|
||||||
if (doc.Elements is not IEnumerable<DocReceiverElementDto> docSignatures)
|
|
||||||
return NotFound("Document is empty.");
|
|
||||||
|
|
||||||
var rcvSignatures = docSignatures.Where(s => s.ReceiverId == receiverId).ToList();
|
|
||||||
|
|
||||||
if (rcvSignatures is null)
|
|
||||||
return NotFound("No signatures found for the current receiver.");
|
|
||||||
else
|
|
||||||
return Ok(rcvSignatures);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
using DigitalData.Auth.Claims;
|
|
||||||
using EnvelopeGenerator.API.Controllers.Interfaces;
|
|
||||||
using EnvelopeGenerator.API.Extensions;
|
|
||||||
using EnvelopeGenerator.Application.Documents.Queries;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provides access to envelope documents for authenticated receivers.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Initializes a new instance of the <see cref="DocumentController"/> class.
|
|
||||||
/// </remarks>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
public class DocumentController(IMediator mediator, IAuthorizationService authService, ILogger<DocumentController> logger) : ControllerBase, IAuthController
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
public IAuthorizationService AuthService => authService;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the document bytes receiver.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="query">Encoded envelope key.</param>
|
|
||||||
/// <param name="cancel">Cancellation token.</param>
|
|
||||||
[HttpGet]
|
|
||||||
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
|
|
||||||
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery? query = null)
|
|
||||||
{
|
|
||||||
// Sender: expects query with envelope key
|
|
||||||
if (await this.IsUserInPolicyAsync(AuthPolicy.Sender))
|
|
||||||
{
|
|
||||||
if (query is null)
|
|
||||||
return BadRequest("Missing document query.");
|
|
||||||
|
|
||||||
var senderDoc = await mediator.Send(query, cancel);
|
|
||||||
return senderDoc.ByteData is byte[] senderDocByte
|
|
||||||
? File(senderDocByte, "application/octet-stream")
|
|
||||||
: NotFound("Document is empty.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Receiver: resolve envelope id from claims
|
|
||||||
if (await this.IsUserInPolicyAsync(AuthPolicy.Receiver))
|
|
||||||
{
|
|
||||||
if (query is not null)
|
|
||||||
return BadRequest("Query parameters are not allowed for receiver role.");
|
|
||||||
|
|
||||||
var envelopeId = User.EnvelopeId();
|
|
||||||
var receiverDoc = await mediator.Send(new ReadDocumentQuery { EnvelopeId = envelopeId }, cancel);
|
|
||||||
return receiverDoc.ByteData is byte[] receiverDocByte
|
|
||||||
? File(receiverDocByte, "application/octet-stream")
|
|
||||||
: NotFound("Document is empty.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the document for the specified envelope key.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="envelopeKey"></param>
|
|
||||||
/// <param name="cancel"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
[HttpGet("{envelopeKey}")]
|
|
||||||
public async Task<IActionResult> GetDocumentOfReceiver(string envelopeKey, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
int envelopeId = User.EnvelopeId();
|
|
||||||
|
|
||||||
var senderDoc = await mediator.Send(new ReadDocumentQuery() { EnvelopeId = envelopeId }, cancel);
|
|
||||||
|
|
||||||
if (senderDoc.ByteData is not byte[] senderDocByte)
|
|
||||||
return NotFound("Document is empty.");
|
|
||||||
|
|
||||||
Response.Headers.ContentDisposition = $"inline; filename=\"{envelopeKey}.pdf\"";
|
|
||||||
return File(senderDocByte, "application/pdf");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
using AutoMapper;
|
|
||||||
using EnvelopeGenerator.Application.EmailTemplates.Commands;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using MediatR;
|
|
||||||
using EnvelopeGenerator.Application.Common.Dto;
|
|
||||||
using DigitalData.Core.Abstraction.Application.Repository;
|
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using EnvelopeGenerator.Application.EmailTemplates.Queries;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Controller for managing temp templates.
|
|
||||||
/// Steuerung zur Verwaltung von E-Mail-Vorlagen.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Initialisiert eine neue Instanz der <see cref="EmailTemplateController"/>-Klasse.
|
|
||||||
/// </remarks>
|
|
||||||
/// <param name="mediator">
|
|
||||||
/// Die Mediator-Instanz, die zum Senden von Befehlen und Abfragen verwendet wird.
|
|
||||||
/// </param>
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[ApiController]
|
|
||||||
[Authorize(Policy = AuthPolicy.Sender)]
|
|
||||||
public class EmailTemplateController(IMediator mediator) : ControllerBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Ruft E-Mail-Vorlagen basierend auf der angegebenen Abfrage ab.
|
|
||||||
/// Gibt alles zurück, wenn keine Id- oder Typ-Informationen eingegeben wurden.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="emailTemplate">Die Abfrageparameter zum Abrufen von E-Mail-Vorlagen.</param>
|
|
||||||
/// <param name="cancel"></param>
|
|
||||||
/// <returns>Gibt HTTP-Antwort zurück</returns>
|
|
||||||
/// <remarks>
|
|
||||||
/// Sample request:
|
|
||||||
/// GET /api/EmailTemplate?emailTemplateId=123
|
|
||||||
/// </remarks>
|
|
||||||
/// <response code="200">Wenn die E-Mail-Vorlagen erfolgreich abgerufen werden.</response>
|
|
||||||
/// <response code="400">Wenn die Abfrageparameter ungültig sind.</response>
|
|
||||||
/// <response code="401">Wenn der Benutzer nicht authentifiziert ist.</response>
|
|
||||||
/// <response code="404">Wenn die gesuchte Abfrage nicht gefunden wird.</response>
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> Get([FromQuery] ReadEmailTemplateQuery emailTemplate, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
var result = await mediator.Send(emailTemplate, cancel);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an temp template or resets it if no update command is provided.
|
|
||||||
/// Aktualisiert eine E-Mail-Vorlage oder setzt sie zurück, wenn kein Aktualisierungsbefehl angegeben ist.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="update"></param>
|
|
||||||
/// <param name="cancel"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
/// <response code="200">Wenn die E-Mail-Vorlage erfolgreich aktualisiert oder zurückgesetzt wird.</response>
|
|
||||||
/// <response code="400">Wenn die Abfrage ohne einen String gesendet wird.</response>
|
|
||||||
/// <response code="401">Wenn der Benutzer nicht authentifiziert ist.</response>
|
|
||||||
/// <response code="404">Wenn die gesuchte Abfrage nicht gefunden wird.</response>
|
|
||||||
[HttpPut]
|
|
||||||
public async Task<IActionResult> Update([FromBody] UpdateEmailTemplateCommand update, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
await mediator.Send(update, cancel);
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
using EnvelopeGenerator.API.Extensions;
|
|
||||||
using EnvelopeGenerator.Application.Envelopes.Commands;
|
|
||||||
using EnvelopeGenerator.Application.Envelopes.Queries;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dieser Controller stellt Endpunkte für die Verwaltung von Umschlägen bereit.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Die API ermöglicht das Abrufen und Verwalten von Umschlägen basierend auf Benutzerinformationen und Statusfiltern.
|
|
||||||
///
|
|
||||||
/// Mögliche Antworten:
|
|
||||||
/// - 200 OK: Die Anfrage war erfolgreich, und die angeforderten Daten werden zurückgegeben.
|
|
||||||
/// - 400 Bad Request: Die Anfrage war fehlerhaft oder unvollständig.
|
|
||||||
/// - 401 Unauthorized: Der Benutzer ist nicht authentifiziert.
|
|
||||||
/// - 403 Forbidden: Der Benutzer hat keine Berechtigung, auf die Ressource zuzugreifen.
|
|
||||||
/// - 404 Not Found: Die angeforderte Ressource wurde nicht gefunden.
|
|
||||||
/// - 500 Internal Server Error: Ein unerwarteter Fehler ist aufgetreten.
|
|
||||||
/// </remarks>
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[ApiController]
|
|
||||||
[Authorize]
|
|
||||||
public class EnvelopeController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ILogger<EnvelopeController> _logger;
|
|
||||||
private readonly IMediator _mediator;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Erstellt eine neue Instanz des EnvelopeControllers.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="logger">Der Logger, der für das Protokollieren von Informationen verwendet wird.</param>
|
|
||||||
/// <param name="mediator"></param>
|
|
||||||
public EnvelopeController(ILogger<EnvelopeController> logger, IMediator mediator)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_mediator = mediator;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ruft eine Liste von Umschlägen basierend auf dem Benutzer und den angegebenen Statusfiltern ab.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="envelope"></param>
|
|
||||||
/// <returns>Eine IActionResult-Instanz, die die abgerufenen Umschläge oder einen Fehlerstatus enthält.</returns>
|
|
||||||
/// <response code="200">Die Anfrage war erfolgreich, und die Umschläge werden zurückgegeben.</response>
|
|
||||||
/// <response code="400">Die Anfrage war fehlerhaft oder unvollständig.</response>
|
|
||||||
/// <response code="401">Der Benutzer ist nicht authentifiziert.</response>
|
|
||||||
/// <response code="403">Der Benutzer hat keine Berechtigung, auf die Ressource zuzugreifen.</response>
|
|
||||||
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
|
|
||||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> GetAsync([FromQuery] ReadEnvelopeQuery envelope)
|
|
||||||
{
|
|
||||||
var result = await _mediator.Send(envelope.Authorize(User.GetId()));
|
|
||||||
return result.Any() ? Ok(result) : NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ruft das Ergebnis eines Dokuments basierend auf der ID ab.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="query"></param>
|
|
||||||
/// <param name="view">Gibt an, ob das Dokument inline angezeigt werden soll (true) oder als Download bereitgestellt wird (false).</param>
|
|
||||||
/// <returns>Eine IActionResult-Instanz, die das Dokument oder einen Fehlerstatus enthält.</returns>
|
|
||||||
/// <response code="200">Das Dokument wurde erfolgreich abgerufen.</response>
|
|
||||||
/// <response code="404">Das Dokument wurde nicht gefunden oder ist nicht verfügbar.</response>
|
|
||||||
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
|
|
||||||
[HttpGet("doc-result")]
|
|
||||||
public async Task<IActionResult> GetDocResultAsync([FromQuery] ReadEnvelopeQuery query, [FromQuery] bool view = false)
|
|
||||||
{
|
|
||||||
var envelopes = await _mediator.Send(query.Authorize(User.GetId()));
|
|
||||||
var envelope = envelopes.FirstOrDefault();
|
|
||||||
|
|
||||||
if (envelope is null)
|
|
||||||
return NotFound("Envelope not available.");
|
|
||||||
if (envelope.DocResult is null)
|
|
||||||
return NotFound("The document has not been fully signed or the result has not yet been released.");
|
|
||||||
|
|
||||||
if (view)
|
|
||||||
{
|
|
||||||
Response.Headers.Append("Content-Disposition", "inline; filename=\"" + envelope.Uuid + ".pdf\"");
|
|
||||||
return File(envelope.DocResult, "application/pdf");
|
|
||||||
}
|
|
||||||
|
|
||||||
return File(envelope.DocResult, "application/pdf", $"{envelope.Uuid}.pdf");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
[NonAction]
|
|
||||||
[Authorize]
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
|
|
||||||
{
|
|
||||||
var res = await _mediator.Send(command.WithAuth(User.GetId()));
|
|
||||||
|
|
||||||
if (res is null)
|
|
||||||
{
|
|
||||||
_logger.LogError("Failed to create envelope. Envelope details: {EnvelopeDetails}", JsonConvert.SerializeObject(command));
|
|
||||||
return StatusCode(StatusCodes.Status500InternalServerError);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
return Ok(res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
using AutoMapper;
|
|
||||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
|
||||||
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
|
||||||
using EnvelopeGenerator.Application.Envelopes.Queries;
|
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
|
||||||
using EnvelopeGenerator.API.Models;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using System.Data;
|
|
||||||
using EnvelopeGenerator.Application.Common.SQL;
|
|
||||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
|
||||||
using EnvelopeGenerator.API.Extensions;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Controller für die Verwaltung von Umschlagempfängern.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Dieser Controller bietet Endpunkte für das Abrufen und Verwalten von Umschlagempfängerdaten.
|
|
||||||
/// </remarks>
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[Authorize]
|
|
||||||
[ApiController]
|
|
||||||
public class EnvelopeReceiverController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ILogger<EnvelopeReceiverController> _logger;
|
|
||||||
private readonly IMediator _mediator;
|
|
||||||
private readonly IMapper _mapper;
|
|
||||||
private readonly IEnvelopeExecutor _envelopeExecutor;
|
|
||||||
private readonly IEnvelopeReceiverExecutor _erExecutor;
|
|
||||||
private readonly IDocumentExecutor _documentExecutor;
|
|
||||||
private readonly string _cnnStr;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Konstruktor für den EnvelopeReceiverController.
|
|
||||||
/// </summary>
|
|
||||||
public EnvelopeReceiverController(ILogger<EnvelopeReceiverController> logger, IMediator mediator, IMapper mapper, IEnvelopeExecutor envelopeExecutor, IEnvelopeReceiverExecutor erExecutor, IDocumentExecutor documentExecutor, IOptions<ConnectionString> csOpt)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_mediator = mediator;
|
|
||||||
_mapper = mapper;
|
|
||||||
_envelopeExecutor = envelopeExecutor;
|
|
||||||
_erExecutor = erExecutor;
|
|
||||||
_documentExecutor = documentExecutor;
|
|
||||||
_cnnStr = csOpt.Value.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ruft eine Liste von Umschlagempfängern basierend auf den angegebenen Abfrageparametern ab.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="envelopeReceiver">Die Abfrageparameter für die Filterung von Umschlagempfängern.</param>
|
|
||||||
/// <returns>Eine HTTP-Antwort mit der Liste der gefundenen Umschlagempfänger oder einem Fehlerstatus.</returns>
|
|
||||||
/// <remarks>
|
|
||||||
/// Dieser Endpunkt ermöglicht es, Umschlagempfänger basierend auf dem Benutzernamen und optionalen Statusfiltern abzurufen.
|
|
||||||
/// Wenn der Benutzername nicht ermittelt werden kann, wird ein Serverfehler zurückgegeben.
|
|
||||||
/// </remarks>
|
|
||||||
/// <response code="200">Die Liste der Umschlagempfänger wurde erfolgreich abgerufen.</response>
|
|
||||||
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
|
|
||||||
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
|
|
||||||
[Authorize]
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> GetEnvelopeReceiver([FromQuery] ReadEnvelopeReceiverQuery envelopeReceiver)
|
|
||||||
{
|
|
||||||
envelopeReceiver = envelopeReceiver with { Username = User.GetUsername() };
|
|
||||||
|
|
||||||
var result = await _mediator.Send(envelopeReceiver);
|
|
||||||
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="envelopeKey"></param>
|
|
||||||
/// <param name="cancel"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
[HttpGet("{envelopeKey}")]
|
|
||||||
public async Task<IActionResult> GetEnvelopeReceiverOfReceiver([FromRoute] string envelopeKey, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
var er = await _mediator.Send(new ReadEnvelopeReceiverQuery()
|
|
||||||
{
|
|
||||||
Key = envelopeKey
|
|
||||||
}, cancel);
|
|
||||||
|
|
||||||
return Ok(er.SingleOrDefault());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ruft den Namen des zuletzt verwendeten Empfängers basierend auf der angegebenen E-Mail-Adresse ab.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="receiver">Abfrage, bei der nur eine der Angaben ID, Signatur oder E-Mail-Adresse des Empfängers eingegeben werden muss.</param>
|
|
||||||
/// <returns>Eine HTTP-Antwort mit dem Namen des Empfängers oder einem Fehlerstatus.</returns>
|
|
||||||
/// <remarks>
|
|
||||||
/// Dieser Endpunkt ermöglicht es, den Namen des zuletzt verwendeten Empfängers basierend auf der E-Mail-Adresse abzurufen.
|
|
||||||
/// </remarks>
|
|
||||||
/// <response code="200">Der Name des Empfängers wurde erfolgreich abgerufen.</response>
|
|
||||||
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
|
|
||||||
/// <response code="404">Kein Empfänger gefunden.</response>
|
|
||||||
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
|
|
||||||
[Authorize]
|
|
||||||
[HttpGet("salute")]
|
|
||||||
public async Task<IActionResult> GetReceiverName([FromQuery] ReadReceiverNameQuery receiver)
|
|
||||||
{
|
|
||||||
var name = await _mediator.Send(receiver);
|
|
||||||
return name is null ? NotFound() : Ok(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Datenübertragungsobjekt mit Informationen zu Umschlägen, Empfängern und Unterschriften.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request"></param>
|
|
||||||
/// <param name="cancel"></param>
|
|
||||||
/// <returns>HTTP-Antwort</returns>
|
|
||||||
/// <remarks>
|
|
||||||
/// Sample request:
|
|
||||||
///
|
|
||||||
/// POST /api/envelope
|
|
||||||
/// {
|
|
||||||
/// "title": "Vertragsdokument",
|
|
||||||
/// "message": "Bitte unterschreiben Sie dieses Dokument.",
|
|
||||||
/// "document": {
|
|
||||||
/// "dataAsBase64": "dGVzdC1iYXNlNjQtZGF0YQ=="
|
|
||||||
/// },
|
|
||||||
/// "receivers": [
|
|
||||||
/// {
|
|
||||||
/// "emailAddress": "example@example.com",
|
|
||||||
/// "signatures": [
|
|
||||||
/// {
|
|
||||||
/// "x": 100,
|
|
||||||
/// "y": 200,
|
|
||||||
/// "page": 1
|
|
||||||
/// }
|
|
||||||
/// ],
|
|
||||||
/// "name": "Max Mustermann",
|
|
||||||
/// "phoneNumber": "+49123456789"
|
|
||||||
/// }
|
|
||||||
/// ],
|
|
||||||
/// "tfaEnabled": false
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// </remarks>
|
|
||||||
/// <response code="202">Envelope-Erstellung und Sendeprozessbefehl erfolgreich</response>
|
|
||||||
/// <response code="400">Wenn ein Fehler im HTTP-Body auftritt</response>
|
|
||||||
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
|
|
||||||
/// <response code="500">Es handelt sich um einen unerwarteten Fehler. Die Protokolle sollten überprüft werden.</response>
|
|
||||||
[Authorize]
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeReceiverCommand request, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
#region Create Envelope
|
|
||||||
var envelope = await _envelopeExecutor.CreateEnvelopeAsync(User.GetId(), request.Title, request.Message, request.TFAEnabled, cancel);
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Add receivers
|
|
||||||
List<EnvelopeReceiver> sentReceivers = new();
|
|
||||||
List<ReceiverGetOrCreateCommand> unsentReceivers = new();
|
|
||||||
|
|
||||||
foreach (var receiver in request.Receivers)
|
|
||||||
{
|
|
||||||
var envelopeReceiver = await _erExecutor.AddEnvelopeReceiverAsync(envelope.Uuid, receiver.EmailAddress, receiver.Salution, receiver.PhoneNumber, cancel);
|
|
||||||
|
|
||||||
if (envelopeReceiver is null)
|
|
||||||
unsentReceivers.Add(receiver);
|
|
||||||
else
|
|
||||||
sentReceivers.Add(envelopeReceiver);
|
|
||||||
}
|
|
||||||
|
|
||||||
var res = _mapper.Map<CreateEnvelopeReceiverResponse>(envelope);
|
|
||||||
res.UnsentReceivers = unsentReceivers;
|
|
||||||
res.SentReceiver = _mapper.Map<List<ReceiverDto>>(sentReceivers.Select(er => er.Receiver));
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Add document
|
|
||||||
var document = await _documentExecutor.CreateDocumentAsync(request.Document.DataAsBase64, envelope.Uuid, cancel);
|
|
||||||
|
|
||||||
if (document is null)
|
|
||||||
return StatusCode(StatusCodes.Status500InternalServerError, "Document creation is failed.");
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Add document element
|
|
||||||
// @DOC_ID, @RECEIVER_ID, @POSITION_X, @POSITION_Y, @PAGE
|
|
||||||
string sql = @"
|
|
||||||
DECLARE @OUT_SUCCESS bit;
|
|
||||||
|
|
||||||
EXEC [dbo].[PRSIG_API_ADD_DOC_RECEIVER_ELEM]
|
|
||||||
{0},
|
|
||||||
{1},
|
|
||||||
{2},
|
|
||||||
{3},
|
|
||||||
{4},
|
|
||||||
@OUT_SUCCESS OUTPUT;
|
|
||||||
|
|
||||||
SELECT @OUT_SUCCESS as [@OUT_SUCCESS];";
|
|
||||||
|
|
||||||
foreach (var rcv in res.SentReceiver)
|
|
||||||
foreach (var sign in request.Receivers.Where(r => r.EmailAddress == rcv.EmailAddress).FirstOrDefault()?.DocReceiverElements ?? Enumerable.Empty<Application.EnvelopeReceivers.Commands.DocReceiverElementCreateDto>())
|
|
||||||
{
|
|
||||||
using SqlConnection conn = new(_cnnStr);
|
|
||||||
conn.Open();
|
|
||||||
|
|
||||||
var formattedSQL = string.Format(sql, document.Id.ToSqlParam(), rcv.Id.ToSqlParam(), sign.X.ToSqlParam(), sign.Y.ToSqlParam(), sign.Page.ToSqlParam());
|
|
||||||
|
|
||||||
using SqlCommand cmd = new(formattedSQL, conn);
|
|
||||||
cmd.CommandType = CommandType.Text;
|
|
||||||
|
|
||||||
using SqlDataReader reader = cmd.ExecuteReader();
|
|
||||||
if (reader.Read())
|
|
||||||
{
|
|
||||||
bool outSuccess = reader.GetBoolean(0);
|
|
||||||
if (!outSuccess)
|
|
||||||
_logger.LogWarning(
|
|
||||||
"PRSIG_API_ADD_DOC_RECEIVER_ELEM returned OUT_SUCCESS=false. DOC_ID={DocId}, RECEIVER_ID={ReceiverId}, Page={Page}",
|
|
||||||
document.Id, rcv.Id, sign.Page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Create history
|
|
||||||
// ENV_UID, STATUS_ID, USER_ID,
|
|
||||||
string sql_hist = @"
|
|
||||||
DECLARE @OUT_SUCCESS bit;
|
|
||||||
|
|
||||||
EXEC [dbo].[PRSIG_API_ADD_HISTORY_STATE]
|
|
||||||
{0},
|
|
||||||
{1},
|
|
||||||
{2},
|
|
||||||
@OUT_SUCCESS OUTPUT;
|
|
||||||
|
|
||||||
SELECT @OUT_SUCCESS as [@OUT_SUCCESS];";
|
|
||||||
|
|
||||||
using (SqlConnection conn = new(_cnnStr))
|
|
||||||
{
|
|
||||||
conn.Open();
|
|
||||||
var formattedSQL_hist = string.Format(sql_hist, envelope.Uuid.ToSqlParam(), 1003.ToSqlParam(), User.GetId().ToSqlParam());
|
|
||||||
using SqlCommand cmd = new(formattedSQL_hist, conn);
|
|
||||||
cmd.CommandType = CommandType.Text;
|
|
||||||
|
|
||||||
using SqlDataReader reader = cmd.ExecuteReader();
|
|
||||||
if (reader.Read())
|
|
||||||
{
|
|
||||||
bool outSuccess = reader.GetBoolean(0);
|
|
||||||
if (!outSuccess)
|
|
||||||
_logger.LogWarning(
|
|
||||||
"PRSIG_API_ADD_HISTORY_STATE returned OUT_SUCCESS=false. EnvelopeUuid={EnvelopeUuid}",
|
|
||||||
envelope.Uuid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
return Ok(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="input"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public static bool IsBase64String(string input)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(input))
|
|
||||||
return false;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Convert.FromBase64String(input);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (FormatException)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using EnvelopeGenerator.Application.EnvelopeTypes.Queries;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.GeneratorAPI.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[ApiController]
|
|
||||||
public class EnvelopeTypeController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ILogger<EnvelopeTypeController> _logger;
|
|
||||||
private readonly IMediator _mediator;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="logger"></param>
|
|
||||||
/// <param name="mediator"></param>
|
|
||||||
public EnvelopeTypeController(ILogger<EnvelopeTypeController> logger, IMediator mediator)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_mediator = mediator;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <returns></returns>
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> GetAllAsync()
|
|
||||||
{
|
|
||||||
var result = await _mediator.Send(new ReadEnvelopeTypesQuery());
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using EnvelopeGenerator.Application.Histories.Queries;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using EnvelopeGenerator.Application.Common.Extensions;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dieser Controller stellt Endpunkte für den Zugriff auf die Umschlaghistorie bereit.
|
|
||||||
/// </summary>
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[ApiController]
|
|
||||||
[Authorize]
|
|
||||||
public class HistoryController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IMemoryCache _memoryCache;
|
|
||||||
|
|
||||||
private readonly IMediator _mediator;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Konstruktor für den HistoryController.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="memoryCache"></param>
|
|
||||||
/// <param name="mediator"></param>
|
|
||||||
public HistoryController(IMemoryCache memoryCache, IMediator mediator)
|
|
||||||
{
|
|
||||||
_memoryCache = memoryCache;
|
|
||||||
_mediator = mediator;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gibt alle möglichen Verweise auf alle möglichen Include in einem Verlaufsdatensatz zurück. (z. B. DocumentSigned bezieht sich auf Receiver.)
|
|
||||||
/// Dies wird hinzugefügt, damit Client-Anwendungen sich selbst auf dem neuesten Stand halten können.
|
|
||||||
/// 1 - Sender:
|
|
||||||
/// Historische Datensätze über den Include der Empfänger. Diese haben Statuscodes, die mit 1* beginnen.
|
|
||||||
/// 2 - Receiver:
|
|
||||||
/// Historische Datensätze, die sich auf den Include des Absenders beziehen. Sie haben Statuscodes, die mit 2* beginnen.
|
|
||||||
/// 3 - System:
|
|
||||||
/// Historische Datensätze, die sich auf den allgemeinen Zustand des Umschlags beziehen. Diese haben Statuscodes, die mit 3* beginnen.
|
|
||||||
/// 4 - Unknown:
|
|
||||||
/// Ein unbekannter Datensatz weist auf einen möglichen Mangel oder eine Unstimmigkeit im Aktualisierungsprozess der Anwendung hin.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns></returns>
|
|
||||||
/// <response code="200"></response>
|
|
||||||
[HttpGet("related")]
|
|
||||||
[Authorize]
|
|
||||||
public IActionResult GetReferenceTypes(ReferenceType? referenceType = null)
|
|
||||||
{
|
|
||||||
return referenceType is null
|
|
||||||
? Ok(_memoryCache.GetEnumAsDictionary<ReferenceType>("gen.api", ReferenceType.Unknown))
|
|
||||||
: Ok(referenceType.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gibt alle möglichen Include in einem Verlaufsdatensatz zurück.
|
|
||||||
/// Dies wird hinzugefügt, damit Client-Anwendungen sich selbst auf dem neuesten Stand halten können.
|
|
||||||
/// 1003: EnvelopeQueued
|
|
||||||
/// 1006: EnvelopeCompletelySigned
|
|
||||||
/// 1007: EnvelopeReportCreated
|
|
||||||
/// 1008: EnvelopeArchived
|
|
||||||
/// 1009: EnvelopeDeleted
|
|
||||||
/// 10007: EnvelopeRejected
|
|
||||||
/// 10009: EnvelopeWithdrawn
|
|
||||||
/// 2001: AccessCodeRequested
|
|
||||||
/// 2002: AccessCodeCorrect
|
|
||||||
/// 2003: AccessCodeIncorrect
|
|
||||||
/// 2004: DocumentOpened
|
|
||||||
/// 2005: DocumentSigned
|
|
||||||
/// 2006: DocumentForwarded
|
|
||||||
/// 2007: DocumentRejected
|
|
||||||
/// 2008: EnvelopeShared
|
|
||||||
/// 2009: EnvelopeViewed
|
|
||||||
/// 3001: MessageInvitationSent (Wird von Trigger verwendet)
|
|
||||||
/// 3002: MessageAccessCodeSent
|
|
||||||
/// 3003: MessageConfirmationSent
|
|
||||||
/// 3004: MessageDeletionSent
|
|
||||||
/// 3005: MessageCompletionSent
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="status">
|
|
||||||
/// Abfrageparameter, der angibt, auf welche Referenz sich der Include bezieht.
|
|
||||||
/// 1 - Sender: Historische Datensätze, die sich auf den Include des Absenders beziehen. Sie haben Statuscodes, die mit 1* beginnen.
|
|
||||||
/// 2 - Receiver: Historische Datensätze über den Include der Empfänger. Diese haben Statuscodes, die mit 2* beginnen.
|
|
||||||
/// 3 - System: Diese werden durch Datenbank-Trigger aktualisiert und sind in den Tabellen EnvelopeHistory und EmailOut zu finden.Sie arbeiten
|
|
||||||
/// integriert mit der Anwendung EmailProfiler, um E-Mails zu versenden und haben die Codes, die mit 3* beginnen.
|
|
||||||
/// </param>
|
|
||||||
/// <returns>Gibt die HTTP-Antwort zurück.</returns>
|
|
||||||
/// <response code="200"></response>
|
|
||||||
[HttpGet("status")]
|
|
||||||
[Authorize]
|
|
||||||
public IActionResult GetEnvelopeStatus([FromQuery] EnvelopeStatus? status = null)
|
|
||||||
{
|
|
||||||
return status is null
|
|
||||||
? Ok(_memoryCache.GetEnumAsDictionary<EnvelopeStatus>("gen.api", Status.NonHist, Status.RelatedToFormApp))
|
|
||||||
: Ok(status.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ruft die gesamte Umschlaghistorie basierend auf den angegebenen Abfrageparametern ab.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="historyQuery">Die Abfrageparameter, die die Filterkriterien für die Umschlaghistorie definieren.</param>
|
|
||||||
/// <param name="cancel"></param>
|
|
||||||
/// <returns>Eine Liste von Historieneinträgen, die den angegebenen Kriterien entsprechen, oder nur der letzte Eintrag.</returns>
|
|
||||||
/// <response code="200">Die Anfrage war erfolgreich, und die Umschlaghistorie wird zurückgegeben.</response>
|
|
||||||
/// <response code="400">Die Anfrage war ungültig oder unvollständig.</response>
|
|
||||||
/// <response code="401">Der Benutzer ist nicht authentifiziert.</response>
|
|
||||||
/// <response code="403">Der Benutzer hat keine Berechtigung, auf die Ressource zuzugreifen.</response>
|
|
||||||
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
|
|
||||||
[HttpGet]
|
|
||||||
[Authorize]
|
|
||||||
public async Task<IActionResult> GetAllAsync([FromQuery] ReadHistoryQuery historyQuery, CancellationToken cancel)
|
|
||||||
{
|
|
||||||
var history = await _mediator.Send(historyQuery, cancel).ThrowIfEmpty(Exceptions.NotFound);
|
|
||||||
return Ok((historyQuery.OnlyLast) ? history.MaxBy(h => h.AddedWhen) : history);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
public interface IAuthController
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
IAuthorizationService AuthService { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
ClaimsPrincipal User { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
public static class AuthControllerExtensions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="controller"></param>
|
|
||||||
/// <param name="policyName"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public static async Task<bool> IsUserInPolicyAsync(this IAuthController controller, string policyName)
|
|
||||||
{
|
|
||||||
var result = await controller.AuthService.AuthorizeAsync(controller.User, policyName);
|
|
||||||
return result.Succeeded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
using DigitalData.Core.API;
|
|
||||||
using EnvelopeGenerator.Application.Resources;
|
|
||||||
using Microsoft.AspNetCore.Localization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
|
||||||
using Microsoft.Extensions.Localization;
|
|
||||||
using EnvelopeGenerator.Application.Resources;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Controller für die Verwaltung der Lokalisierung und Spracheinstellungen.
|
|
||||||
/// </summary>
|
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[ApiController]
|
|
||||||
public class LocalizationController : ControllerBase
|
|
||||||
{
|
|
||||||
private static readonly Guid L_KEY = Guid.NewGuid();
|
|
||||||
|
|
||||||
private readonly ILogger<LocalizationController> _logger;
|
|
||||||
private readonly IStringLocalizer<Resource> _mLocalizer;
|
|
||||||
private readonly IStringLocalizer<Resource> _localizer;
|
|
||||||
private readonly IMemoryCache _cache;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Konstruktor für den <see cref="LocalizationController"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="logger">Logger für die Protokollierung.</param>
|
|
||||||
/// <param name="localizer">Lokalisierungsdienst für Ressourcen.</param>
|
|
||||||
/// <param name="memoryCache">Speicher-Cache für die Zwischenspeicherung von Daten.</param>
|
|
||||||
/// <param name="_modelLocalizer">Lokalisierungsdienst für Modelle.</param>
|
|
||||||
public LocalizationController(
|
|
||||||
ILogger<LocalizationController> logger,
|
|
||||||
IStringLocalizer<Resource> localizer,
|
|
||||||
IMemoryCache memoryCache,
|
|
||||||
IStringLocalizer<Resource> _modelLocalizer)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_localizer = localizer;
|
|
||||||
_cache = memoryCache;
|
|
||||||
_mLocalizer = _modelLocalizer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ruft alle lokalisierten Daten ab.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Eine Liste aller lokalisierten Daten.</returns>
|
|
||||||
[HttpGet]
|
|
||||||
public IActionResult GetAll() => Ok(_cache.GetOrCreate(Language ?? string.Empty + L_KEY, _ => _mLocalizer.ToDictionary()));
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ruft die aktuelle Sprache ab.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Die aktuelle Sprache oder ein NotFound-Ergebnis, wenn keine Sprache gesetzt ist.</returns>
|
|
||||||
[HttpGet("lang")]
|
|
||||||
public IActionResult GetLanguage() => Language is null ? NotFound() : Ok(Language);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Setzt die Sprache.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="language">Die zu setzende Sprache.</param>
|
|
||||||
/// <returns>Ein Ok-Ergebnis, wenn die Sprache erfolgreich gesetzt wurde, oder ein BadRequest-Ergebnis, wenn die Eingabe ungültig ist.</returns>
|
|
||||||
[HttpPost("lang")]
|
|
||||||
public IActionResult SetLanguage([FromQuery] string language)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(language))
|
|
||||||
return BadRequest();
|
|
||||||
|
|
||||||
Language = language;
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Löscht die aktuelle Sprache.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Ein Ok-Ergebnis, wenn die Sprache erfolgreich gelöscht wurde.</returns>
|
|
||||||
[HttpDelete("lang")]
|
|
||||||
public IActionResult DeleteLanguage()
|
|
||||||
{
|
|
||||||
Language = null;
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Eigenschaft für die Verwaltung der aktuellen Sprache über Cookies.
|
|
||||||
/// </summary>
|
|
||||||
private string? Language
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var cookieValue = Request.Cookies[CookieRequestCultureProvider.DefaultCookieName];
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(cookieValue))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var culture = CookieRequestCultureProvider.ParseCookieValue(cookieValue)?.Cultures[0];
|
|
||||||
return culture?.Value ?? null;
|
|
||||||
}
|
|
||||||
set
|
|
||||||
{
|
|
||||||
if (value is null)
|
|
||||||
Response.Cookies.Delete(CookieRequestCultureProvider.DefaultCookieName);
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var cookieOptions = new CookieOptions()
|
|
||||||
{
|
|
||||||
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
|
||||||
Secure = false,
|
|
||||||
SameSite = SameSiteMode.Strict,
|
|
||||||
HttpOnly = true
|
|
||||||
};
|
|
||||||
|
|
||||||
Response.Cookies.Append(
|
|
||||||
CookieRequestCultureProvider.DefaultCookieName,
|
|
||||||
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(value)),
|
|
||||||
cookieOptions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
using DigitalData.Core.Abstraction.Application.DTO;
|
|
||||||
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
|
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using EnvelopeGenerator.API.Extensions;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Manages read-only envelope sharing flows.
|
|
||||||
/// </summary>
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[ApiController]
|
|
||||||
public class ReadOnlyController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ILogger<ReadOnlyController> _logger;
|
|
||||||
private readonly IEnvelopeReceiverReadOnlyService _readOnlyService;
|
|
||||||
private readonly IEnvelopeMailService _mailService;
|
|
||||||
private readonly IEnvelopeHistoryService _historyService;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="ReadOnlyController"/> class.
|
|
||||||
/// </summary>
|
|
||||||
public ReadOnlyController(ILogger<ReadOnlyController> logger, IEnvelopeReceiverReadOnlyService readOnlyService, IEnvelopeMailService mailService, IEnvelopeHistoryService historyService)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_readOnlyService = readOnlyService;
|
|
||||||
_mailService = mailService;
|
|
||||||
_historyService = historyService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new read-only receiver for the current envelope.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="createDto">Creation payload.</param>
|
|
||||||
[HttpPost]
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
[Obsolete("Use MediatR")]
|
|
||||||
public async Task<IActionResult> CreateAsync([FromBody] EnvelopeReceiverReadOnlyCreateDto createDto)
|
|
||||||
{
|
|
||||||
var authReceiverMail = User.ReceiverMail();
|
|
||||||
if (authReceiverMail is null)
|
|
||||||
{
|
|
||||||
_logger.LogError("EmailAddress claim is not found in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
|
|
||||||
return Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var envelopeId = User.EnvelopeId();
|
|
||||||
|
|
||||||
createDto.AddedWho = authReceiverMail;
|
|
||||||
createDto.EnvelopeId = envelopeId;
|
|
||||||
|
|
||||||
var creationRes = await _readOnlyService.CreateAsync(createDto: createDto);
|
|
||||||
|
|
||||||
if (creationRes.IsFailed)
|
|
||||||
{
|
|
||||||
_logger.LogNotice(creationRes);
|
|
||||||
return StatusCode(StatusCodes.Status500InternalServerError);
|
|
||||||
}
|
|
||||||
|
|
||||||
var readRes = await _readOnlyService.ReadByIdAsync(creationRes.Data.Id);
|
|
||||||
if (readRes.IsFailed)
|
|
||||||
{
|
|
||||||
_logger.LogNotice(creationRes);
|
|
||||||
return StatusCode(StatusCodes.Status500InternalServerError);
|
|
||||||
}
|
|
||||||
|
|
||||||
var newReadOnly = readRes.Data;
|
|
||||||
|
|
||||||
return await _mailService.SendAsync(newReadOnly).ThenAsync<int, IActionResult>(SuccessAsync: async _ =>
|
|
||||||
{
|
|
||||||
var histRes = await _historyService.RecordAsync((int)createDto.EnvelopeId, createDto.AddedWho, EnvelopeStatus.EnvelopeShared);
|
|
||||||
if (histRes.IsFailed)
|
|
||||||
{
|
|
||||||
_logger.LogError("Although the envelope was sent as read-only, the EnvelopeShared history could not be saved. Create DTO:\n{createDto}", JsonConvert.SerializeObject(createDto));
|
|
||||||
_logger.LogNotice(histRes.Notices);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok();
|
|
||||||
},
|
|
||||||
|
|
||||||
Fail: (msg, ntc) =>
|
|
||||||
{
|
|
||||||
_logger.LogNotice(ntc);
|
|
||||||
return StatusCode(StatusCodes.Status500InternalServerError);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
using MediatR;
|
|
||||||
using EnvelopeGenerator.Application.Receivers.Queries;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.GeneratorAPI.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Controller für die Verwaltung von Empfängern.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Dieser Controller bietet Endpunkte für das Abrufen von Empfängern basierend auf E-Mail-Adresse oder Signatur.
|
|
||||||
/// </remarks>
|
|
||||||
[Route("api/[controller]")]
|
|
||||||
[ApiController]
|
|
||||||
[Authorize]
|
|
||||||
public class ReceiverController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IMediator _mediator;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initialisiert eine neue Instanz des <see cref="ReceiverController"/>-Controllers.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="mediator">Mediator für Anfragen.</param>
|
|
||||||
public ReceiverController(IMediator mediator)
|
|
||||||
{
|
|
||||||
_mediator = mediator;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Ruft eine Liste von Empfängern ab, basierend auf den angegebenen Abfrageparametern.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="receiver">Die Abfrageparameter, einschließlich E-Mail-Adresse und Signatur.</param>
|
|
||||||
/// <returns>Eine Liste von Empfängern oder ein Fehlerstatus.</returns>
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> Get([FromQuery] ReadReceiverQuery receiver)
|
|
||||||
{
|
|
||||||
if (!receiver.HasAnyCriteria)
|
|
||||||
{
|
|
||||||
var all = await _mediator.Send(new ReadReceiverQuery());
|
|
||||||
return Ok(all);
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _mediator.Send(receiver);
|
|
||||||
return result is null ? NotFound() : Ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
using DigitalData.Core.Abstraction.Application.DTO;
|
|
||||||
using EnvelopeGenerator.Application.Common.Extensions;
|
|
||||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
|
||||||
using EnvelopeGenerator.Application.Resources;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using EnvelopeGenerator.API.Models;
|
|
||||||
using Ganss.Xss;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.Extensions.Localization;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exposes endpoints for registering and managing two-factor authentication for envelope receivers.
|
|
||||||
/// </summary>
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/tfa")]
|
|
||||||
public class TfaRegistrationController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly ILogger<TfaRegistrationController> _logger;
|
|
||||||
private readonly IEnvelopeReceiverService _envelopeReceiverService;
|
|
||||||
private readonly IAuthenticator _authenticator;
|
|
||||||
private readonly IReceiverService _receiverService;
|
|
||||||
private readonly TFARegParams _parameters;
|
|
||||||
private readonly IStringLocalizer<Resource> _localizer;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="TfaRegistrationController"/> class.
|
|
||||||
/// </summary>
|
|
||||||
public TfaRegistrationController(
|
|
||||||
ILogger<TfaRegistrationController> logger,
|
|
||||||
IEnvelopeReceiverService envelopeReceiverService,
|
|
||||||
IAuthenticator authenticator,
|
|
||||||
IReceiverService receiverService,
|
|
||||||
IOptions<TFARegParams> tfaRegParamsOptions,
|
|
||||||
IStringLocalizer<Resource> localizer)
|
|
||||||
{
|
|
||||||
_logger = logger;
|
|
||||||
_envelopeReceiverService = envelopeReceiverService;
|
|
||||||
_authenticator = authenticator;
|
|
||||||
_receiverService = receiverService;
|
|
||||||
_parameters = tfaRegParamsOptions.Value;
|
|
||||||
_localizer = localizer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates registration metadata (QR code and deadline) for a receiver.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="envelopeReceiverId">Encoded envelope receiver id.</param>
|
|
||||||
[Authorize]
|
|
||||||
[HttpGet("{envelopeReceiverId}")]
|
|
||||||
public async Task<IActionResult> RegisterAsync(string envelopeReceiverId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var (uuid, signature) = envelopeReceiverId.DecodeEnvelopeReceiverId();
|
|
||||||
|
|
||||||
if (uuid is null || signature is null)
|
|
||||||
{
|
|
||||||
_logger.LogEnvelopeError(uuid: uuid, signature: signature, message: _localizer.WrongEnvelopeReceiverId());
|
|
||||||
return Unauthorized(new { message = _localizer.WrongEnvelopeReceiverId() });
|
|
||||||
}
|
|
||||||
|
|
||||||
var secretResult = await _envelopeReceiverService.ReadWithSecretByUuidSignatureAsync(uuid: uuid, signature: signature);
|
|
||||||
if (secretResult.IsFailed)
|
|
||||||
{
|
|
||||||
_logger.LogNotice(secretResult.Notices);
|
|
||||||
return NotFound(new { message = _localizer.WrongEnvelopeReceiverId() });
|
|
||||||
}
|
|
||||||
|
|
||||||
var envelopeReceiver = secretResult.Data;
|
|
||||||
|
|
||||||
if (!envelopeReceiver.Envelope!.TFAEnabled)
|
|
||||||
return Unauthorized(new { message = _localizer.WrongAccessCode() });
|
|
||||||
|
|
||||||
var receiver = envelopeReceiver.Receiver;
|
|
||||||
receiver!.TotpSecretkey = _authenticator.GenerateTotpSecretKey();
|
|
||||||
await _receiverService.UpdateAsync(receiver);
|
|
||||||
var totpQr64 = _authenticator.GenerateTotpQrCode(userEmail: receiver.EmailAddress, secretKey: receiver.TotpSecretkey).ToBase64String();
|
|
||||||
|
|
||||||
if (receiver.TfaRegDeadline is null)
|
|
||||||
{
|
|
||||||
receiver.TfaRegDeadline = _parameters.Deadline;
|
|
||||||
await _receiverService.UpdateAsync(receiver);
|
|
||||||
}
|
|
||||||
else if (receiver.TfaRegDeadline <= DateTime.Now)
|
|
||||||
{
|
|
||||||
return StatusCode(StatusCodes.Status410Gone, new { message = _localizer.WrongAccessCode() });
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
envelopeReceiver.EnvelopeId,
|
|
||||||
envelopeReceiver.Envelope!.Uuid,
|
|
||||||
envelopeReceiver.Receiver!.Signature,
|
|
||||||
receiver.TfaRegDeadline,
|
|
||||||
TotpQR64 = totpQr64
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogEnvelopeError(envelopeReceiverId: envelopeReceiverId, exception: ex, message: _localizer.WrongEnvelopeReceiverId());
|
|
||||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = _localizer.UnexpectedError() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Logs out the envelope receiver from cookie authentication.
|
|
||||||
/// </summary>
|
|
||||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
|
||||||
[HttpPost("auth/logout")]
|
|
||||||
public async Task<IActionResult> LogOutAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "{message}", ex.Message);
|
|
||||||
return StatusCode(StatusCodes.Status500InternalServerError, new { message = _localizer.UnexpectedError() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
using EnvelopeGenerator.API.Models;
|
|
||||||
using Microsoft.OpenApi.Any;
|
|
||||||
using Microsoft.OpenApi.Models;
|
|
||||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Documentation;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AuthProxyDocumentFilter : IDocumentFilter
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="swaggerDoc"></param>
|
|
||||||
/// <param name="context"></param>
|
|
||||||
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
|
||||||
{
|
|
||||||
AddLoginOperation(swaggerDoc, context);
|
|
||||||
AddEnvelopeReceiverLoginOperation(swaggerDoc, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddLoginOperation(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
|
||||||
{
|
|
||||||
const string path = "/api/auth";
|
|
||||||
|
|
||||||
var loginSchema = context.SchemaGenerator.GenerateSchema(typeof(Login), context.SchemaRepository);
|
|
||||||
var loginExample = new OpenApiObject
|
|
||||||
{
|
|
||||||
["password"] = new OpenApiString(""),
|
|
||||||
["username"] = new OpenApiString("")
|
|
||||||
};
|
|
||||||
|
|
||||||
var operation = new OpenApiOperation
|
|
||||||
{
|
|
||||||
Summary = "Proxy login (auth-hub)",
|
|
||||||
Description = "Proxies the request to the auth service. Add query parameter `cookie=true|false`.",
|
|
||||||
Tags = [new() { Name = "Auth" }],
|
|
||||||
Parameters =
|
|
||||||
{
|
|
||||||
new OpenApiParameter
|
|
||||||
{
|
|
||||||
Name = "cookie",
|
|
||||||
In = ParameterLocation.Query,
|
|
||||||
Required = false,
|
|
||||||
Schema = new OpenApiSchema { Type = "boolean", Default = new OpenApiBoolean(true) },
|
|
||||||
Example = new OpenApiBoolean(true),
|
|
||||||
Description = "If true, auth service sets the auth cookie."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
RequestBody = new OpenApiRequestBody
|
|
||||||
{
|
|
||||||
Required = true,
|
|
||||||
Content =
|
|
||||||
{
|
|
||||||
["application/json"] = new OpenApiMediaType { Schema = loginSchema, Example = loginExample },
|
|
||||||
["multipart/form-data"] = new OpenApiMediaType { Schema = loginSchema, Example = loginExample }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Responses =
|
|
||||||
{
|
|
||||||
["200"] = new OpenApiResponse { Description = "OK (proxied response)" },
|
|
||||||
["401"] = new OpenApiResponse { Description = "Unauthorized" }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
swaggerDoc.Paths[path] = new OpenApiPathItem
|
|
||||||
{
|
|
||||||
Operations =
|
|
||||||
{
|
|
||||||
[OperationType.Post] = operation
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddEnvelopeReceiverLoginOperation(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
|
||||||
{
|
|
||||||
const string path = "/api/Auth/envelope-receiver/{key}";
|
|
||||||
|
|
||||||
var bodySchema = context.SchemaGenerator.GenerateSchema(typeof(EnvelopeReceiverLogin), context.SchemaRepository);
|
|
||||||
|
|
||||||
var operation = new OpenApiOperation
|
|
||||||
{
|
|
||||||
Summary = "Envelope receiver login (auth-hub proxy)",
|
|
||||||
Description = "Proxies the envelope receiver login to the auth service. " +
|
|
||||||
"The `cookie` query parameter is always forwarded as `true` so the auth service sets the per-envelope cookie automatically.",
|
|
||||||
Tags = [new() { Name = "Auth" }],
|
|
||||||
Parameters =
|
|
||||||
{
|
|
||||||
new OpenApiParameter
|
|
||||||
{
|
|
||||||
Name = "key",
|
|
||||||
In = ParameterLocation.Path,
|
|
||||||
Required = true,
|
|
||||||
Schema = new OpenApiSchema { Type = "string" },
|
|
||||||
Description = "The unique envelope receiver key."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
RequestBody = new OpenApiRequestBody
|
|
||||||
{
|
|
||||||
Required = false,
|
|
||||||
Content =
|
|
||||||
{
|
|
||||||
["multipart/form-data"] = new OpenApiMediaType { Schema = bodySchema }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Responses =
|
|
||||||
{
|
|
||||||
["200"] = new OpenApiResponse { Description = "OK – per-envelope cookie set by auth service." },
|
|
||||||
["401"] = new OpenApiResponse { Description = "Unauthorized – invalid or missing access code." }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
swaggerDoc.Paths[path] = new OpenApiPathItem
|
|
||||||
{
|
|
||||||
Operations =
|
|
||||||
{
|
|
||||||
[OperationType.Post] = operation
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFrameworks>net8.0</TargetFrameworks>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
|
||||||
<PackageId>EnvelopeGenerator.GeneratorAPI</PackageId>
|
|
||||||
<Title></Title>
|
|
||||||
<Authors>Digital Data GmbH</Authors>
|
|
||||||
<Company>Digital Data GmbH</Company>
|
|
||||||
<Product>EnvelopeGenerator.GeneratorAPI</Product>
|
|
||||||
<Version>1.4.0</Version>
|
|
||||||
<FileVersion>1.4.0</FileVersion>
|
|
||||||
<AssemblyVersion>1.4.0</AssemblyVersion>
|
|
||||||
<PackageOutputPath>Copyright © 2025 Digital Data GmbH. All rights reserved.</PackageOutputPath>
|
|
||||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Remove="ClientApp\**" />
|
|
||||||
<Content Remove="ClientApp\**" />
|
|
||||||
<EmbeddedResource Remove="ClientApp\**" />
|
|
||||||
<None Remove="ClientApp\**" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<None Include="yarp.json" CopyToOutputDirectory="PreserveNewest" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="AspNetCore.Scalar" Version="1.1.8" />
|
|
||||||
<PackageReference Include="DigitalData.Auth.Claims" Version="1.0.3" />
|
|
||||||
<PackageReference Include="DigitalData.Auth.Client" Version="1.3.7" />
|
|
||||||
<PackageReference Include="DigitalData.Core.API" Version="2.2.1" />
|
|
||||||
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.28" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.11" Condition="'$(TargetFramework)' == 'net8.0'" />
|
|
||||||
<PackageReference Include="itext" Version="8.0.5" />
|
|
||||||
<PackageReference Include="itext.bouncy-castle-adapter" Version="8.0.5" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.4" Condition="'$(TargetFramework)' == 'net9.0'" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" Condition="'$(TargetFramework)' == 'net8.0'" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.6" Condition="'$(TargetFramework)' == 'net9.0'" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.17" Condition="'$(TargetFramework)' == 'net8.0'" />
|
|
||||||
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
|
|
||||||
<PackageReference Include="NLog" Version="5.2.5" />
|
|
||||||
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.0" />
|
|
||||||
<PackageReference Include="Scalar.AspNetCore" Version="2.2.1" />
|
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
|
|
||||||
<PackageReference Include="DigitalData.EmailProfilerDispatcher.Abstraction" Version="3.2.0" />
|
|
||||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.1.0" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup Condition="'$(TargetFramework)' == 'net7.0'">
|
|
||||||
<PackageReference Include="System.DirectoryServices" Version="7.0.1" />
|
|
||||||
<PackageReference Include="System.DirectoryServices.AccountManagement" Version="7.0.1" />
|
|
||||||
<PackageReference Include="System.DirectoryServices.Protocols" Version="7.0.1" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
|
||||||
<PackageReference Include="System.DirectoryServices" Version="8.0.0" />
|
|
||||||
<PackageReference Include="System.DirectoryServices.AccountManagement" Version="8.0.1" />
|
|
||||||
<PackageReference Include="System.DirectoryServices.Protocols" Version="8.0.1" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup Condition="'$(TargetFramework)' == 'net9.0'">
|
|
||||||
<PackageReference Include="System.DirectoryServices" Version="9.0.4" />
|
|
||||||
<PackageReference Include="System.DirectoryServices.AccountManagement" Version="9.0.4" />
|
|
||||||
<PackageReference Include="System.DirectoryServices.Protocols" Version="9.0.4" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="wwwroot\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
|
||||||
<ProjectReference Include="..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
|
|
||||||
<ProjectReference Include="..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
|
|
||||||
<ProjectReference Include="..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
using DigitalData.Auth.Claims;
|
|
||||||
using Microsoft.IdentityModel.JsonWebTokens;
|
|
||||||
using System.Security.Claims;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Extensions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Provides helper methods for working with envelope-specific authentication claims.
|
|
||||||
/// </summary>
|
|
||||||
public static class ReceiverClaimExtensions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user"></param>
|
|
||||||
/// <param name="claimType"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
/// <exception cref="InvalidOperationException"></exception>
|
|
||||||
private static string GetRequiredClaimValue(this ClaimsPrincipal user, string claimType)
|
|
||||||
{
|
|
||||||
var value = user.FindFirstValue(claimType);
|
|
||||||
if (value is not null)
|
|
||||||
{
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
var identity = user.Identity;
|
|
||||||
var principalName = identity?.Name ?? "(anonymous)";
|
|
||||||
var authType = identity?.AuthenticationType ?? "(none)";
|
|
||||||
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
|
|
||||||
var message = $"Required claim '{claimType}' is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
|
|
||||||
throw new InvalidOperationException(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetRequiredClaimValue(this ClaimsPrincipal user, params string[] claimTypes)
|
|
||||||
{
|
|
||||||
foreach (var claimType in claimTypes.Where(t => !string.IsNullOrWhiteSpace(t)).Distinct())
|
|
||||||
{
|
|
||||||
var value = user.FindFirstValue(claimType);
|
|
||||||
if (!string.IsNullOrWhiteSpace(value))
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
var identity = user.Identity;
|
|
||||||
var principalName = identity?.Name ?? "(anonymous)";
|
|
||||||
var authType = identity?.AuthenticationType ?? "(none)";
|
|
||||||
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
|
|
||||||
var message = $"Required claim(s) '{string.Join("', '", claimTypes)}' are missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
|
|
||||||
throw new InvalidOperationException(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the authenticated envelope UUID from the claims.
|
|
||||||
/// </summary>
|
|
||||||
public static string EnvelopeUuid(this ClaimsPrincipal user)
|
|
||||||
=> user.GetRequiredClaimValue(EnvelopeClaimNames.EnvelopeUuid);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the authenticated receiver signature from the claims.
|
|
||||||
/// </summary>
|
|
||||||
public static string ReceiverSignature(this ClaimsPrincipal user)
|
|
||||||
=> user.GetRequiredClaimValue(EnvelopeClaimNames.ReceiverSignature);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the authenticated receiver email address from the claims.
|
|
||||||
/// </summary>
|
|
||||||
public static string ReceiverMail(this ClaimsPrincipal user)
|
|
||||||
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the authenticated envelope identifier from the claims.
|
|
||||||
/// </summary>
|
|
||||||
public static int EnvelopeId(this ClaimsPrincipal user)
|
|
||||||
{
|
|
||||||
var envIdStr = user.GetRequiredClaimValue(EnvelopeClaimNames.EnvelopeId);
|
|
||||||
if (int.TryParse(envIdStr, out var envId))
|
|
||||||
return envId;
|
|
||||||
else
|
|
||||||
throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.EnvelopeId}' is not a valid integer.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the authenticated receiver identifier from the claims.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user"></param>
|
|
||||||
/// <returns></returns>
|
|
||||||
/// <exception cref="InvalidOperationException"></exception>
|
|
||||||
public static int ReceiverId(this ClaimsPrincipal user)
|
|
||||||
{
|
|
||||||
var rcvIdStr = user.GetRequiredClaimValue(EnvelopeClaimNames.ReceiverId);
|
|
||||||
if (int.TryParse(rcvIdStr, out var rcvId))
|
|
||||||
return rcvId;
|
|
||||||
else
|
|
||||||
throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.ReceiverId}' is not a valid integer.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Extensions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Provides extension methods for extracting user information from a <see cref="ClaimsPrincipal"/>.
|
|
||||||
/// </summary>
|
|
||||||
public static class SenderClaimExtensions
|
|
||||||
{
|
|
||||||
private static string GetRequiredClaimOfSender(this ClaimsPrincipal user, string claimType)
|
|
||||||
{
|
|
||||||
var value = user.FindFirstValue(claimType);
|
|
||||||
if (value is not null)
|
|
||||||
{
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
var identity = user.Identity;
|
|
||||||
var principalName = identity?.Name ?? "(anonymous)";
|
|
||||||
var authType = identity?.AuthenticationType ?? "(none)";
|
|
||||||
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
|
|
||||||
var message = $"Required claim '{claimType}' is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
|
|
||||||
throw new InvalidOperationException(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetRequiredClaimOfSender(this ClaimsPrincipal user, params string[] claimTypes)
|
|
||||||
{
|
|
||||||
string? value = null;
|
|
||||||
|
|
||||||
foreach (var claimType in claimTypes)
|
|
||||||
{
|
|
||||||
value = user.FindFirstValue(claimType);
|
|
||||||
if (value is not null)
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
var identity = user.Identity;
|
|
||||||
var principalName = identity?.Name ?? "(anonymous)";
|
|
||||||
var authType = identity?.AuthenticationType ?? "(none)";
|
|
||||||
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
|
|
||||||
var message = $"Required claim among [{string.Join(", ", claimTypes)}] is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
|
|
||||||
throw new InvalidOperationException(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the user's ID from the claims. Throws an exception if the ID is missing or invalid.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
|
|
||||||
/// <returns>The user's ID as an integer.</returns>
|
|
||||||
/// <exception cref="InvalidOperationException">Thrown if the user ID claim is missing or invalid.</exception>
|
|
||||||
public static int GetId(this ClaimsPrincipal user)
|
|
||||||
{
|
|
||||||
var idValue = user.GetRequiredClaimOfSender(ClaimTypes.NameIdentifier, "sub");
|
|
||||||
|
|
||||||
if (!int.TryParse(idValue, out var result))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("User ID claim is missing or invalid. This may indicate a misconfigured or forged JWT token.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the username from the claims.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
|
|
||||||
/// <returns>The username as a string.</returns>
|
|
||||||
public static string GetUsername(this ClaimsPrincipal user)
|
|
||||||
=> user.GetRequiredClaimOfSender(ClaimTypes.Name);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the user's surname (last name) from the claims.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
|
|
||||||
/// <returns>The surname as a string.</returns>
|
|
||||||
public static string GetName(this ClaimsPrincipal user)
|
|
||||||
=> user.GetRequiredClaimOfSender(ClaimTypes.Surname);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the user's given name (first name) from the claims.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
|
|
||||||
/// <returns>The given name as a string.</returns>
|
|
||||||
public static string GetPrename(this ClaimsPrincipal user)
|
|
||||||
=> user.GetRequiredClaimOfSender(ClaimTypes.GivenName);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves the user's email address from the claims.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
|
|
||||||
/// <returns>The email address as a string.</returns>
|
|
||||||
public static string GetEmail(this ClaimsPrincipal user)
|
|
||||||
=> user.GetRequiredClaimOfSender(ClaimTypes.Email);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
10
EnvelopeGenerator.API/Jenkinsfile
vendored
10
EnvelopeGenerator.API/Jenkinsfile
vendored
@@ -1,10 +0,0 @@
|
|||||||
pipeline {
|
|
||||||
agent any
|
|
||||||
stages {
|
|
||||||
stage('Build') {
|
|
||||||
steps {
|
|
||||||
sh 'dotnet build'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Middleware;
|
|
||||||
|
|
||||||
using DigitalData.Core.Exceptions;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using System.Net;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Middleware for handling exceptions globally in the application.
|
|
||||||
/// Captures exceptions thrown during the request pipeline execution,
|
|
||||||
/// logs them, and returns an appropriate HTTP response with a JSON error message.
|
|
||||||
/// </summary>
|
|
||||||
public class ExceptionHandlingMiddleware
|
|
||||||
{
|
|
||||||
private readonly RequestDelegate _next;
|
|
||||||
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="ExceptionHandlingMiddleware"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="next">The next middleware in the request pipeline.</param>
|
|
||||||
/// <param name="logger">The logger instance for logging exceptions.</param>
|
|
||||||
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
|
|
||||||
{
|
|
||||||
_next = next;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Invokes the middleware to handle the HTTP request.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="context">The HTTP context of the current request.</param>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _next(context); // Continue down the pipeline
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await HandleExceptionAsync(context, ex, _logger);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles exceptions by logging them and writing an appropriate JSON response.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="context">The HTTP context of the current request.</param>
|
|
||||||
/// <param name="exception">The exception that occurred.</param>
|
|
||||||
/// <param name="logger">The logger instance for logging the exception.</param>
|
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
||||||
private static async Task HandleExceptionAsync(HttpContext context, Exception exception, ILogger logger)
|
|
||||||
{
|
|
||||||
context.Response.ContentType = "application/json";
|
|
||||||
|
|
||||||
string message;
|
|
||||||
|
|
||||||
switch (exception)
|
|
||||||
{
|
|
||||||
case BadRequestException badRequestEx:
|
|
||||||
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
|
|
||||||
message = badRequestEx.Message;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case NotFoundException notFoundEx:
|
|
||||||
context.Response.StatusCode = (int)HttpStatusCode.NotFound;
|
|
||||||
message = notFoundEx.Message;
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
logger.LogError(exception, "Unhandled exception occurred.");
|
|
||||||
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
|
||||||
message = "An unexpected error occurred.";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.Response.WriteAsync(JsonSerializer.Serialize(new
|
|
||||||
{
|
|
||||||
message
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models;
|
|
||||||
|
|
||||||
public record Auth(string? AccessCode = null, string? SmsCode = null, string? AuthenticatorCode = null, bool UserSelectSMS = default)
|
|
||||||
{
|
|
||||||
public bool HasAccessCode => AccessCode is not null;
|
|
||||||
|
|
||||||
public bool HasSmsCode => SmsCode is not null;
|
|
||||||
|
|
||||||
public bool HasAuthenticatorCode => AuthenticatorCode is not null;
|
|
||||||
|
|
||||||
public bool HasMulti => new[] { HasAccessCode, HasSmsCode, HasAuthenticatorCode }.Count(state => state) > 1;
|
|
||||||
|
|
||||||
public bool HasNone => !(HasAccessCode || HasSmsCode || HasAuthenticatorCode);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents the keys and default values used for authentication token handling
|
|
||||||
/// within the Envelope Generator API.
|
|
||||||
/// </summary>
|
|
||||||
public class AuthTokenKeys
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the name of the cookie used to store the authentication token.
|
|
||||||
/// </summary>
|
|
||||||
public string Cookie { get; init; } = "AuthToken";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the name of the query string parameter used to pass the authentication token.
|
|
||||||
/// </summary>
|
|
||||||
public string QueryString { get; init; } = "AuthToken";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the expected issuer value for the authentication token.
|
|
||||||
/// </summary>
|
|
||||||
public string Issuer { get; init; } = "auth.digitaldata.works";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the expected audience value for the authentication token.
|
|
||||||
/// </summary>
|
|
||||||
public string Audience { get; init; } = "sign-flow.digitaldata.works";
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
public class ConnectionString
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
public string Value { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a hyperlink for contact purposes with various HTML attributes.
|
|
||||||
/// </summary>
|
|
||||||
public class ContactLink
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the label of the hyperlink.
|
|
||||||
/// </summary>
|
|
||||||
public string Label { get; init; } = "Contact";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the URL that the hyperlink points to.
|
|
||||||
/// </summary>
|
|
||||||
public string Href { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the target where the hyperlink should open.
|
|
||||||
/// Commonly used values are "_blank", "_self", "_parent", "_top".
|
|
||||||
/// </summary>
|
|
||||||
public string Target { get; set; } = "_blank";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the relationship of the linked URL as space-separated link types.
|
|
||||||
/// Examples include "nofollow", "noopener", "noreferrer".
|
|
||||||
/// </summary>
|
|
||||||
public string Rel { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the filename that should be downloaded when clicking the hyperlink.
|
|
||||||
/// This attribute will only have an effect if the href attribute is set.
|
|
||||||
/// </summary>
|
|
||||||
public string Download { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the language of the linked resource. Useful when linking to
|
|
||||||
/// content in another language.
|
|
||||||
/// </summary>
|
|
||||||
public string HrefLang { get; set; } = "en";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the MIME type of the linked URL. Helps browsers to handle
|
|
||||||
/// the type correctly when the link is clicked.
|
|
||||||
/// </summary>
|
|
||||||
public string Type { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets additional information about the hyperlink, typically viewed
|
|
||||||
/// as a tooltip when the mouse hovers over the link.
|
|
||||||
/// </summary>
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets an identifier for the hyperlink, unique within the HTML document.
|
|
||||||
/// </summary>
|
|
||||||
public string Id { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Models;
|
|
||||||
|
|
||||||
public class Culture
|
|
||||||
{
|
|
||||||
private string _language = string.Empty;
|
|
||||||
public string Language { get => _language;
|
|
||||||
init {
|
|
||||||
_language = value;
|
|
||||||
Info = new(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public string FIClass { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public CultureInfo? Info { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models;
|
|
||||||
|
|
||||||
public class Cultures : List<Culture>
|
|
||||||
{
|
|
||||||
public IEnumerable<string> Languages => this.Select(c => c.Language);
|
|
||||||
|
|
||||||
public IEnumerable<string> FIClasses => this.Select(c => c.FIClass);
|
|
||||||
|
|
||||||
public Culture Default => this.First();
|
|
||||||
|
|
||||||
public Culture? this[string? language] => language is null ? null : this.Where(c => c.Language == language).FirstOrDefault();
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models;
|
|
||||||
|
|
||||||
public class CustomImages : Dictionary<string, Image>
|
|
||||||
{
|
|
||||||
public new Image this[string key] => TryGetValue(key, out var img) && img is not null ? img : new();
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Request body for the envelope-receiver login endpoint.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="AccessCode">The access code sent to the receiver.</param>
|
|
||||||
public record EnvelopeReceiverLogin(string? AccessCode = null);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models;
|
|
||||||
|
|
||||||
public class ErrorViewModel
|
|
||||||
{
|
|
||||||
public string Title { get; init; } = "404";
|
|
||||||
|
|
||||||
public string Subtitle { get; init; } = "Hmmm...";
|
|
||||||
|
|
||||||
public string Body { get; init; } = "It looks like one of the developers fell asleep";
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models;
|
|
||||||
|
|
||||||
public class Image
|
|
||||||
{
|
|
||||||
public string Src { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
public Dictionary<string, string> Classes { get; init; } = new();
|
|
||||||
|
|
||||||
public string GetClassIn(string page) => Classes.TryGetValue(page, out var cls) && cls is not null ? cls : string.Empty;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Repräsentiert ein Login-Modell mit erforderlichem Passwort und optionaler ID und Benutzername.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="Password">Das erforderliche Passwort für das Login.</param>
|
|
||||||
/// <param name="UserId">Die optionale ID des Benutzers.</param>
|
|
||||||
/// <param name="Username">Der optionale Benutzername.</param>
|
|
||||||
public record Login([Required] string Password, int? UserId = null, string? Username = null)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models;
|
|
||||||
|
|
||||||
public class MainViewModel
|
|
||||||
{
|
|
||||||
public string? Title { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
using EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
|
|
||||||
|
|
||||||
public record Annotation : IAnnotation
|
|
||||||
{
|
|
||||||
public required string Name { get; init; }
|
|
||||||
|
|
||||||
#region Bound Annotation
|
|
||||||
[JsonIgnore]
|
|
||||||
public string? HorBoundAnnotName { get; init; }
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
public string? VerBoundAnnotName { get; init; }
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Layout
|
|
||||||
[JsonIgnore]
|
|
||||||
public double? MarginLeft { get; set; }
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
public double MarginLeftRatio { get; init; } = 1;
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
public double? MarginTop { get; set; }
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
public double MarginTopRatio { get; init; } = 1;
|
|
||||||
|
|
||||||
public double? Width { get; set; }
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
public double WidthRatio { get; init; } = 1;
|
|
||||||
|
|
||||||
public double? Height { get; set; }
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
public double HeightRatio { get; init; } = 1;
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Position
|
|
||||||
public double Left => (MarginLeft ?? 0) + (HorBoundAnnot?.HorBoundary ?? 0);
|
|
||||||
|
|
||||||
public double Top => (MarginTop ?? 0) + (VerBoundAnnot?.VerBoundary ?? 0);
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Boundary
|
|
||||||
[JsonIgnore]
|
|
||||||
public double HorBoundary => Left + (Width ?? 0);
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
public double VerBoundary => Top + (Height ?? 0);
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region BoundAnnot
|
|
||||||
[JsonIgnore]
|
|
||||||
public Annotation? HorBoundAnnot { get; set; }
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
public Annotation? VerBoundAnnot { get; set; }
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
public Color? BackgroundColor { get; init; }
|
|
||||||
|
|
||||||
#region Border
|
|
||||||
public Color? BorderColor { get; init; }
|
|
||||||
|
|
||||||
public string? BorderStyle { get; init; }
|
|
||||||
|
|
||||||
public int? BorderWidth { get; set; }
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
[JsonIgnore]
|
|
||||||
internal Annotation Default
|
|
||||||
{
|
|
||||||
set
|
|
||||||
{
|
|
||||||
// To set null value, annotation must have null (0) value but null must has non-null value
|
|
||||||
if (MarginLeft == null && value.MarginLeft != null)
|
|
||||||
MarginLeft = value.MarginLeft * MarginLeftRatio;
|
|
||||||
|
|
||||||
if (MarginTop == null && value.MarginTop != null)
|
|
||||||
MarginTop = value.MarginTop * MarginTopRatio;
|
|
||||||
|
|
||||||
if (Width == null && value.Width != null)
|
|
||||||
Width = value.Width * WidthRatio;
|
|
||||||
|
|
||||||
if (Height == null && value.Height != null)
|
|
||||||
Height = value.Height * HeightRatio;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
using EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
|
|
||||||
|
|
||||||
public class AnnotationParams
|
|
||||||
{
|
|
||||||
public AnnotationParams()
|
|
||||||
{
|
|
||||||
_AnnotationJSObjectInitor = new(CreateAnnotationJSObject);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Background? Background { get; init; }
|
|
||||||
|
|
||||||
#region Annotation
|
|
||||||
[JsonIgnore]
|
|
||||||
public Annotation? DefaultAnnotation { get; init; }
|
|
||||||
|
|
||||||
private readonly List<Annotation> _annots = new List<Annotation>();
|
|
||||||
|
|
||||||
public bool TryGet(string name, out Annotation annotation)
|
|
||||||
{
|
|
||||||
#pragma warning disable CS8601 // Possible null reference assignment.
|
|
||||||
annotation = _annots.FirstOrDefault(a => a.Name == name);
|
|
||||||
#pragma warning restore CS8601 // Possible null reference assignment.
|
|
||||||
return annotation is not null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public required IEnumerable<Annotation> Annotations
|
|
||||||
{
|
|
||||||
get => _annots;
|
|
||||||
init
|
|
||||||
{
|
|
||||||
_annots = value.ToList();
|
|
||||||
|
|
||||||
if (DefaultAnnotation is not null)
|
|
||||||
foreach (var annot in _annots)
|
|
||||||
annot.Default = DefaultAnnotation;
|
|
||||||
|
|
||||||
for (int i = 0; i < _annots.Count; i++)
|
|
||||||
{
|
|
||||||
#region set bound annotations
|
|
||||||
// horizontal
|
|
||||||
if (_annots[i].HorBoundAnnotName is string horBoundAnnotName)
|
|
||||||
if (TryGet(horBoundAnnotName, out var horBoundAnnot))
|
|
||||||
_annots[i].HorBoundAnnot = horBoundAnnot;
|
|
||||||
else
|
|
||||||
throw new InvalidOperationException($"{horBoundAnnotName} added as bound anotation. However, it is not defined.");
|
|
||||||
|
|
||||||
// vertical
|
|
||||||
if (_annots[i].VerBoundAnnotName is string verBoundAnnotName)
|
|
||||||
if (TryGet(verBoundAnnotName, out var verBoundAnnot))
|
|
||||||
_annots[i].VerBoundAnnot = verBoundAnnot;
|
|
||||||
else
|
|
||||||
throw new InvalidOperationException($"{verBoundAnnotName} added as bound anotation. However, it is not defined.");
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region AnnotationJSObject
|
|
||||||
private Dictionary<string, IAnnotation> CreateAnnotationJSObject()
|
|
||||||
{
|
|
||||||
var dict = _annots.ToDictionary(a => a.Name.ToLower(), a => a as IAnnotation);
|
|
||||||
|
|
||||||
if (Background is not null)
|
|
||||||
{
|
|
||||||
Background.Locate(_annots);
|
|
||||||
dict.Add(Background.Name.ToLower(), Background);
|
|
||||||
}
|
|
||||||
|
|
||||||
return dict;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly Lazy<Dictionary<string, IAnnotation>> _AnnotationJSObjectInitor;
|
|
||||||
|
|
||||||
public Dictionary<string, IAnnotation> AnnotationJSObject => _AnnotationJSObjectInitor.Value;
|
|
||||||
#endregion
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The Background is an annotation for the PSPDF Kit. However, it has no function.
|
|
||||||
/// It is only the first annotation as a background for other annotations.
|
|
||||||
/// </summary>
|
|
||||||
public record Background : IAnnotation
|
|
||||||
{
|
|
||||||
[JsonIgnore]
|
|
||||||
public double Margin { get; init; }
|
|
||||||
|
|
||||||
public string Name { get; } = "Background";
|
|
||||||
|
|
||||||
public double? Width { get; set; }
|
|
||||||
|
|
||||||
public double? Height { get; set; }
|
|
||||||
|
|
||||||
public double Left { get; set; }
|
|
||||||
|
|
||||||
public double Top { get; set; }
|
|
||||||
|
|
||||||
public Color? BackgroundColor { get; init; }
|
|
||||||
|
|
||||||
#region Border
|
|
||||||
public Color? BorderColor { get; init; }
|
|
||||||
|
|
||||||
public string? BorderStyle { get; init; }
|
|
||||||
|
|
||||||
public int? BorderWidth { get; set; }
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
public void Locate(IEnumerable<IAnnotation> annotations)
|
|
||||||
{
|
|
||||||
// set Top
|
|
||||||
if (annotations.MinBy(a => a.Top)?.Top is double minTop)
|
|
||||||
Top = minTop;
|
|
||||||
|
|
||||||
// set Left
|
|
||||||
if (annotations.MinBy(a => a.Left)?.Left is double minLeft)
|
|
||||||
Left = minLeft;
|
|
||||||
|
|
||||||
// set Width
|
|
||||||
if(annotations.MaxBy(a => a.GetRight())?.GetRight() is double maxRight)
|
|
||||||
Width = maxRight - Left;
|
|
||||||
|
|
||||||
// set Height
|
|
||||||
if (annotations.MaxBy(a => a.GetBottom())?.GetBottom() is double maxBottom)
|
|
||||||
Height = maxBottom - Top;
|
|
||||||
|
|
||||||
// add margins
|
|
||||||
Top -= Margin;
|
|
||||||
Left -= Margin;
|
|
||||||
Width += Margin * 2;
|
|
||||||
Height += Margin * 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
|
|
||||||
|
|
||||||
public record Color
|
|
||||||
{
|
|
||||||
public int R { get; init; } = 0;
|
|
||||||
|
|
||||||
public int G { get; init; } = 0;
|
|
||||||
|
|
||||||
public int B { get; init; } = 0;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
|
|
||||||
|
|
||||||
public static class Extensions
|
|
||||||
{
|
|
||||||
public static double GetRight(this IAnnotation annotation) => annotation.Left + annotation?.Width ?? 0;
|
|
||||||
|
|
||||||
public static double GetBottom(this IAnnotation annotation) => annotation.Top + annotation?.Height ?? 0;
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models.PsPdfKitAnnotation;
|
|
||||||
|
|
||||||
public interface IAnnotation
|
|
||||||
{
|
|
||||||
string Name { get; }
|
|
||||||
|
|
||||||
double? Width { get; }
|
|
||||||
|
|
||||||
double? Height { get; }
|
|
||||||
|
|
||||||
double Left { get; }
|
|
||||||
|
|
||||||
double Top { get; }
|
|
||||||
|
|
||||||
Color? BackgroundColor { get; }
|
|
||||||
|
|
||||||
Color? BorderColor { get; }
|
|
||||||
|
|
||||||
string? BorderStyle { get; }
|
|
||||||
|
|
||||||
int? BorderWidth { get; }
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents the parameters for two-factor authentication (2FA) registration.
|
|
||||||
/// </summary>
|
|
||||||
public class TFARegParams
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The maximum allowed time for completing the registration process.
|
|
||||||
/// </summary>
|
|
||||||
public TimeSpan TimeLimit { get; init; } = new(0, 30, 0);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The deadline for registration, calculated as the current time plus the <see cref="TimeLimit"/>.
|
|
||||||
/// </summary>
|
|
||||||
public DateTime Deadline => DateTime.Now.AddTicks(TimeLimit.Ticks);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace EnvelopeGenerator.API.Options;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration options for distributed caching.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CacheOptions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration section name in appsettings.json.
|
|
||||||
/// </summary>
|
|
||||||
public const string SectionName = "Cache";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Signature cache expiration time.
|
|
||||||
/// If null, signatures will not expire automatically.
|
|
||||||
/// </summary>
|
|
||||||
public TimeSpan? SignatureCacheExpiration { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
using DigitalData.Core.API;
|
|
||||||
using DigitalData.Core.Application;
|
|
||||||
using EnvelopeGenerator.Infrastructure;
|
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
|
||||||
using Microsoft.AspNetCore.Localization;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using System.Globalization;
|
|
||||||
using Scalar.AspNetCore;
|
|
||||||
using Microsoft.OpenApi.Models;
|
|
||||||
using DigitalData.UserManager.DependencyInjection;
|
|
||||||
using EnvelopeGenerator.Application;
|
|
||||||
using DigitalData.Auth.Client;
|
|
||||||
using DigitalData.Core.Abstractions;
|
|
||||||
using EnvelopeGenerator.API.Models;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using DigitalData.Core.Abstractions.Security.Extensions;
|
|
||||||
using EnvelopeGenerator.API.Middleware;
|
|
||||||
using EnvelopeGenerator.API.Options;
|
|
||||||
using NLog.Web;
|
|
||||||
using NLog;
|
|
||||||
using DigitalData.Auth.Claims;
|
|
||||||
using EnvelopeGenerator.API;
|
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
||||||
|
|
||||||
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
|
|
||||||
logger.Info("Logging initialized!");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
builder.Configuration.AddJsonFile("yarp.json", optional: true, reloadOnChange: true);
|
|
||||||
|
|
||||||
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace);
|
|
||||||
|
|
||||||
if (!builder.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
builder.Logging.ClearProviders();
|
|
||||||
builder.Host.UseNLog();
|
|
||||||
}
|
|
||||||
|
|
||||||
var config = builder.Configuration;
|
|
||||||
|
|
||||||
var deferredProvider = new DeferredServiceProvider();
|
|
||||||
|
|
||||||
builder.Services.AddControllers()
|
|
||||||
.AddJsonOptions(options =>
|
|
||||||
{
|
|
||||||
options.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
|
|
||||||
});
|
|
||||||
builder.Services.AddHttpClient();
|
|
||||||
builder.Services.AddReverseProxy().LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
|
|
||||||
|
|
||||||
// CORS Policy
|
|
||||||
var allowedOrigins = config.GetSection("AllowedOrigins").Get<string[]>() ??
|
|
||||||
throw new InvalidOperationException("AllowedOrigins section is missing in the configuration.");
|
|
||||||
builder.Services.AddCors(options =>
|
|
||||||
{
|
|
||||||
options.AddPolicy("AllowSpecificOriginsPolicy", builder =>
|
|
||||||
{
|
|
||||||
builder.WithOrigins(allowedOrigins)
|
|
||||||
.SetIsOriginAllowedToAllowWildcardSubdomains()
|
|
||||||
.AllowAnyMethod()
|
|
||||||
.AllowAnyHeader()
|
|
||||||
.AllowCredentials();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Swagger
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen(options =>
|
|
||||||
{
|
|
||||||
options.SwaggerDoc("v1", new OpenApiInfo
|
|
||||||
{
|
|
||||||
Version = "v1",
|
|
||||||
Title = "signFLOW Absender-API",
|
|
||||||
Description = "Eine API zur Verwaltung der Erstellung, des Versands und der Nachverfolgung von Umschlägen in der signFLOW-Anwendung.",
|
|
||||||
Contact = new OpenApiContact
|
|
||||||
{
|
|
||||||
Name = "Digital Data GmbH",
|
|
||||||
Url = new Uri("https://digitaldata.works/digitale-signatur#kontakt"),
|
|
||||||
Email = "info-flow@digitaldata.works"
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
|
||||||
{
|
|
||||||
Name = "Authorization",
|
|
||||||
Type = SecuritySchemeType.Http,
|
|
||||||
Scheme = "bearer",
|
|
||||||
BearerFormat = "JWT",
|
|
||||||
In = ParameterLocation.Header,
|
|
||||||
Description = "JWT-Autorisierungs-Header unter Verwendung des Bearer-Schemas.",
|
|
||||||
});
|
|
||||||
|
|
||||||
options.AddSecurityRequirement(new OpenApiSecurityRequirement
|
|
||||||
{
|
|
||||||
{
|
|
||||||
new OpenApiSecurityScheme
|
|
||||||
{
|
|
||||||
Reference = new OpenApiReference
|
|
||||||
{
|
|
||||||
Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
|
|
||||||
Id = "Bearer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Array.Empty<string>()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var xmlFiles = Directory.GetFiles(AppContext.BaseDirectory, "*.xml");
|
|
||||||
foreach (var xmlFile in xmlFiles)
|
|
||||||
{
|
|
||||||
options.IncludeXmlComments(xmlFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
options.DocumentFilter<EnvelopeGenerator.API.Documentation.AuthProxyDocumentFilter>();
|
|
||||||
});
|
|
||||||
#if NET9_0_OR_GREATER
|
|
||||||
builder.Services.AddOpenApi();
|
|
||||||
#endif
|
|
||||||
|
|
||||||
//Add EF Core dbcontext
|
|
||||||
var useDbMigration = Environment.GetEnvironmentVariable("MIGRATION_TEST_MODE") == true.ToString() || config.GetValue<bool>("UseDbMigration");
|
|
||||||
var cnnStrName = useDbMigration ? "DbMigrationTest" : "Default";
|
|
||||||
var connStr = config.GetConnectionString(cnnStrName)
|
|
||||||
?? throw new InvalidOperationException($"Connection string '{cnnStrName}' is missing in the application configuration.");
|
|
||||||
|
|
||||||
builder.Services.Configure<ConnectionString>(cs => cs.Value = connStr);
|
|
||||||
|
|
||||||
builder.Services.AddDbContext<EGDbContext>(options => options.UseSqlServer(connStr));
|
|
||||||
|
|
||||||
builder.Services.AddAuthHubClient(config.GetSection("AuthClientParams"));
|
|
||||||
|
|
||||||
var authTokenKeys = config.GetOrDefault<AuthTokenKeys>();
|
|
||||||
|
|
||||||
builder.Services.AddAuthentication(options =>
|
|
||||||
{
|
|
||||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
|
||||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
|
||||||
})
|
|
||||||
.AddJwtBearer(AuthScheme.Sender, opt =>
|
|
||||||
{
|
|
||||||
opt.TokenValidationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
IssuerSigningKeyResolver = (token, securityToken, identifier, parameters) =>
|
|
||||||
{
|
|
||||||
var clientParams = deferredProvider.GetOptions<ClientParams>();
|
|
||||||
var publicKey = clientParams!.PublicKeys.Get(authTokenKeys.Issuer, authTokenKeys.Audience);
|
|
||||||
return [publicKey.SecurityKey];
|
|
||||||
},
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidIssuer = authTokenKeys.Issuer,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidAudience = authTokenKeys.Audience,
|
|
||||||
};
|
|
||||||
|
|
||||||
opt.Events = new JwtBearerEvents
|
|
||||||
{
|
|
||||||
OnMessageReceived = context =>
|
|
||||||
{
|
|
||||||
// if there is no token read related cookie or query string
|
|
||||||
if (context.Token is null) // if there is no token
|
|
||||||
{
|
|
||||||
if (context.Request.Cookies.TryGetValue(authTokenKeys.Cookie, out var cookieToken) && cookieToken is not null)
|
|
||||||
context.Token = cookieToken;
|
|
||||||
else if (context.Request.Query.TryGetValue(authTokenKeys.QueryString, out var queryStrToken))
|
|
||||||
context.Token = queryStrToken;
|
|
||||||
}
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
})
|
|
||||||
// Per-envelope receiver scheme: reads the JWT from the cookie named
|
|
||||||
// AuthTokenSignFLOWReceiver.{envelope_key} where envelope_key is the
|
|
||||||
// last path segment of the request URL.
|
|
||||||
// This enables simultaneous authentication for multiple envelopes
|
|
||||||
// within the same browser session.
|
|
||||||
.AddJwtBearer(AuthScheme.Receiver, opt =>
|
|
||||||
{
|
|
||||||
opt.TokenValidationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
IssuerSigningKeyResolver = (token, securityToken, identifier, parameters) =>
|
|
||||||
{
|
|
||||||
var clientParams = deferredProvider.GetOptions<ClientParams>();
|
|
||||||
var publicKey = clientParams!.PublicKeys.Get(authTokenKeys.Issuer, authTokenKeys.Audience);
|
|
||||||
return [publicKey.SecurityKey];
|
|
||||||
},
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidIssuer = authTokenKeys.Issuer,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidAudience = authTokenKeys.Audience,
|
|
||||||
};
|
|
||||||
|
|
||||||
opt.Events = new JwtBearerEvents
|
|
||||||
{
|
|
||||||
OnMessageReceived = context =>
|
|
||||||
{
|
|
||||||
var paths = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
|
|
||||||
// Derive the envelope key from the last route segment: /{envelope_key}
|
|
||||||
var envelopeKey = paths?.LastOrDefault();
|
|
||||||
|
|
||||||
if (envelopeKey is not null)
|
|
||||||
{
|
|
||||||
var cookieName = CookieNames.GetEnvelopeReceiverCookieName(authTokenKeys.Cookie, envelopeKey);
|
|
||||||
if (context.Request.Cookies.TryGetValue(cookieName, out var cookieToken) && cookieToken is not null)
|
|
||||||
context.Token = cookieToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
},
|
|
||||||
OnTokenValidated = context =>
|
|
||||||
{
|
|
||||||
var paths = context.Request.Path.Value?.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
|
||||||
var envelopeKey = paths?.LastOrDefault();
|
|
||||||
|
|
||||||
var sub = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
|
|
||||||
?? context.Principal?.FindFirst("sub")?.Value;
|
|
||||||
|
|
||||||
if (envelopeKey is null || sub != envelopeKey)
|
|
||||||
context.Fail("Envelope key in the path does not match the token subject.");
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Authentication
|
|
||||||
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
||||||
.AddCookie(options =>
|
|
||||||
{
|
|
||||||
options.Cookie.HttpOnly = true; // Makes the cookie inaccessible to client-side scripts for security
|
|
||||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; // Ensures cookies are sent over HTTPS only
|
|
||||||
options.Cookie.SameSite = SameSiteMode.Strict; // Protects against CSRF attacks by restricting how cookies are sent with requests from external sites
|
|
||||||
options.LoginPath = "/api/auth/login";
|
|
||||||
options.LogoutPath = "/api/auth/logout";
|
|
||||||
options.SlidingExpiration = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddAuthorizationBuilder()
|
|
||||||
.AddPolicy(AuthPolicy.SenderOrReceiver, policy => policy
|
|
||||||
.RequireRole(Role.Sender, Role.Receiver.Full)
|
|
||||||
.AddAuthenticationSchemes(AuthScheme.Sender, AuthScheme.Receiver))
|
|
||||||
.AddPolicy(AuthPolicy.Sender, policy => policy
|
|
||||||
.RequireRole(Role.Sender)
|
|
||||||
.AddAuthenticationSchemes(AuthScheme.Sender))
|
|
||||||
.AddPolicy(AuthPolicy.Receiver, policy => policy
|
|
||||||
.AddAuthenticationSchemes(AuthScheme.Receiver)
|
|
||||||
.RequireAuthenticatedUser()
|
|
||||||
.RequireRole(Role.Receiver.Full, "receiver"))
|
|
||||||
.AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA));
|
|
||||||
|
|
||||||
// User manager
|
|
||||||
#pragma warning disable CS0618 // Type or member is obsolete
|
|
||||||
builder.Services.AddUserManager<EGDbContext>();
|
|
||||||
#pragma warning restore CS0618 // Type or member is obsolete
|
|
||||||
|
|
||||||
// LDAP
|
|
||||||
builder.ConfigureBySection<DirectorySearchOptions>();
|
|
||||||
builder.Services.AddDirectorySearchService(config.GetSection("DirectorySearchOptions"));
|
|
||||||
|
|
||||||
// Localizer
|
|
||||||
builder.Services.AddCookieBasedLocalizer();
|
|
||||||
|
|
||||||
// Cache options
|
|
||||||
builder.Services.Configure<CacheOptions>(config.GetSection(CacheOptions.SectionName));
|
|
||||||
|
|
||||||
// Distributed Cache - SQL Server
|
|
||||||
builder.Services.AddDistributedSqlServerCache(options =>
|
|
||||||
{
|
|
||||||
config.GetSection("Cache:SqlServer").Bind(options);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(options.ConnectionString))
|
|
||||||
{
|
|
||||||
options.ConnectionString = connStr;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Envelope generator serives
|
|
||||||
#pragma warning disable CS0618 // Type or member is obsolete
|
|
||||||
builder.Services
|
|
||||||
.AddEnvelopeGeneratorInfrastructureServices(opt =>
|
|
||||||
{
|
|
||||||
opt.AddDbTriggerParams(config);
|
|
||||||
opt.AddDbContext((provider, options) =>
|
|
||||||
{
|
|
||||||
var logger = provider.GetRequiredService<ILogger<EGDbContext>>();
|
|
||||||
options.UseSqlServer(connStr)
|
|
||||||
.LogTo(log => logger.LogInformation("{log}", log), Microsoft.Extensions.Logging.LogLevel.Trace)
|
|
||||||
.EnableSensitiveDataLogging()
|
|
||||||
.EnableDetailedErrors();
|
|
||||||
});
|
|
||||||
opt.AddSQLExecutor(executor => executor.ConnectionString = connStr);
|
|
||||||
})
|
|
||||||
.AddEnvelopeGeneratorServices(config);
|
|
||||||
#pragma warning restore CS0618 // Type or member is obsolete
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
deferredProvider.Factory = () => app.Services;
|
|
||||||
|
|
||||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
|
||||||
|
|
||||||
#if NET9_0_OR_GREATER
|
|
||||||
app.MapOpenApi();
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
|
||||||
if (app.Environment.IsDevelopment() || (app.IsDevOrDiP() && config.GetValue<bool>("UseSwagger")))
|
|
||||||
{
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
app.MapScalarApiReference();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set CORS policy
|
|
||||||
app.UseCors("AllowSpecificOriginsPolicy");
|
|
||||||
|
|
||||||
// Localizer
|
|
||||||
string[] supportedCultureNames = ["de-DE", "en-US"];
|
|
||||||
IList<CultureInfo> list = [.. supportedCultureNames.Select(cn => new CultureInfo(cn))];
|
|
||||||
var cultureInfo = list.FirstOrDefault() ?? throw new InvalidOperationException("There is no supported culture.");
|
|
||||||
var requestLocalizationOptions = new RequestLocalizationOptions
|
|
||||||
{
|
|
||||||
SupportedCultures = list,
|
|
||||||
SupportedUICultures = list
|
|
||||||
};
|
|
||||||
requestLocalizationOptions.RequestCultureProviders.Add(new QueryStringRequestCultureProvider());
|
|
||||||
app.UseRequestLocalization(requestLocalizationOptions);
|
|
||||||
|
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
|
||||||
|
|
||||||
app.UseDefaultFiles();
|
|
||||||
app.UseStaticFiles();
|
|
||||||
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
|
|
||||||
app.MapControllers();
|
|
||||||
|
|
||||||
// Catch-all YARP proxy — only forward requests that are not swagger/scalar/openapi paths.
|
|
||||||
app.MapReverseProxy();
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.Error(ex, "Stopped program because of exception");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
|
||||||
-->
|
|
||||||
<Project>
|
|
||||||
<PropertyGroup>
|
|
||||||
<WebPublishMethod>Package</WebPublishMethod>
|
|
||||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
|
||||||
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
|
||||||
<SiteUrlToLaunchAfterPublish />
|
|
||||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
|
||||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
|
||||||
<ProjectGuid>5e0e17c0-ff5a-4246-bf87-1add85376a27</ProjectGuid>
|
|
||||||
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\signFLOW\PreRelease\API\net8\$(Version)\EnvelopeGenerator.API.zip</DesktopBuildPackageLocation>
|
|
||||||
<PackageAsSingleFile>true</PackageAsSingleFile>
|
|
||||||
<DeployIisAppPath>EnvelopeGenerator</DeployIisAppPath>
|
|
||||||
<_TargetId>IISWebDeployPackage</_TargetId>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
</PropertyGroup>
|
|
||||||
</Project>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
|
||||||
"iisSettings": {
|
|
||||||
"windowsAuthentication": false,
|
|
||||||
"anonymousAuthentication": true,
|
|
||||||
"iisExpress": {
|
|
||||||
"applicationUrl": "http://localhost:4176",
|
|
||||||
"sslPort": 44300
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"profiles": {
|
|
||||||
"http": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"launchUrl": "swagger",
|
|
||||||
"applicationUrl": "http://localhost:5131",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"https": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"launchUrl": "sender",
|
|
||||||
"applicationUrl": "https://localhost:8088;http://localhost:5131",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"angular": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"launchUrl": "",
|
|
||||||
"applicationUrl": "https://localhost:7174;http://localhost:5131",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"IIS Express": {
|
|
||||||
"commandName": "IISExpress",
|
|
||||||
"launchBrowser": true,
|
|
||||||
"launchUrl": "swagger",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ReverseProxy": {
|
|
||||||
"Clusters": {
|
|
||||||
"receiver-ui": {
|
|
||||||
"Destinations": {
|
|
||||||
"primary": {
|
|
||||||
"Address": "https://localhost:52936"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AuthClientParams": {
|
|
||||||
"Url": "http://172.24.12.39:9090/auth-hub",
|
|
||||||
"PublicKeys": [
|
|
||||||
{
|
|
||||||
"Issuer": "auth.digitaldata.works",
|
|
||||||
"Audience": "sign-flow.digitaldata.works"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"RetryDelay": "00:00:05"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
{
|
|
||||||
"UseSwagger": true,
|
|
||||||
"UseDbMigration": false,
|
|
||||||
"DiPMode": true,
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
"AllowedOrigins": [ "http://localhost:4200", "http://172.24.12.39:9090", "https://localhost:8088", "http://localhost:5131", "http://localhost:7192" ],
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"Default": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;",
|
|
||||||
"DbMigrationTest": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM_DATA_MIGR_TEST;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
|
|
||||||
},
|
|
||||||
"DirectorySearchOptions": {
|
|
||||||
"ServerName": "DD-VMP01-DC01",
|
|
||||||
"Root": "DC=dd-gan,DC=local,DC=digitaldata,DC=works",
|
|
||||||
"UserCacheExpirationDays": 1,
|
|
||||||
"CustomSearchFilters": {
|
|
||||||
"User": "(&(objectClass=user)(sAMAccountName=*))",
|
|
||||||
"Group": "(&(objectClass=group)(samAccountName=*))"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AuthClientParams": {
|
|
||||||
"Url": "http://172.24.12.39:9090/auth-hub",
|
|
||||||
"PublicKeys": [
|
|
||||||
{
|
|
||||||
"Issuer": "auth.digitaldata.works",
|
|
||||||
"Audience": "sign-flow.digitaldata.works"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"RetryDelay": "00:00:05"
|
|
||||||
},
|
|
||||||
"AuthTokenKeys": {
|
|
||||||
"Cookie": "AuthToken",
|
|
||||||
"QueryString": "AuthToken",
|
|
||||||
"Issuer": "auth.digitaldata.works",
|
|
||||||
"Audience": "sign-flow.digitaldata.works"
|
|
||||||
},
|
|
||||||
"PSPDFKitLicenseKey": "SXCtGGY9XA-31OGUXQK-r7c6AkdLGPm2ljuyDr1qu0kkhLvydg-Do-fxpNUF4Rq3fS_xAnZRNFRHbXpE6sQ2BMcCSVTcXVJO6tPviexjpiT-HnrDEySlUERJnnvh-tmeOWprxS6BySPnSILkmaVQtUfOIUS-cUbvvEYHTvQBKbSF8di4XHQFyfv49ihr51axm3NVV3AXwh2EiKL5C5XdqBZ4sQ4O7vXBjM2zvxdPxlxdcNYmiU83uAzw7B83O_jubPzya4CdUHh_YH7Nlp2gP56MeG1Sw2JhMtfG3Rj14Sg4ctaeL9p6AEWca5dDjJ2li5tFIV2fQSsw6A_cowLu0gtMm5i8IfJXeIcQbMC2-0wGv1oe9hZYJvFMdzhTM_FiejM0agemxt3lJyzuyP8zbBSOgp7Si6A85krLWPZptyZBTG7pp7IHboUHfPMxCXqi-zMsqewOJtQBE2mjntU-lPryKnssOpMPfswwQX7QSkJYV5EMqNmEhQX6mEkp2wcqFzMC7bJQew1aO4pOpvChUaMvb1vgRek0HxLag0nwQYX2YrYGh7F_xXJs-8HNwJe8H0-eW4x4faayCgM5rB5772CCCsD9ThZcvXFrjNHHLGJ8WuBUFm6LArvSfFQdii_7j-_sqHMpeKZt26NFgivj1A==",
|
|
||||||
"Content-Security-Policy": [ // The first format parameter {0} will be replaced by the nonce value.
|
|
||||||
"default-src 'self'",
|
|
||||||
"script-src 'self' 'nonce-{0}' 'unsafe-eval'",
|
|
||||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com:*",
|
|
||||||
"img-src 'self' data: https: blob:",
|
|
||||||
"font-src 'self' https://fonts.gstatic.com:*",
|
|
||||||
"connect-src 'self' https://nominatim.openstreetmap.org:* http://localhost:* https://localhost:* ws://localhost:* wss://localhost:* blob:",
|
|
||||||
"frame-src 'self'",
|
|
||||||
"media-src 'self'",
|
|
||||||
"object-src 'self'"
|
|
||||||
],
|
|
||||||
"NLog": {
|
|
||||||
"throwConfigExceptions": true,
|
|
||||||
"variables": {
|
|
||||||
"logDirectory": "E:\\LogFiles\\Digital Data\\signFlow",
|
|
||||||
"logFileNamePrefix": "${shortdate}-ECM.EnvelopeGenerator.Web"
|
|
||||||
},
|
|
||||||
"targets": {
|
|
||||||
"infoLogs": {
|
|
||||||
"type": "File",
|
|
||||||
"fileName": "${logDirectory}\\${logFileNamePrefix}-Info.log",
|
|
||||||
"maxArchiveDays": 30
|
|
||||||
},
|
|
||||||
"errorLogs": {
|
|
||||||
"type": "File",
|
|
||||||
"fileName": "${logDirectory}\\${logFileNamePrefix}-Error.log",
|
|
||||||
"maxArchiveDays": 30
|
|
||||||
},
|
|
||||||
"criticalLogs": {
|
|
||||||
"type": "File",
|
|
||||||
"fileName": "${logDirectory}\\${logFileNamePrefix}-Critical.log",
|
|
||||||
"maxArchiveDays": 30
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Trace, Debug, Info, Warn, Error and *Fatal*
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"logger": "*",
|
|
||||||
"minLevel": "Info",
|
|
||||||
"maxLevel": "Warn",
|
|
||||||
"writeTo": "infoLogs"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"logger": "*",
|
|
||||||
"level": "Error",
|
|
||||||
"writeTo": "errorLogs"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"logger": "*",
|
|
||||||
"level": "Fatal",
|
|
||||||
"writeTo": "criticalLogs"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"ContactLink": {
|
|
||||||
"Label": "Kontakt",
|
|
||||||
"Href": "https://digitaldata.works/",
|
|
||||||
"HrefLang": "de",
|
|
||||||
"Target": "_blank",
|
|
||||||
"Title": "Digital Data GmbH"
|
|
||||||
},
|
|
||||||
/* Resx naming format is -> Resource.language.resx (eg: Resource.de_DE.resx).
|
|
||||||
To add a new language, first you should write the required resx file.
|
|
||||||
first is the default culture name. */
|
|
||||||
"Cultures": [
|
|
||||||
{
|
|
||||||
"Language": "de-DE",
|
|
||||||
"FIClass": "fi-de"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Language": "en-US",
|
|
||||||
"FIClass": "fi-us"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"DisableMultiLanguage": false,
|
|
||||||
"Regexes": [
|
|
||||||
{
|
|
||||||
"Pattern": "/^\\p{L}+(?:([\\ \\-\\']|(\\.\\ ))\\p{L}+)*$/u",
|
|
||||||
"Name": "City",
|
|
||||||
"Platforms": [ ".NET" ]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Pattern": "/^[a-zA-Z\\u0080-\\u024F]+(?:([\\ \\-\\']|(\\.\\ ))[a-zA-Z\\u0080-\\u024F]+)*$/",
|
|
||||||
"Name": "City",
|
|
||||||
"Platforms": [ "javascript" ]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"CustomImages": {
|
|
||||||
"App": {
|
|
||||||
"Src": "/img/DD_signFLOW_LOGO.png",
|
|
||||||
"Classes": {
|
|
||||||
"Main": "signFlow-logo"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Company": {
|
|
||||||
"Src": "/img/digital_data.svg",
|
|
||||||
"Classes": {
|
|
||||||
"Show": "dd-show-logo",
|
|
||||||
"Locked": "dd-locked-logo"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"DispatcherParams": {
|
|
||||||
"SendingProfile": 1,
|
|
||||||
"AddedWho": "DDEnvelopGenerator",
|
|
||||||
"ReminderTypeId": 202377,
|
|
||||||
"EmailAttmt1": ""
|
|
||||||
},
|
|
||||||
"MailParams": {
|
|
||||||
"Placeholders": {
|
|
||||||
"[NAME_PORTAL]": "signFlow",
|
|
||||||
"[SIGNATURE_TYPE]": "signieren",
|
|
||||||
"[REASON]": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"GtxMessagingParams": {
|
|
||||||
"Uri": "https://rest.gtx-messaging.net",
|
|
||||||
"Path": "smsc/sendsms/f566f7e5-bdf2-4a9a-bf52-ed88215a432e/json",
|
|
||||||
"Headers": {},
|
|
||||||
"QueryParams": {
|
|
||||||
"from": "signFlow"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"TFARegParams": {
|
|
||||||
"TimeLimit": "00:30:00"
|
|
||||||
},
|
|
||||||
"DbTriggerParams": {
|
|
||||||
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
|
||||||
"EnvelopeHistory": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
|
||||||
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
|
||||||
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
|
||||||
"Receiver": [],
|
|
||||||
"EmailTemplate": [ "TBSIG_EMAIL_TEMPLATE_AFT_UPD" ]
|
|
||||||
},
|
|
||||||
"Cache": {
|
|
||||||
"SignatureCacheExpiration": null,
|
|
||||||
"SqlServer": {
|
|
||||||
"ConnectionString": null,
|
|
||||||
"SchemaName": "dbo",
|
|
||||||
"TableName": "TBDD_CACHE"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"MainPageTitle": null,
|
|
||||||
"AnnotationParams": {
|
|
||||||
"Background": {
|
|
||||||
"Margin": 0.20,
|
|
||||||
"BackgroundColor": {
|
|
||||||
"R": 222,
|
|
||||||
"G": 220,
|
|
||||||
"B": 215
|
|
||||||
},
|
|
||||||
"BorderColor": {
|
|
||||||
"R": 204,
|
|
||||||
"G": 202,
|
|
||||||
"B": 198
|
|
||||||
},
|
|
||||||
"BorderStyle": "underline",
|
|
||||||
"BorderWidth": 4
|
|
||||||
},
|
|
||||||
"DefaultAnnotation": {
|
|
||||||
"Width": 1,
|
|
||||||
"Height": 0.5,
|
|
||||||
"MarginTop": 1
|
|
||||||
},
|
|
||||||
"Annotations": [
|
|
||||||
{
|
|
||||||
"Name": "Signature",
|
|
||||||
"MarginTop": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "PositionLabel",
|
|
||||||
"VerBoundAnnotName": "Signature",
|
|
||||||
"WidthRatio": 1.2,
|
|
||||||
"HeightRatio": 0.5,
|
|
||||||
"MarginTopRatio": 0.22
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "Position",
|
|
||||||
"VerBoundAnnotName": "PositionLabel",
|
|
||||||
"WidthRatio": 1.2,
|
|
||||||
"HeightRatio": 0.5,
|
|
||||||
"MarginTopRatio": -0.05
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "CityLabel",
|
|
||||||
"VerBoundAnnotName": "Position",
|
|
||||||
"WidthRatio": 1.2,
|
|
||||||
"HeightRatio": 0.5,
|
|
||||||
"MarginTopRatio": 0.05
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "City",
|
|
||||||
"VerBoundAnnotName": "CityLabel",
|
|
||||||
"WidthRatio": 1.2,
|
|
||||||
"HeightRatio": 0.5,
|
|
||||||
"MarginTopRatio": -0.05
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "DateLabel",
|
|
||||||
"VerBoundAnnotName": "City",
|
|
||||||
"WidthRatio": 1.55,
|
|
||||||
"HeightRatio": 0.5,
|
|
||||||
"MarginTopRatio": 0.05
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "Date",
|
|
||||||
"VerBoundAnnotName": "DateLabel",
|
|
||||||
"WidthRatio": 1.55,
|
|
||||||
"HeightRatio": 0.5,
|
|
||||||
"MarginTopRatio": -0.1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
{
|
|
||||||
"ReverseProxy": {
|
|
||||||
"Routes": {
|
|
||||||
"receiver-ui-root": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 300,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
},
|
|
||||||
"Transforms": [
|
|
||||||
{ "PathSet": "/index.html" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"receiver-ui-sender": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 100,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/sender/{**catch-all}",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
},
|
|
||||||
"Transforms": [
|
|
||||||
{ "PathSet": "/index.html" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"receiver-ui-envelope": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 100,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/envelope/{EnvelopeKey}",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
},
|
|
||||||
"Transforms": [
|
|
||||||
{ "PathSet": "/index.html" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"receiver-ui-envelope-dxreportviewer": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 90,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/envelope/{EnvelopeKey}/DxReportViewer",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
},
|
|
||||||
"Transforms": [
|
|
||||||
{ "PathSet": "/index.html" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"receiver-ui-blazor-framework": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 50,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/_framework/{**catch-all}",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"receiver-ui-blazor-content": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 50,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/_content/{**catch-all}",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"receiver-ui-static-css": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 200,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/css/{**catch-all}",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
},
|
|
||||||
"Transforms": [
|
|
||||||
{
|
|
||||||
"ResponseHeader": "Cache-Control",
|
|
||||||
"Set": "no-cache, no-store, must-revalidate",
|
|
||||||
"When": "Always"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"receiver-ui-static-js": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 200,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/js/{**catch-all}",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
},
|
|
||||||
"Transforms": [
|
|
||||||
{
|
|
||||||
"ResponseHeader": "Cache-Control",
|
|
||||||
"Set": "no-cache, no-store, must-revalidate",
|
|
||||||
"When": "Always"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"receiver-ui-fake-data": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 200,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/fake-data/{**catch-all}",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"receiver-ui-appsettings": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 50,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/appsettings.json",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"receiver-ui-appsettings-dev": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 50,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/appsettings.Development.json",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"receiver-ui-styles": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 50,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/EnvelopeGenerator.ReceiverUI.styles.css",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"receiver-ui-fonts": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 200,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/fonts/{**catch-all}",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"receiver-ui-images": {
|
|
||||||
"ClusterId": "receiver-ui",
|
|
||||||
"Order": 200,
|
|
||||||
"Match": {
|
|
||||||
"Path": "/images/{**catch-all}",
|
|
||||||
"Methods": [ "GET", "HEAD" ]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"auth-login": {
|
|
||||||
"ClusterId": "auth-hub",
|
|
||||||
"Match": {
|
|
||||||
"Path": "/api/auth",
|
|
||||||
"Methods": [ "POST" ]
|
|
||||||
},
|
|
||||||
"Transforms": [
|
|
||||||
{ "PathSet": "/api/auth/sign-flow" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"auth-envelope-receiver-login": {
|
|
||||||
"ClusterId": "auth-hub",
|
|
||||||
"Match": {
|
|
||||||
"Path": "/api/Auth/envelope-receiver/{key}",
|
|
||||||
"Methods": [ "POST" ]
|
|
||||||
},
|
|
||||||
"Transforms": [
|
|
||||||
{ "PathPattern": "/api/auth/envelope-receiver/{key}" },
|
|
||||||
{
|
|
||||||
"QueryValueParameter": "cookie",
|
|
||||||
"Set": "true"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"Clusters": {
|
|
||||||
"receiver-ui": {
|
|
||||||
"Destinations": {
|
|
||||||
"primary": {
|
|
||||||
"Address": "https://localhost:52936"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"auth-hub": {
|
|
||||||
"Destinations": {
|
|
||||||
"primary": {
|
|
||||||
"Address": "http://172.24.12.39:9090"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ public sealed record Signature
|
|||||||
public string? Position
|
public string? Position
|
||||||
{
|
{
|
||||||
get => _position;
|
get => _position;
|
||||||
init => _position = string.IsNullOrWhiteSpace(value) ? value : null;
|
init => _position = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -62,4 +62,4 @@ public sealed record Signature
|
|||||||
/// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025"
|
/// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025"
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public required string Place { get; init; }
|
public required string Place { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,18 +65,21 @@ public static class DependencyInjection
|
|||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
|
||||||
|
|
||||||
// 1. AnnotationBehavior - Saves annotations (executes second)
|
// 1. AnnotationBehavior - Saves annotations (executes second)
|
||||||
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SaveSignatureBehavior>();
|
||||||
|
|
||||||
|
// 2. AnnotationBehavior - Saves annotations for legacy web flow
|
||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, AnnotationBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, AnnotationBehavior>();
|
||||||
|
|
||||||
// 2. DocStatusBehavior - Creates document status (executes third)
|
// 3. DocStatusBehavior - Creates document status
|
||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, DocStatusBehavior>();
|
||||||
|
|
||||||
// 3. HistoryBehavior - Records history (executes fourth)
|
// 4. HistoryBehavior - Records history
|
||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, HistoryBehavior>();
|
||||||
|
|
||||||
// 4. SendSignedMailBehavior - Sends notification email (executes LAST, only if all previous succeed)
|
// 5. SendSignedMailBehavior - Sends notification email (executes LAST)
|
||||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
|
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
|
||||||
});
|
});
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,11 @@ public class EnvelopeReceiverResolutionBehavior : IPipelineBehavior<SigningComma
|
|||||||
// If EnvelopeReceiver is not provided, query it from database
|
// If EnvelopeReceiver is not provided, query it from database
|
||||||
if (request.EnvelopeReceiver is null)
|
if (request.EnvelopeReceiver is null)
|
||||||
{
|
{
|
||||||
var er = await _erRepo.Query.Where(request, notnull: true).SingleOrDefaultAsync(cancellationToken)
|
var er = await _erRepo.Query
|
||||||
|
.Where(request, notnull: true)
|
||||||
|
.Include(x => x.Envelope)
|
||||||
|
.Include(x => x.Receiver)
|
||||||
|
.SingleOrDefaultAsync(cancellationToken)
|
||||||
?? throw new NotFoundException("EnvelopeReceiver not found");
|
?? throw new NotFoundException("EnvelopeReceiver not found");
|
||||||
|
|
||||||
request.SetEnvelopeReceiver(_mapper.Map<EnvelopeReceiverDto>(er));
|
request.SetEnvelopeReceiver(_mapper.Map<EnvelopeReceiverDto>(er));
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ public class SaveSignatureBehavior : IPipelineBehavior<SigningCommand, Unit>
|
|||||||
throw new BadRequestException($"Signatures are required for saving signature behavior.");
|
throw new BadRequestException($"Signatures are required for saving signature behavior.");
|
||||||
|
|
||||||
var elements = await _elementRepo
|
var elements = await _elementRepo
|
||||||
.Where(e => e.Document.EnvelopeId == request.Envelope.Id)
|
.Where(e => e.Document.EnvelopeId == request.EnvelopeReceiver.EnvelopeId)
|
||||||
.Where(e => e.ReceiverId == request.Receiver.Id)
|
.Where(e => e.ReceiverId == request.EnvelopeReceiver.ReceiverId)
|
||||||
.ToListAsync(cancel);
|
.ToListAsync(cancel);
|
||||||
|
|
||||||
foreach (var element in elements)
|
foreach (var element in elements)
|
||||||
|
|||||||
@@ -204,16 +204,24 @@ public abstract class EGDbContextBase : DbContext
|
|||||||
|
|
||||||
#region Trigger
|
#region Trigger
|
||||||
// Configure entities to handle database triggers
|
// Configure entities to handle database triggers
|
||||||
void AddTrigger<T>() where T : class => _triggers
|
void AddTrigger<T>() where T : class
|
||||||
.Where(t => t.Key == typeof(T).Name)
|
{
|
||||||
.SelectMany(t => t.Value)
|
var entityName = typeof(T).Name;
|
||||||
.ForEach(tName =>
|
|
||||||
|
var configuredTriggerNames = _triggers
|
||||||
|
.Where(t => t.Key == entityName)
|
||||||
|
.SelectMany(t => t.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var triggerName in configuredTriggerNames)
|
||||||
{
|
{
|
||||||
#if NET
|
#if NET
|
||||||
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(tName));
|
modelBuilder.Entity<T>().ToTable(tb => tb.HasTrigger(triggerName));
|
||||||
#endif
|
#endif
|
||||||
_logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", tName, typeof(T).Name);
|
_logger?.LogInformation("Trigger '{triggerName}' has been added to the '{entityName}' entity.", triggerName, entityName);
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: call add trigger methods with attributes and reflection
|
// TODO: call add trigger methods with attributes and reflection
|
||||||
AddTrigger<Config>();
|
AddTrigger<Config>();
|
||||||
@@ -243,4 +251,4 @@ public abstract class EGDbContextBase : DbContext
|
|||||||
|
|
||||||
#if NETFRAMEWORK
|
#if NETFRAMEWORK
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
|
public sealed record SignatureSubmitDto
|
||||||
|
{
|
||||||
|
public required int Id { get; init; }
|
||||||
|
public required string DataUrl { get; init; }
|
||||||
|
public required string FullName { get; init; }
|
||||||
|
public string? Position { get; init; }
|
||||||
|
public required string Place { get; init; }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Server.Client.Models;
|
using EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Server.Client.Services;
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
@@ -21,4 +22,32 @@ public class SignatureService(IHttpClientFactory httpClientFactory)
|
|||||||
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
|
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
|
||||||
return result ?? [];
|
return result ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SubmitAsync(string envelopeKey, IEnumerable<SignatureSubmitDto> signatures, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
|
||||||
|
var payload = signatures
|
||||||
|
.Select(signature => new Signature
|
||||||
|
{
|
||||||
|
Id = signature.Id,
|
||||||
|
DataUrl = signature.DataUrl,
|
||||||
|
FullName = signature.FullName,
|
||||||
|
Position = signature.Position,
|
||||||
|
Place = signature.Place
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var response = await http.PostAsJsonAsync(
|
||||||
|
$"/api/Signature/submit/{Uri.EscapeDataString(envelopeKey)}",
|
||||||
|
payload,
|
||||||
|
cancel);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancel);
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Failed to submit signature for envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="sender-title">Umschlag-Übersicht</div>
|
<div class="sender-title" aria-label="signFLOW Portal - Übersicht">
|
||||||
|
<span class="sender-title-main">signFLOW Portal</span>
|
||||||
|
<span class="sender-title-sub">Übersicht</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* ── Kunden-Logo (Mitte) ───────────────────────────────────────── *@
|
@* ── Kunden-Logo (Mitte) ───────────────────────────────────────── *@
|
||||||
@@ -51,7 +54,7 @@
|
|||||||
@if (_companyLogo is not null && !string.IsNullOrEmpty(_companyLogo.Src))
|
@if (_companyLogo is not null && !string.IsNullOrEmpty(_companyLogo.Src))
|
||||||
{
|
{
|
||||||
<img src="@_companyLogo.Src"
|
<img src="@_companyLogo.Src"
|
||||||
style="height: @_companyLogo.Height"
|
style="--sf-company-logo-height: @_companyLogo.Height"
|
||||||
class="@_companyLogo.GetClass("Show")"
|
class="@_companyLogo.GetClass("Show")"
|
||||||
alt="Kunden-Logo" />
|
alt="Kunden-Logo" />
|
||||||
}
|
}
|
||||||
@@ -104,6 +107,40 @@
|
|||||||
Vorschau
|
Vorschau
|
||||||
</button>
|
</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">
|
||||||
|
<option value="xlsx">XLSX</option>
|
||||||
|
<option value="csv">CSV</option>
|
||||||
|
<option value="pdf">PDF</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label class="sender-export-selected">
|
||||||
|
<input type="checkbox" @bind="_exportSelectedRowsOnly" />
|
||||||
|
<span class="sender-export-selected-text">
|
||||||
|
<span>Nur</span>
|
||||||
|
<span>ausgewählte</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="sender-btn sender-btn--export"
|
||||||
|
@onclick="ExportSelectedFormatAsync"
|
||||||
|
disabled="@IsExportDisabled()"
|
||||||
|
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" />
|
||||||
|
</svg>
|
||||||
|
@if (_isExporting)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>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="Abmelden">
|
||||||
@if (_isLoggingOut)
|
@if (_isLoggingOut)
|
||||||
{
|
{
|
||||||
@@ -171,19 +208,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sender-grid-wrapper">
|
<div class="sender-grid-wrapper">
|
||||||
|
|
||||||
@* Grid persistence hint — informs users their layout is auto-saved *@
|
|
||||||
<div class="sf-grid-persistence-hint">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" 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="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z" />
|
|
||||||
</svg>
|
|
||||||
Die Größe und Position der Spalten wird automatisch für Sie gespeichert.
|
|
||||||
</div>
|
|
||||||
@if (_activeTab == "active")
|
@if (_activeTab == "active")
|
||||||
{
|
{
|
||||||
<DxGrid Data="@_activeEnvelopes"
|
<DxGrid Data="@_activeEnvelopes"
|
||||||
@ref="_gridActive"
|
@ref="_gridActive"
|
||||||
|
KeyFieldName="Id"
|
||||||
ShowFilterRow="true"
|
ShowFilterRow="true"
|
||||||
ShowSearchBox="true"
|
ShowSearchBox="true"
|
||||||
AllowColumnReorder="true"
|
AllowColumnReorder="true"
|
||||||
@@ -213,7 +242,7 @@
|
|||||||
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
|
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
|
||||||
</CellDisplayTemplate>
|
</CellDisplayTemplate>
|
||||||
</DxGridDataColumn>
|
</DxGridDataColumn>
|
||||||
<DxGridDataColumn FieldName="Status" Caption="Status">
|
<DxGridDataColumn FieldName="Status" Caption="Status" ExportEnabled="false">
|
||||||
<CellDisplayTemplate Context="cellContext">
|
<CellDisplayTemplate Context="cellContext">
|
||||||
@{
|
@{
|
||||||
var envelope = cellContext.DataItem as EnvelopeDto;
|
var envelope = cellContext.DataItem as EnvelopeDto;
|
||||||
@@ -228,7 +257,7 @@
|
|||||||
}
|
}
|
||||||
</CellDisplayTemplate>
|
</CellDisplayTemplate>
|
||||||
</DxGridDataColumn>
|
</DxGridDataColumn>
|
||||||
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger">
|
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger" ExportEnabled="false">
|
||||||
<CellDisplayTemplate Context="cellContext">
|
<CellDisplayTemplate Context="cellContext">
|
||||||
@{
|
@{
|
||||||
var envelope = cellContext.DataItem as EnvelopeDto;
|
var envelope = cellContext.DataItem as EnvelopeDto;
|
||||||
@@ -308,6 +337,7 @@
|
|||||||
{
|
{
|
||||||
<DxGrid Data="@_completedEnvelopes"
|
<DxGrid Data="@_completedEnvelopes"
|
||||||
@ref="_gridCompleted"
|
@ref="_gridCompleted"
|
||||||
|
KeyFieldName="Id"
|
||||||
ShowFilterRow="true"
|
ShowFilterRow="true"
|
||||||
ShowSearchBox="true"
|
ShowSearchBox="true"
|
||||||
PageSize="20"
|
PageSize="20"
|
||||||
@@ -334,7 +364,7 @@
|
|||||||
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
|
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
|
||||||
</CellDisplayTemplate>
|
</CellDisplayTemplate>
|
||||||
</DxGridDataColumn>
|
</DxGridDataColumn>
|
||||||
<DxGridDataColumn FieldName="Status" Caption="Status">
|
<DxGridDataColumn FieldName="Status" Caption="Status" ExportEnabled="false">
|
||||||
<CellDisplayTemplate Context="cellContext">
|
<CellDisplayTemplate Context="cellContext">
|
||||||
@{
|
@{
|
||||||
var envelope = cellContext.DataItem as EnvelopeDto;
|
var envelope = cellContext.DataItem as EnvelopeDto;
|
||||||
@@ -349,7 +379,7 @@
|
|||||||
}
|
}
|
||||||
</CellDisplayTemplate>
|
</CellDisplayTemplate>
|
||||||
</DxGridDataColumn>
|
</DxGridDataColumn>
|
||||||
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger">
|
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger" ExportEnabled="false">
|
||||||
<CellDisplayTemplate Context="cellContext">
|
<CellDisplayTemplate Context="cellContext">
|
||||||
@{
|
@{
|
||||||
var envelope = cellContext.DataItem as EnvelopeDto;
|
var envelope = cellContext.DataItem as EnvelopeDto;
|
||||||
@@ -514,6 +544,11 @@
|
|||||||
private byte[]? _previewPdfBytes = null;
|
private byte[]? _previewPdfBytes = null;
|
||||||
private string? _previewErrorMessage = null;
|
private string? _previewErrorMessage = null;
|
||||||
|
|
||||||
|
// Phase 6e (step 1): UI state only. Export logic is added in step 2.
|
||||||
|
private string _selectedExportFormat = "xlsx";
|
||||||
|
private bool _exportSelectedRowsOnly = false;
|
||||||
|
private bool _isExporting = false;
|
||||||
|
|
||||||
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
|
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
|
||||||
private const string GridKeyActive = "sender.active-envelopes";
|
private const string GridKeyActive = "sender.active-envelopes";
|
||||||
private const string GridKeyCompleted = "sender.completed-envelopes";
|
private const string GridKeyCompleted = "sender.completed-envelopes";
|
||||||
@@ -572,6 +607,66 @@
|
|||||||
await LoadEnvelopesAsync();
|
await LoadEnvelopesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool IsExportDisabled()
|
||||||
|
{
|
||||||
|
if (_isLoading || _isExporting)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return _exportSelectedRowsOnly && _selectedEnvelope is null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ExportSelectedFormatAsync()
|
||||||
|
{
|
||||||
|
var grid = _activeTab == "active" ? _gridActive : _gridCompleted;
|
||||||
|
if (grid is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_isExporting = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tabToken = _activeTab == "completed" ? "abgeschlossen" : "aktiv";
|
||||||
|
var stamp = DateTime.Now.ToString("yyyy-MM-dd");
|
||||||
|
var exportBaseName = $"Umschlaege_{tabToken}_{stamp}";
|
||||||
|
|
||||||
|
switch (_selectedExportFormat)
|
||||||
|
{
|
||||||
|
case "csv":
|
||||||
|
await grid.ExportToCsvAsync(
|
||||||
|
exportBaseName,
|
||||||
|
new GridCsvExportOptions
|
||||||
|
{
|
||||||
|
ExportSelectedRowsOnly = _exportSelectedRowsOnly
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "pdf":
|
||||||
|
await grid.ExportToPdfAsync(
|
||||||
|
exportBaseName,
|
||||||
|
new GridPdfExportOptions
|
||||||
|
{
|
||||||
|
ExportSelectedRowsOnly = _exportSelectedRowsOnly,
|
||||||
|
SelectedRowsExportMode = GridSelectedRowsExportMode.KeepGrouping
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await grid.ExportToXlsxAsync(
|
||||||
|
exportBaseName,
|
||||||
|
new GridXlExportOptions
|
||||||
|
{
|
||||||
|
ExportSelectedRowsOnly = _exportSelectedRowsOnly,
|
||||||
|
SelectedRowsExportMode = GridSelectedRowsExportMode.KeepGrouping
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isExporting = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void CreateEnvelope()
|
void CreateEnvelope()
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/sender/editor");
|
Navigation.NavigateTo("/sender/editor");
|
||||||
@@ -753,4 +848,4 @@
|
|||||||
var json = JsonSerializer.Serialize(e.Layout);
|
var json = JsonSerializer.Serialize(e.Layout);
|
||||||
await UserPreferencesService.SaveGridLayoutAsync(GridKeyCompleted, json);
|
await UserPreferencesService.SaveGridLayoutAsync(GridKeyCompleted, json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
||||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||||
|
@inject SignatureService SignatureService
|
||||||
@inject AppVersionService AppVersion
|
@inject AppVersionService AppVersion
|
||||||
@inject IMemoryCache MemoryCache
|
@inject IMemoryCache MemoryCache
|
||||||
@inject ILogger<ReceiverSignedPage> Logger
|
@inject ILogger<ReceiverSignedPage> Logger
|
||||||
@@ -122,6 +123,12 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_submitErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger mb-0" role="alert" style="font-size: 0.85rem;">
|
||||||
|
@_submitErrorMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</BodyContentTemplate>
|
</BodyContentTemplate>
|
||||||
<FooterContentTemplate>
|
<FooterContentTemplate>
|
||||||
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
|
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
|
||||||
@@ -161,17 +168,48 @@
|
|||||||
// ----- Submit / logout state -----
|
// ----- Submit / logout state -----
|
||||||
bool _isLoggingOut = false;
|
bool _isLoggingOut = false;
|
||||||
bool _submitConfirmVisible = false;
|
bool _submitConfirmVisible = false;
|
||||||
|
string? _submitErrorMessage;
|
||||||
|
|
||||||
void OpenSubmitConfirmPopup() => _submitConfirmVisible = true;
|
void OpenSubmitConfirmPopup() => _submitConfirmVisible = true;
|
||||||
|
|
||||||
async Task SubmitAndLogoutAsync()
|
async Task SubmitAndLogoutAsync()
|
||||||
{
|
{
|
||||||
if (_isLoggingOut) return;
|
if (_isLoggingOut) return;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(EnvelopeKey) || _sig is null)
|
||||||
|
return;
|
||||||
|
|
||||||
_isLoggingOut = true;
|
_isLoggingOut = true;
|
||||||
_submitConfirmVisible = false;
|
_submitConfirmVisible = false;
|
||||||
|
_submitErrorMessage = null;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey!);
|
|
||||||
Navigation.NavigateTo("/", forceLoad: true);
|
try
|
||||||
|
{
|
||||||
|
if (_signatures.Count == 0)
|
||||||
|
throw new InvalidOperationException("No signature fields found for receiver.");
|
||||||
|
|
||||||
|
var payload = _signatures.Select(field => new SignatureSubmitDto
|
||||||
|
{
|
||||||
|
Id = field.Id,
|
||||||
|
DataUrl = _sig.DataUrl,
|
||||||
|
FullName = _sig.FullName,
|
||||||
|
Position = string.IsNullOrWhiteSpace(_sig.Position) ? null : _sig.Position,
|
||||||
|
Place = _sig.Place
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
await SignatureService.SubmitAsync(EnvelopeKey, payload);
|
||||||
|
|
||||||
|
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey);
|
||||||
|
Navigation.NavigateTo("/", forceLoad: true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Failed to finalize signature for {EnvelopeKey}", EnvelopeKey);
|
||||||
|
_submitErrorMessage = "Die Unterschrift konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.";
|
||||||
|
_isLoggingOut = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
using EnvelopeGenerator.Server.Extensions;
|
using EnvelopeGenerator.Server.Extensions;
|
||||||
using EnvelopeGenerator.Application.Common.Dto;
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Application.Common.Extensions;
|
using EnvelopeGenerator.Application.Common.Extensions;
|
||||||
|
using EnvelopeGenerator.Application.DocReceiverElements.Commands;
|
||||||
using EnvelopeGenerator.Application.Documents.Queries;
|
using EnvelopeGenerator.Application.Documents.Queries;
|
||||||
|
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||||
|
using EnvelopeGenerator.Application.Histories.Queries;
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -18,13 +21,15 @@ namespace EnvelopeGenerator.Server.Controllers;
|
|||||||
public class SignatureController : ControllerBase
|
public class SignatureController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator;
|
||||||
|
private readonly ILogger<SignatureController> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of <see cref="SignatureController"/>.
|
/// Initializes a new instance of <see cref="SignatureController"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SignatureController(IMediator mediator)
|
public SignatureController(IMediator mediator, ILogger<SignatureController> logger)
|
||||||
{
|
{
|
||||||
_mediator = mediator;
|
_mediator = mediator;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: update to use signature query
|
//TODO: update to use signature query
|
||||||
@@ -54,4 +59,46 @@ public class SignatureController : ControllerBase
|
|||||||
else
|
else
|
||||||
return Ok(rcvSignatures);
|
return Ok(rcvSignatures);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finalizes the receiver signing flow and persists signature data to TBSIG_DOCUMENT_RECEIVER_ELEMENT.
|
||||||
|
/// Also triggers status/history/mail side effects through SigningCommand pipeline.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[HttpPost("submit/{envelopeKey}")]
|
||||||
|
public async Task<IActionResult> Submit(
|
||||||
|
[FromRoute] string envelopeKey,
|
||||||
|
[FromBody] IEnumerable<Signature>? signatures,
|
||||||
|
CancellationToken cancel)
|
||||||
|
{
|
||||||
|
if (signatures is null)
|
||||||
|
return BadRequest("Signature payload is required.");
|
||||||
|
|
||||||
|
var uuid = User.EnvelopeUuid();
|
||||||
|
var receiverSignature = User.ReceiverSignature();
|
||||||
|
|
||||||
|
if (await _mediator.IsSignedAsync(uuid, receiverSignature, cancel))
|
||||||
|
return Problem(statusCode: StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
|
if (await _mediator.AnyHistoryAsync(uuid, new[] { EnvelopeStatus.EnvelopeRejected, EnvelopeStatus.DocumentRejected }, cancel))
|
||||||
|
return Problem(statusCode: StatusCodes.Status423Locked);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _mediator.Send(new SigningCommand
|
||||||
|
{
|
||||||
|
Envelope = new() { Uuid = uuid },
|
||||||
|
Receiver = new() { Signature = receiverSignature },
|
||||||
|
ReceiverAppType = ReceiverAppType.ReceiverUI,
|
||||||
|
Signatures = signatures.ToList()
|
||||||
|
}, cancel);
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to submit signatures for envelope {EnvelopeKey}", envelopeKey);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
"launchBrowser": true,
|
"launchBrowser": true,
|
||||||
"applicationUrl": "https://localhost:8088;http://localhost:5131",
|
"applicationUrl": "https://localhost:8089;http://localhost:5132",
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -185,8 +185,10 @@
|
|||||||
"TimeLimit": "00:30:00"
|
"TimeLimit": "00:30:00"
|
||||||
},
|
},
|
||||||
"DbTriggerParams": {
|
"DbTriggerParams": {
|
||||||
|
"DocReceiverElement": [ "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS", "TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD" ],
|
||||||
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
"Envelope": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
||||||
"EnvelopeHistory": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
"EnvelopeHistory": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
||||||
|
"History": [ "TBSIG_ENVELOPE_HISTORY_AFT_INS" ],
|
||||||
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
"EmailOut": [ "TBEMLP_EMAIL_OUT_AFT_INS", "TBEMLP_EMAIL_OUT_AFT_UPD" ],
|
||||||
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
"EnvelopeReceiverReadOnly": [ "TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD" ],
|
||||||
"Receiver": [],
|
"Receiver": [],
|
||||||
|
|||||||
@@ -22,13 +22,29 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender-title-section {
|
.sender-title-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 0.75rem;
|
||||||
flex-shrink: 0; /* don't shrink — logo area in the middle gets the space */
|
flex-shrink: 0; /* don't shrink — logo area in the middle gets the space */
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-logo {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-logo .signflow-app-logo {
|
||||||
|
height: clamp(20px, 1.6vw, 24px) !important;
|
||||||
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Customer logo area (middle of action bar) ──────────────────────────── */
|
/* ── Customer logo area (middle of action bar) ──────────────────────────── */
|
||||||
@@ -40,6 +56,13 @@
|
|||||||
min-width: 0; /* allow flex child to shrink below content size */
|
min-width: 0; /* allow flex child to shrink below content size */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sender-customer-logo-area .signflow-company-logo {
|
||||||
|
height: clamp(13px, 1.45vw, 18px);
|
||||||
|
max-width: min(24vw, 190px);
|
||||||
|
width: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
/* Placeholder shown when no customer logo is configured */
|
/* Placeholder shown when no customer logo is configured */
|
||||||
.sf-customer-logo-placeholder {
|
.sf-customer-logo-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -64,10 +87,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sender-title {
|
.sender-title {
|
||||||
font-size: 1.25rem;
|
display: flex;
|
||||||
font-weight: 700;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.08rem;
|
||||||
|
line-height: 1.12;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
letter-spacing: -0.025em;
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-title-main {
|
||||||
|
font-size: clamp(1rem, 1.25vw, 1.12rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-title-sub {
|
||||||
|
color: #475569;
|
||||||
|
font-size: clamp(0.72rem, 0.95vw, 0.78rem);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender-toolbar {
|
.sender-toolbar {
|
||||||
@@ -136,6 +178,58 @@
|
|||||||
min-width: 38px;
|
min-width: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sender-btn--export {
|
||||||
|
padding-inline: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-export-group {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border: 1px solid rgba(126, 34, 206, 0.18);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(126, 34, 206, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-export-format {
|
||||||
|
height: 32px;
|
||||||
|
min-width: 84px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
border: 1px solid rgba(126, 34, 206, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-export-format:focus {
|
||||||
|
border-color: rgba(126, 34, 206, 0.55);
|
||||||
|
box-shadow: 0 0 0 2px rgba(126, 34, 206, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-export-selected {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
color: #4b5563;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-export-selected-text {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-export-selected input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sender-content {
|
.sender-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -199,20 +293,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sender-grid-wrapper {
|
.sender-grid-wrapper {
|
||||||
padding: 0 2rem 2rem; /* top padding removed — hint banner provides top spacing */
|
padding: 1rem 2rem 2rem;
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Grid persistence hint banner ─────────────────────────────────────────── */
|
|
||||||
.sf-grid-persistence-hint {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 2rem;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: rgba(126, 34, 206, 0.65);
|
|
||||||
background: rgba(126, 34, 206, 0.04);
|
|
||||||
border-bottom: 1px solid rgba(126, 34, 206, 0.08);
|
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide DevExpress empty cells */
|
/* Hide DevExpress empty cells */
|
||||||
@@ -300,13 +381,62 @@
|
|||||||
color: #c62828;
|
color: #c62828;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@media (max-width: 768px) {
|
@media (max-width: 1320px) {
|
||||||
|
.sender-action-bar__inner {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-customer-logo-area {
|
||||||
|
order: 2;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-left: 2.4rem;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-toolbar {
|
||||||
|
order: 3;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-title-main {
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-title-sub {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
.sender-action-bar {
|
.sender-action-bar {
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender-action-bar__inner {
|
.sender-action-bar__inner {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-title-section {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-logo .signflow-app-logo {
|
||||||
|
height: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-customer-logo-area .signflow-company-logo {
|
||||||
|
height: 13px;
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-customer-logo-area {
|
||||||
|
padding-left: 0;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender-toolbar {
|
.sender-toolbar {
|
||||||
@@ -314,8 +444,25 @@
|
|||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sender-export-group {
|
||||||
|
order: 98;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-btn--logout {
|
||||||
|
order: 99;
|
||||||
|
}
|
||||||
|
|
||||||
.sender-title {
|
.sender-title {
|
||||||
font-size: 1.125rem;
|
margin-top: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-title-main {
|
||||||
|
font-size: 0.94rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-title-sub {
|
||||||
|
font-size: 0.68rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sender-content {
|
.sender-content {
|
||||||
@@ -337,6 +484,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.sender-title-section {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-title {
|
||||||
|
padding-left: 0.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Theme overrides ─────────────────────────────────────────────────────── */
|
/* ── Theme overrides ─────────────────────────────────────────────────────── */
|
||||||
/* Selector: [data-sf-theme="<name>"] set on <html> by signflow-theme.js */
|
/* Selector: [data-sf-theme="<name>"] set on <html> by signflow-theme.js */
|
||||||
|
|
||||||
@@ -373,6 +532,20 @@
|
|||||||
box-shadow: 0 4px 12px rgba(147, 51, 234, 0.22);
|
box-shadow: 0 4px 12px rgba(147, 51, 234, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="purple"] .sender-export-group {
|
||||||
|
border-color: rgba(147, 51, 234, 0.28);
|
||||||
|
background: rgba(147, 51, 234, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="purple"] .sender-export-format {
|
||||||
|
border-color: rgba(147, 51, 234, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="purple"] .sender-export-format:focus {
|
||||||
|
border-color: rgba(147, 51, 234, 0.6);
|
||||||
|
box-shadow: 0 0 0 2px rgba(147, 51, 234, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
[data-sf-theme="purple"] .sender-tabs {
|
[data-sf-theme="purple"] .sender-tabs {
|
||||||
border-bottom-color: rgba(147, 51, 234, 0.12);
|
border-bottom-color: rgba(147, 51, 234, 0.12);
|
||||||
}
|
}
|
||||||
@@ -382,6 +555,10 @@
|
|||||||
border-bottom-color: #9333ea;
|
border-bottom-color: #9333ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="purple"] .sender-title-sub {
|
||||||
|
color: #6b21a8;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Blazing Dark theme ────────────────────────────────────────────────────
|
/* ── Blazing Dark theme ────────────────────────────────────────────────────
|
||||||
Dark navy background, frosted glass surfaces turn semi-dark */
|
Dark navy background, frosted glass surfaces turn semi-dark */
|
||||||
[data-sf-theme="blazing-dark"] .sender-dashboard-layout {
|
[data-sf-theme="blazing-dark"] .sender-dashboard-layout {
|
||||||
@@ -398,6 +575,10 @@
|
|||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="blazing-dark"] .sender-title-sub {
|
||||||
|
color: rgba(224, 231, 255, 0.82);
|
||||||
|
}
|
||||||
|
|
||||||
[data-sf-theme="blazing-dark"] .sender-logo svg {
|
[data-sf-theme="blazing-dark"] .sender-logo svg {
|
||||||
color: #818cf8;
|
color: #818cf8;
|
||||||
filter: drop-shadow(0 2px 4px rgba(129, 140, 248, 0.3));
|
filter: drop-shadow(0 2px 4px rgba(129, 140, 248, 0.3));
|
||||||
@@ -445,6 +626,27 @@
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="blazing-dark"] .sender-export-group {
|
||||||
|
border-color: rgba(129, 140, 248, 0.26);
|
||||||
|
background: rgba(99, 102, 241, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="blazing-dark"] .sender-export-label,
|
||||||
|
[data-sf-theme="blazing-dark"] .sender-export-selected {
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="blazing-dark"] .sender-export-format {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
border-color: rgba(129, 140, 248, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sf-theme="blazing-dark"] .sender-export-format:focus {
|
||||||
|
border-color: rgba(129, 140, 248, 0.58);
|
||||||
|
box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
[data-sf-theme="blazing-dark"] .sender-grid-container {
|
[data-sf-theme="blazing-dark"] .sender-grid-container {
|
||||||
background: rgba(15, 20, 40, 0.95);
|
background: rgba(15, 20, 40, 0.95);
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||||
|
|||||||
@@ -1,418 +0,0 @@
|
|||||||
# SF-74 — signFLOW: Theme, Dark Mode, Grid Persistence, Logo & Dashboard
|
|
||||||
|
|
||||||
**Ticket:** SF-74 | **Bearbeiter:** Hakan Tek | **Status:** In Bearbeitung
|
|
||||||
**Letzte Aktualisierung:** 2026-09-03
|
|
||||||
**Erstellt von:** Marlon Schreiber | **Verknüpft mit:** SF-71
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Originale Anforderungen
|
|
||||||
|
|
||||||
### Marlon Schreiber (Ticket-Text):
|
|
||||||
1. **signFLOW-Logo** + **Kunden-Logo-Bereich** auf der Startseite
|
|
||||||
2. **DevExpress Skin-Auswahl** — mindestens 3 Themes, vom User wählbar + gespeichert
|
|
||||||
3. **Grid-Layout-Persistenz** — Spaltenbreite/Position pro User gespeichert
|
|
||||||
|
|
||||||
### Screenshot-Annotationen (`/sender`):
|
|
||||||
```
|
|
||||||
[signFLOW Logo] Umschlag-Übersicht [Kunden-Logo] [+Neuer Umschlag][Bearb.][Lösch.][↺][→|]
|
|
||||||
signFLOW Portal-Übersicht [Zurückrufen/löschen][Dok.anzeigen][Excel]
|
|
||||||
|
|
||||||
[Info-Banner: "Die Größe und Position der Spalten muss verändert und für den User gespeichert..."]
|
|
||||||
|
|
||||||
ID ↓ Titel Status [Typ] Empfänger [Erstellt] [Geändert am]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Projektstruktur — Clean Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
EnvelopeGenerator.Domain/ → Entities, Domain-Interfaces
|
|
||||||
EnvelopeGenerator.Application/ → Service-Interfaces, DTOs, Use-Cases
|
|
||||||
EnvelopeGenerator.Infrastructure/ → Cache, DB, Implementierungen
|
|
||||||
EnvelopeGenerator.Server/ → Blazor SSR Host, Controller, Middleware
|
|
||||||
EnvelopeGenerator.Server.Client/ → Blazor WASM Client
|
|
||||||
```
|
|
||||||
|
|
||||||
**Abhängigkeitsregel:** `Domain ← Application ← Infrastructure ← Server / Server.Client`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Abgeschlossene Phasen (1–5, 6a–6c)
|
|
||||||
|
|
||||||
| Phase | Inhalt | Status |
|
|
||||||
|-------|--------|--------|
|
|
||||||
| 1 | Server: UserPreferences API (TBDD_CACHE, `IUserPreferencesService`) | ✅ |
|
|
||||||
| 2 | WASM: `ThemeService`, `UserPreferencesService` (1h In-Memory-Cache) | ✅ |
|
|
||||||
| 3 | Footer Theme Slider (🌙 blazing-dark ── blazing-berry ── purple ☀️) | ✅ |
|
|
||||||
| 4 | Grid Layout Persistence (`LayoutAutoSaving`/`LayoutAutoLoading`) | ✅ |
|
|
||||||
| 5 | Theme-CSS-Overrides für alle Seiten (`[data-sf-theme]`) | ✅ |
|
|
||||||
| 6a | signFLOW-Logo (CustomImages Options, Controller, Service, Dateien) | ✅ |
|
|
||||||
| 6b | Kunden-Logo Placeholder (sender-customer-logo-area, CSS) | ✅ |
|
|
||||||
| 6c | Grid-Spalten (EnvelopeTypeTitle, AddedWhen, ChangedWhen) + Persistence Hint | ✅ |
|
|
||||||
|
|
||||||
**Key decisions:** TBDD_CACHE statt eigener DB-Tabelle · `SenderOrReceiver` Policy · Slider statt Toggle · `signflow.theme` in localStorage · DevExpress 25.2.3 hat keine `.dark.`-Suffix-Dateien
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Phase 6a — signFLOW-Logo
|
|
||||||
|
|
||||||
### Ziel
|
|
||||||
signFLOW-App-Logo im Sender Dashboard (`/sender`) Action Bar links neben dem Titel, sowie auf der Startseite (`/`) im Hero-Header.
|
|
||||||
|
|
||||||
### Technischer Ansatz
|
|
||||||
|
|
||||||
**Options Pattern** — `appsettings.json` → `CustomImagesOptions` → DI → Controller → WASM Service.
|
|
||||||
|
|
||||||
```
|
|
||||||
appsettings.json
|
|
||||||
└── CustomImagesOptions (Options Pattern, IOptions<>)
|
|
||||||
└── GET /api/CustomImages (neuer Controller, auth: SenderOrReceiver)
|
|
||||||
└── CustomImagesService (WASM, in-memory cache, kein TTL)
|
|
||||||
└── EnvelopeSenderPage + IndexPage
|
|
||||||
```
|
|
||||||
|
|
||||||
**Warum kein `wwwroot/appsettings.json`?**
|
|
||||||
Statische Assets sind public (keine Auth). Logo-Pfade sind deployment-spezifisch.
|
|
||||||
Options Pattern erlaubt Produktionswechsel ohne Code-Änderung.
|
|
||||||
|
|
||||||
### Neue/geänderte Dateien
|
|
||||||
|
|
||||||
#### `Server/Models/CustomImages.cs` — NEU
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed class CustomImageEntry
|
|
||||||
{
|
|
||||||
public string Src { get; set; } = string.Empty;
|
|
||||||
/// <summary>CSS height, e.g. "28px" or "2rem". Change per deployment in appsettings.</summary>
|
|
||||||
public string Height { get; set; } = "28px";
|
|
||||||
public Dictionary<string, string> Classes { get; set; } = new();
|
|
||||||
public string GetClass(string key) =>
|
|
||||||
Classes.TryGetValue(key, out var cls) ? cls ?? string.Empty : string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class CustomImagesOptions
|
|
||||||
{
|
|
||||||
public const string SectionName = "CustomImages";
|
|
||||||
public CustomImageEntry App { get; set; } = new();
|
|
||||||
public CustomImageEntry Company { get; set; } = new();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `appsettings.json` — Änderung (Zeile 145)
|
|
||||||
|
|
||||||
```json
|
|
||||||
"CustomImages": {
|
|
||||||
"App": { "Src": "/img/DD_signFLOW_LOGO.png", "Height": "28px",
|
|
||||||
"Classes": { "Main": "signflow-app-logo" } },
|
|
||||||
"Company": { "Src": "/img/digital_data.svg", "Height": "22px",
|
|
||||||
"Classes": { "Show": "signflow-company-logo" } }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Deployment:** Nur `Src` + `Height` in `appsettings.json` ändern — keine Code-Anpassung nötig.
|
|
||||||
|
|
||||||
#### `Server/Controllers/CustomImagesController.cs` — NEU
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/CustomImages
|
|
||||||
Auth: [Authorize(Policy = AuthPolicy.SenderOrReceiver)]
|
|
||||||
Returns: CustomImagesOptions
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `Server/Program.cs` — DI
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
builder.Services.Configure<CustomImagesOptions>(
|
|
||||||
config.GetSection(CustomImagesOptions.SectionName));
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `Server/wwwroot/img/` — Neue Dateien
|
|
||||||
|
|
||||||
Kopiert aus `EnvelopeGenerator.Web/wwwroot/img/`:
|
|
||||||
- `DD_signFLOW_LOGO.png`
|
|
||||||
- `digital_data.svg`
|
|
||||||
|
|
||||||
#### `Client/Services/CustomImagesService.cs` — NEU
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// GET /api/CustomImages → einmalig laden, in-memory gecacht (Config ändert sich kaum)
|
|
||||||
public class CustomImagesService(IHttpClientFactory factory)
|
|
||||||
{
|
|
||||||
private CustomImagesDto? _cached;
|
|
||||||
public async Task<CustomImagesDto> GetAsync(CancellationToken ct = default) { ... }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `app.css` — Neue Klassen
|
|
||||||
|
|
||||||
```css
|
|
||||||
.signflow-app-logo { height: var(--sf-app-height, 28px); width: auto; object-fit: contain; }
|
|
||||||
.signflow-company-logo { height: var(--sf-company-height, 22px); width: auto; object-fit: contain; opacity: .88; }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `IndexPage.razor` — Änderung
|
|
||||||
|
|
||||||
```razor
|
|
||||||
<!-- Alt: <svg class="home-hero-header__icon"> -->
|
|
||||||
<!-- Neu: -->
|
|
||||||
<img src="@_appLogo.Src" style="height: @_appLogo.Height"
|
|
||||||
class="home-hero-header__icon" alt="signFLOW" />
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `EnvelopeSenderPage.razor` — signFLOW Logo im Action Bar
|
|
||||||
|
|
||||||
```razor
|
|
||||||
<div class="sender-branding">
|
|
||||||
<img src="@_appLogo.Src" style="height: @_appLogo.Height"
|
|
||||||
class="@_appLogo.GetClass("Main")" alt="signFLOW" />
|
|
||||||
<div class="sender-title-block">
|
|
||||||
<div class="sender-title">Umschlag-Übersicht</div>
|
|
||||||
<div class="sender-subtitle">signFLOW Portal - Übersicht</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Phase 6b — Kunden-Logo Placeholder
|
|
||||||
|
|
||||||
### Ziel
|
|
||||||
Mittlerer Bereich im Action Bar zeigt das Kunden-Logo (aus Config) oder einen Platzhalter.
|
|
||||||
|
|
||||||
### Implementierung
|
|
||||||
|
|
||||||
**Config-Quelle:** `CustomImagesOptions.Company` (aus Phase 6a bereits geladen)
|
|
||||||
|
|
||||||
```razor
|
|
||||||
<!-- Mitte des Action Bars -->
|
|
||||||
<div class="sender-customer-logo-area">
|
|
||||||
@if (!string.IsNullOrEmpty(_companyLogo?.Src))
|
|
||||||
{
|
|
||||||
<img src="@_companyLogo.Src" style="height: @_companyLogo.Height"
|
|
||||||
class="@_companyLogo.GetClass("Show")" alt="Kunden-Logo" />
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="sf-customer-logo-placeholder">Kunden-Logo</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `app.css` — Neue Klasse
|
|
||||||
|
|
||||||
```css
|
|
||||||
.sf-customer-logo-placeholder {
|
|
||||||
height: 24px; min-width: 90px;
|
|
||||||
border: 1px dashed rgba(126, 34, 206, 0.3); border-radius: 4px;
|
|
||||||
display: flex; align-items: center; padding: 0 10px;
|
|
||||||
font-size: 0.62rem; color: rgba(126, 34, 206, 0.4); white-space: nowrap;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `sender-page.css` — Neues Layout (3-Bereich-Action-Bar)
|
|
||||||
|
|
||||||
```css
|
|
||||||
.sender-branding { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; }
|
|
||||||
.sender-subtitle { font-size: .72rem; color: #6b7280; font-weight: 400; }
|
|
||||||
.sender-customer-logo-area { flex: 1; display: flex; justify-content: center; }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Phase 6c — Grid: Fehlende Spalten + Persistence Hint
|
|
||||||
|
|
||||||
### Fehlende Spalten (aus Screenshot-Annotationen)
|
|
||||||
|
|
||||||
**Verfügbarkeit in `EnvelopeDto`:**
|
|
||||||
- `EnvelopeTypeTitle` (string?) → `spalte für "Typ"` ✅
|
|
||||||
- `AddedWhen` (DateTime) → `spalte für "erstellt"` ✅
|
|
||||||
- `ChangedWhen` (DateTime?) → `Spalte "zuletzt geändert am"` ✅
|
|
||||||
|
|
||||||
> ⚠️ **Vor Implementierung prüfen:** `EnvelopeTypeTitle` wird aus dem Navigation Property `EnvelopeType` gemappt. Mapping-Konfiguration in `Application/...MappingProfile.cs` verifizieren.
|
|
||||||
|
|
||||||
#### `EnvelopeSenderPage.razor` — Neue Spalten (beide Grids)
|
|
||||||
|
|
||||||
```razor
|
|
||||||
<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" />
|
|
||||||
```
|
|
||||||
|
|
||||||
### Grid Persistence Hint
|
|
||||||
|
|
||||||
Info-Banner über dem Grid:
|
|
||||||
|
|
||||||
```razor
|
|
||||||
<div class="sf-grid-persistence-hint">
|
|
||||||
<svg><!-- info icon --></svg>
|
|
||||||
Die Größe und Position der Spalten wird automatisch für Sie gespeichert.
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
```css
|
|
||||||
.sf-grid-persistence-hint {
|
|
||||||
display: flex; align-items: center; gap: 6px;
|
|
||||||
padding: 5px 16px; font-size: 0.72rem;
|
|
||||||
color: rgba(126, 34, 206, 0.7);
|
|
||||||
background: rgba(126, 34, 206, 0.04);
|
|
||||||
border-bottom: 1px solid rgba(126, 34, 206, 0.08);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔲 Phase 6d — Dokument anzeigen (PDF Preview)
|
|
||||||
|
|
||||||
### Anforderung
|
|
||||||
Ausgewähltes Envelope-Dokument im Popup anzeigen. Envelope ist nach dem Senden **read-only** (Mails wurden gesendet — nachträgliche Änderungen würden Konsistenz brechen).
|
|
||||||
|
|
||||||
### Technischer Ansatz
|
|
||||||
|
|
||||||
`DxPdfViewer` erfordert **zwingend `@rendermode InteractiveServer`**.
|
|
||||||
`EnvelopeSenderPage` ist WASM (`@rendermode InteractiveWebAssembly`).
|
|
||||||
|
|
||||||
**Lösung:** `DxPopup` im WASM-Parent mit einem Server-Component darin:
|
|
||||||
|
|
||||||
```
|
|
||||||
EnvelopeSenderPage.razor (@rendermode WASM)
|
|
||||||
└── <DxPopup>
|
|
||||||
└── <EnvelopePdfPreviewComponent @rendermode InteractiveServer>
|
|
||||||
└── <DxPdfViewer> (SSR-only)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `Server/Components/Shared/EnvelopePdfPreviewComponent.razor` — NEU
|
|
||||||
|
|
||||||
```razor
|
|
||||||
@rendermode InteractiveServer
|
|
||||||
@inject IDocumentService DocumentService <!-- oder HTTP client -->
|
|
||||||
|
|
||||||
<DxPdfViewer DocumentContent="@_pdfBytes" />
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public int EnvelopeId { get; set; }
|
|
||||||
byte[]? _pdfBytes;
|
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
|
||||||
{
|
|
||||||
if (EnvelopeId > 0)
|
|
||||||
_pdfBytes = await DocumentService.GetPdfBytesAsync(EnvelopeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Button: nur aktiv wenn Envelope ausgewählt UND Dokument vorhanden (`envelope.Documents?.Any() == true`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔲 Phase 6e — Export nach Excel
|
|
||||||
|
|
||||||
### Technischer Ansatz
|
|
||||||
|
|
||||||
`DxGrid.ExportToXlsxAsync(fileName, options)` — direkt im Razor-Code, keine Backend-Implementierung nötig.
|
|
||||||
Download geht direkt an den Browser.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
async Task ExportToExcelAsync()
|
|
||||||
{
|
|
||||||
var grid = _activeTab == "active" ? _gridActive : _gridCompleted;
|
|
||||||
if (grid is null) return;
|
|
||||||
var fileName = $"Umschlaege_{DateTime.Now:yyyy-MM-dd}.xlsx";
|
|
||||||
await grid.ExportToXlsxAsync(fileName, new GridXlExportOptions());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Hinweis:** `CellDisplayTemplate`-Inhalte werden nicht exportiert (DevExpress-Limitierung).
|
|
||||||
> Die Spalten `Status`, `EnvelopeReceivers` nutzen Templates → Wert im Export ist leer.
|
|
||||||
> **Future Task:** Für vollständigen Export mit formatierten Zellen `CustomizeCell`-Event nutzen.
|
|
||||||
|
|
||||||
Button in Toolbar:
|
|
||||||
```razor
|
|
||||||
<button class="sender-btn" @onclick="ExportToExcelAsync"
|
|
||||||
title="Aktuelle Ansicht als Excel exportieren">
|
|
||||||
<!-- Excel-Icon SVG -->
|
|
||||||
Export nach Excel
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔲 Phase 6f — Action Bar Redesign
|
|
||||||
|
|
||||||
### Ziel-Layout (aus Screenshot)
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ [Logo] Umschlag-Übersicht │ [Kunden-Logo] │ [+Neu] [Bearb.] [Lösch.] │
|
|
||||||
│ signFLOW Portal-Übers│ │ [↺] [→|] │
|
|
||||||
│ │ │ [Zurückrufen?] [Dok.] [XLS] │
|
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### Neue CSS-Klassen in `sender-page.css`
|
|
||||||
|
|
||||||
```css
|
|
||||||
.sender-toolbar-right { display: flex; flex-direction: column; gap: .35rem; align-items: flex-end; }
|
|
||||||
.sender-toolbar-row { display: flex; align-items: center; gap: .5rem; }
|
|
||||||
.sender-toolbar-row--primary { /* Hauptbuttons */ }
|
|
||||||
.sender-toolbar-row--secondary { /* Dokument anzeigen, Export */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
### `Zurückrufen/löschen` — Offene Frage ❓
|
|
||||||
|
|
||||||
> **Ticket-Kommentar-Entwurf (Deutsch, für Marlon Schreiber):**
|
|
||||||
>
|
|
||||||
> Hallo @Marlon Schreiber,
|
|
||||||
>
|
|
||||||
> im Screenshot sehe ich einen Button „Zurückrufen/löschen". Könntest du den
|
|
||||||
> Unterschied zum bereits vorhandenen „Löschen"-Button erläutern?
|
|
||||||
>
|
|
||||||
> Meine Vermutung: „Zurückrufen" bedeutet das Widerrufen eines **bereits gesendeten**
|
|
||||||
> Umschlags (Status → `EnvelopeWithdrawn`), während „Löschen" einen noch nicht
|
|
||||||
> gesendeten Umschlag entfernt. Ist das korrekt? Und falls ja:
|
|
||||||
> - Sollen Empfänger bei einem Widerruf automatisch benachrichtigt werden?
|
|
||||||
> - Soll der Status auf `Zurückgerufen` gesetzt werden und der Umschlag
|
|
||||||
> weiterhin in der Liste erscheinen (aber inaktiv)?
|
|
||||||
>
|
|
||||||
> Bitte kurz antworten, damit ich dies korrekt implementieren kann.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementierungsreihenfolge Phase 6
|
|
||||||
|
|
||||||
```
|
|
||||||
6a (Logo) → 6b (Kunden-Logo) → 6c (Grid-Spalten) → 6d (PDF Preview) → 6e (Excel) → 6f (Action Bar)
|
|
||||||
```
|
|
||||||
|
|
||||||
Jede Teilphase ist unabhängig deploybar. `6f` fasst alle Layout-Änderungen zusammen
|
|
||||||
und sollte zuletzt implementiert werden, wenn alle Komponenten fertig sind.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bekannte Einschränkungen & Offene Punkte
|
|
||||||
|
|
||||||
| # | Thema | Status |
|
|
||||||
|---|-------|--------|
|
|
||||||
| 1 | `DxPdfViewer` → InteractiveServer zwingend | Architektonisch gelöst (6d) |
|
|
||||||
| 2 | Excel-Export: Template-Spalten leer im Export | Akzeptiert, Future Task (CustomizeCell) |
|
|
||||||
| 3 | `Zurückrufen/löschen` — Bedeutung unklar | Kommentar-Frage vorbereitet (6f) |
|
|
||||||
| 4 | `EnvelopeTypeTitle` Mapping prüfen | Vor 6c verifizieren |
|
|
||||||
| 5 | Logo-Pfade produktionsspezifisch | Via `appsettings.json` konfigurierbar |
|
|
||||||
| 6 | Server-seitige Pages — inline Farben bleiben | Out-of-scope |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nicht in diesem Ticket
|
|
||||||
|
|
||||||
- `Einstellungen`-Seite (separates Ticket)
|
|
||||||
- Mobile Responsive (separates Ticket)
|
|
||||||
- Excel CustomizeCell vollständige Implementierung (Future Task)
|
|
||||||
- Widerruf-Backend (wartet auf Klärung)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Erstellt: 2026-09-01 | Aktualisiert: 2026-09-03 | Ticket: SF-74 | Projekt: signFLOW*
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
# Migration Plan: EnvelopeGenerator.Service_legacy ? EnvelopeGenerator.Service
|
|
||||||
**Revision v3** — Full deep analysis complete
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Migrate the legacy VB.NET Windows Service (`EnvelopeGenerator.Service_legacy`) to a modern
|
|
||||||
C# Worker Service (`EnvelopeGenerator.Service`) targeting **.NET Framework 4.6.2**, using
|
|
||||||
`Microsoft.Extensions.Hosting`, `IConfiguration`, `ILogger<T>` and **NLog**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deep Analysis Findings (vs. v1/v2)
|
|
||||||
|
|
||||||
### Finding 1 — Connection String: Plain Text, No Encryption Required
|
|
||||||
`FinalizeDocumentJob` and `APIEnvelopeJob` call `MSSQLServer.DecryptConnectionString()` on the
|
|
||||||
`JobDataMap[Value.DATABASE]` value. However, `MSSQLServer.DecryptConnectionString()` is a pass-through
|
|
||||||
for plain-text strings — no wrapping/encryption step is needed.
|
|
||||||
|
|
||||||
**Resolution**: The connection string is read from `IConfiguration` via the standard
|
|
||||||
`ConnectionStrings:Default` key (same key as `appsettings.Database.json` in `EnvelopeGenerator.Web`).
|
|
||||||
It is passed **as-is** to both the Worker-level `MSSQLServer` and the `JobDataMap`. No
|
|
||||||
`EncryptConnectionString()` call is made anywhere in the new service.
|
|
||||||
|
|
||||||
### Finding 2 — Dual TempFiles Architecture
|
|
||||||
There are **two independent** `TempFiles` classes:
|
|
||||||
- `EnvelopeGenerator.CommonServices.TempFiles` — used **inside each job** via `New TempFiles(LogConfig)`.
|
|
||||||
This is called on every job execution and manages the job's own temp lifecycle. It expects `LogConfig`.
|
|
||||||
**We do not touch this.**
|
|
||||||
- `EnvelopeGenerator.Service_legacy.TempFiles` — used at the **service/host level** (startup cleanup,
|
|
||||||
shutdown cleanup). This is the one we rewrite in C# in the Service project using `ILogger<TempFiles>`.
|
|
||||||
|
|
||||||
### Finding 3 — LogConfig is a Hard Dependency of All Jobs
|
|
||||||
`FinalizeDocumentJob.Execute()` and `APIEnvelopeJob.Execute()` both do:
|
|
||||||
```vb
|
|
||||||
LogConfig = pContext.MergedJobDataMap.Item(Value.LOGCONFIG)
|
|
||||||
Logger = LogConfig.GetLogger()
|
|
||||||
myTempFiles = New TempFiles(LogConfig) ' ? CommonServices.TempFiles
|
|
||||||
```
|
|
||||||
`LogConfig` is **not optional**. The schedulers must construct a valid `LogConfig` and place it in the
|
|
||||||
`JobDataMap`. It must carry the correct log path, debug flag, and application identity.
|
|
||||||
|
|
||||||
**Resolution**: A `LogConfigFactory` static helper class will be created in the Service project to
|
|
||||||
build a `LogConfig` from `ServiceConfig` values:
|
|
||||||
- `LogPath` = `Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log")` (mirrors legacy `My.Application.Info.DirectoryPath + "\Log"`)
|
|
||||||
- `Debug` = `ServiceConfig.Debug`
|
|
||||||
- Application name = `"EnvelopeGenerator.Service"`
|
|
||||||
|
|
||||||
### Finding 4 — ProjectInstaller is Obsolete
|
|
||||||
`ProjectInstaller.vb` was the old `installutil.exe` mechanism for Windows Service registration.
|
|
||||||
With `AddWindowsService()` + `sc create` / PowerShell `New-Service`, this is **completely replaced**.
|
|
||||||
No equivalent is needed in the new project.
|
|
||||||
|
|
||||||
### Finding 5 — GDPicture License SQL
|
|
||||||
The legacy service queries: `SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME = 'GDPICTURE' and ACTIVE = 1`
|
|
||||||
This must be replicated exactly in the Worker startup sequence. If the result is null/empty, startup fails
|
|
||||||
with a descriptive exception (matching legacy behavior).
|
|
||||||
|
|
||||||
### Finding 6 — Quartz Version Mismatch
|
|
||||||
`CommonServices` uses `Quartz 3.8.0`. `Service_legacy` uses `Quartz 3.15.0`.
|
|
||||||
The new `EnvelopeGenerator.Service` must use **Quartz 3.8.0** to match `CommonServices` (same AppDomain,
|
|
||||||
same version must be loaded). The legacy version bump in `Service_legacy` was inconsistent.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Project File (`EnvelopeGenerator.Service.csproj`)
|
|
||||||
|
|
||||||
- Add **NuGet packages**:
|
|
||||||
- `NLog` (5.x, matching CommonServices' `NLog.5.0.5`)
|
|
||||||
- `NLog.Extensions.Logging` (latest net462-compatible)
|
|
||||||
- `Quartz` (**3.8.0** — must match CommonServices)
|
|
||||||
- `Microsoft.Extensions.Hosting.WindowsServices` (for `AddWindowsService()`)
|
|
||||||
- Add **project reference** to `EnvelopeGenerator.CommonServices` (vbproj)
|
|
||||||
- Add **project reference** to `EnvelopeGenerator.Domain` (csproj)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Configuration (`ServiceConfig.cs`)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class ServiceConfig
|
|
||||||
{
|
|
||||||
public string ConnectionString { get; set; } = string.Empty;
|
|
||||||
public bool Debug { get; set; } = false;
|
|
||||||
public int IntervalInMin { get; set; } = 1;
|
|
||||||
public PDFBurnerParams PDFBurnerParams { get; set; } = new PDFBurnerParams();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Connection string is read via the standard `ConnectionStrings:Default` key — consistent with
|
|
||||||
`appsettings.Database.json` used by `EnvelopeGenerator.Web`:
|
|
||||||
|
|
||||||
`appsettings.json`:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"Default": ""
|
|
||||||
},
|
|
||||||
"ServiceConfig": {
|
|
||||||
"Debug": false,
|
|
||||||
"IntervalInMin": 1,
|
|
||||||
"PDFBurnerParams": {
|
|
||||||
"IgnoredLabels": [ "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" ],
|
|
||||||
"TopMargin": 0.1,
|
|
||||||
"YOffset": -0.3,
|
|
||||||
"FontName": "Arial",
|
|
||||||
"FontSize": 8,
|
|
||||||
"FontStyle": 2,
|
|
||||||
"TruncationSuffix": "..",
|
|
||||||
"TextMaxWidths": {
|
|
||||||
"position": 1.41,
|
|
||||||
"city": 1.41,
|
|
||||||
"date": 2.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> `FontStyle` integer: Regular=0, Bold=1, **Italic=2**, Underline=4, Strikeout=8.
|
|
||||||
> All values mirror `PDFBurnerParams.vb` defaults exactly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. NLog Setup
|
|
||||||
|
|
||||||
**`nlog.config`**: File target ? `${basedir}/Log/service-${shortdate}.log`, async wrapper,
|
|
||||||
layout with timestamp + level + logger + message + exception.
|
|
||||||
|
|
||||||
**`Program.cs`**: Wire via `NLog.Extensions.Logging`:
|
|
||||||
```csharp
|
|
||||||
builder.Logging.ClearProviders();
|
|
||||||
builder.Logging.AddNLog("nlog.config");
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. LogConfigFactory (`LogConfigFactory.cs`)
|
|
||||||
|
|
||||||
New static helper — bridges `ServiceConfig` ? `LogConfig` (required by CommonServices jobs):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
internal static class LogConfigFactory
|
|
||||||
{
|
|
||||||
public static LogConfig Create(ServiceConfig config)
|
|
||||||
{
|
|
||||||
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log");
|
|
||||||
var logConfig = new LogConfig(
|
|
||||||
LogConfig.PathType.CustomPath, logPath, null,
|
|
||||||
"Digital Data", "EnvelopeGenerator.Service");
|
|
||||||
logConfig.Debug = config.Debug;
|
|
||||||
return logConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This `LogConfig` instance is what gets placed into every `JobDataMap[Value.LOGCONFIG]`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Quartz LogProvider (`QuartzLogProvider.cs`)
|
|
||||||
|
|
||||||
C# rewrite of `LogProvider.vb` — bridges Quartz `ILogProvider` ? `ILogger<T>`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
internal class QuartzLogProvider : ILogProvider
|
|
||||||
{
|
|
||||||
private readonly ILogger _logger;
|
|
||||||
public QuartzLogProvider(ILogger logger) => _logger = logger;
|
|
||||||
public Logger GetLogger(string name) => (level, func, ex, parameters) => { ... };
|
|
||||||
// OpenNestedContext / OpenMappedContext ? NotImplementedException (same as legacy)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. TempFiles (`TempFiles.cs`) — Service-level only
|
|
||||||
|
|
||||||
Service-level rewrite of `Service_legacy/TempFiles.vb` using `ILogger<TempFiles>`:
|
|
||||||
- `Create()` ? create `%TEMP%\EnvelopeGenerator`, or clean existing files
|
|
||||||
- `CleanUp()` ? delete the directory on service stop
|
|
||||||
|
|
||||||
> **Does NOT replace** `CommonServices.TempFiles` — that one continues to be used
|
|
||||||
> inside each job via `LogConfig`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Schedulers
|
|
||||||
|
|
||||||
### `Schedulers/SchedulerFinishEnvelope.cs`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class SchedulerFinishEnvelope
|
|
||||||
{
|
|
||||||
// Constructor: ILogger<SchedulerFinishEnvelope>, IOptions<ServiceConfig>
|
|
||||||
// Start(licenseKey, logConfig):
|
|
||||||
// - Build JobDataMap:
|
|
||||||
// [GDPICTURE] = licenseKey
|
|
||||||
// [LOGCONFIG] = logConfig ? LogConfigFactory output
|
|
||||||
// [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString)
|
|
||||||
// [PDF_BURNER_PARAMS] = config.PDFBurnerParams
|
|
||||||
// - Schedule FinalizeDocumentJob with Quartz (interval from config)
|
|
||||||
// - Register QuartzLogProvider
|
|
||||||
// Stop(): Scheduler.Shutdown()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `Schedulers/SchedulerEnvelopeTaskApi.cs`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class SchedulerEnvelopeTaskApi
|
|
||||||
{
|
|
||||||
// Constructor: ILogger<SchedulerEnvelopeTaskApi>, IOptions<ServiceConfig>
|
|
||||||
// Start(logConfig):
|
|
||||||
// - Build JobDataMap:
|
|
||||||
// [LOGCONFIG] = logConfig
|
|
||||||
// [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString)
|
|
||||||
// - Schedule APIEnvelopeJob with Quartz (interval from config)
|
|
||||||
// - Register QuartzLogProvider
|
|
||||||
// Stop(): Scheduler.Shutdown()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Worker (`Worker.cs`)
|
|
||||||
|
|
||||||
Full lifecycle replacing the placeholder:
|
|
||||||
|
|
||||||
```
|
|
||||||
ExecuteAsync:
|
|
||||||
1. Read ServiceConfig from IOptions<ServiceConfig>
|
|
||||||
2. Validate ConnectionString ? throw if empty
|
|
||||||
3. Build LogConfig via LogConfigFactory
|
|
||||||
4. Connect to DB (plain connection string — Worker-level only)
|
|
||||||
5. Query: SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME='GDPICTURE' AND ACTIVE=1
|
|
||||||
? throw if null/empty (matches legacy behavior)
|
|
||||||
6. TempFiles.Create()
|
|
||||||
7. await SchedulerFinishEnvelope.Start(licenseKey, logConfig)
|
|
||||||
8. await Task.Delay(2500, stoppingToken) ? preserved from legacy
|
|
||||||
9. await SchedulerEnvelopeTaskApi.Start(logConfig)
|
|
||||||
10. await Task.Delay(Timeout.Infinite, stoppingToken) ? wait for cancellation
|
|
||||||
|
|
||||||
StopAsync override:
|
|
||||||
11. await SchedulerFinishEnvelope.Stop()
|
|
||||||
12. await SchedulerEnvelopeTaskApi.Stop()
|
|
||||||
13. TempFiles.CleanUp()
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Program.cs
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
|
||||||
|
|
||||||
builder.Logging.ClearProviders();
|
|
||||||
builder.Logging.AddNLog("nlog.config");
|
|
||||||
|
|
||||||
builder.Services.Configure<ServiceConfig>(
|
|
||||||
builder.Configuration.GetSection("ServiceConfig"));
|
|
||||||
|
|
||||||
builder.Services.AddSingleton<TempFiles>();
|
|
||||||
builder.Services.AddSingleton<SchedulerFinishEnvelope>();
|
|
||||||
builder.Services.AddSingleton<SchedulerEnvelopeTaskApi>();
|
|
||||||
builder.Services.AddHostedService<Worker>();
|
|
||||||
builder.Services.AddWindowsService(o => o.ServiceName = "EnvelopeGenerator.Service");
|
|
||||||
|
|
||||||
var host = builder.Build();
|
|
||||||
host.Run();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Files to Create / Modify
|
|
||||||
|
|
||||||
| File | Action | Notes |
|
|
||||||
|------|--------|-------|
|
|
||||||
| `EnvelopeGenerator.Service.csproj` | **Modify** | Add packages, project refs |
|
|
||||||
| `Program.cs` | **Modify** | Full DI/NLog/WindowsService setup |
|
|
||||||
| `Worker.cs` | **Modify** | Full service lifecycle |
|
|
||||||
| `ServiceConfig.cs` | **Create** | |
|
|
||||||
| `LogConfigFactory.cs` | **Create** | Critical bridge: ServiceConfig ? LogConfig |
|
|
||||||
| `TempFiles.cs` | **Create** | Service-level only; CommonServices.TempFiles untouched |
|
|
||||||
| `QuartzLogProvider.cs` | **Create** | |
|
|
||||||
| `Schedulers/SchedulerFinishEnvelope.cs` | **Create** | |
|
|
||||||
| `Schedulers/SchedulerEnvelopeTaskApi.cs` | **Create** | |
|
|
||||||
| `appsettings.json` | **Create** | All PDFBurnerParams explicit |
|
|
||||||
| `nlog.config` | **Create** | |
|
|
||||||
|
|
||||||
**Not created** (obsolete in new architecture):
|
|
||||||
- ~~`ProjectInstaller`~~ ? replaced by `sc create` / `New-Service`
|
|
||||||
- ~~`Service.Designer.vb`~~ ? BackgroundService handles this
|
|
||||||
- ~~`Service.resx`~~ ? not applicable
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Key Design Decisions
|
|
||||||
|
|
||||||
| Decision | Rationale |
|
|
||||||
|----------|-----------|
|
|
||||||
| **Quartz 3.8.0** (not 3.15.0) | Must match CommonServices; same AppDomain, one version |
|
|
||||||
| **Connection string plain-text, no encryption** | `MSSQLServer.DecryptConnectionString()` is a pass-through for plain strings; `ConnectionStrings:Default` key used (same convention as Web project) |
|
|
||||||
| **Connection string in `ConnectionStrings:Default`** | Consistent with rest of the solution (`appsettings.Database.json`); `ServiceConfig` holds only service-specific settings |
|
|
||||||
| **LogConfigFactory** | Jobs have hard dependency on `LogConfig` in JobDataMap; this is the clean bridge without leaking LogConfig into the DI container |
|
|
||||||
| **2500ms delay preserved** | Legacy behavior; ensures Scheduler1 is fully initialized before Scheduler2 starts |
|
|
||||||
| **`AddWindowsService()`** | Native SCM integration; replaces ProjectInstaller/installutil entirely |
|
|
||||||
| **NLog log path = BaseDirectory/Log** | Mirrors `My.Application.Info.DirectoryPath + "\Log"` from legacy |
|
|
||||||
| **CommonServices.TempFiles untouched** | Jobs create their own TempFiles internally via LogConfig; service-level TempFiles is separate concern |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Approval Required
|
|
||||||
|
|
||||||
> Please review this plan and confirm before implementation begins.
|
|
||||||
|
|
||||||
@@ -20,8 +20,6 @@ EndProject
|
|||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{134D4164-B291-4E19-99B9-E4FA3AFAB62C}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{134D4164-B291-4E19-99B9-E4FA3AFAB62C}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
COPILOT_CONTEXT.md = COPILOT_CONTEXT.md
|
COPILOT_CONTEXT.md = COPILOT_CONTEXT.md
|
||||||
fix-report-label-read-and-confirmed.md = fix-report-label-read-and-confirmed.md
|
|
||||||
fix-signature-field-formatting.md = fix-signature-field-formatting.md
|
|
||||||
FORM_APPLICATION_CONTEXT.md = FORM_APPLICATION_CONTEXT.md
|
FORM_APPLICATION_CONTEXT.md = FORM_APPLICATION_CONTEXT.md
|
||||||
RECEIVER_PDF_VIEWER_CONTEXT.md = RECEIVER_PDF_VIEWER_CONTEXT.md
|
RECEIVER_PDF_VIEWER_CONTEXT.md = RECEIVER_PDF_VIEWER_CONTEXT.md
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
@@ -38,14 +36,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.PdfEditor
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Tests", "EnvelopeGenerator.Tests\EnvelopeGenerator.Tests.csproj", "{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Tests", "EnvelopeGenerator.Tests\EnvelopeGenerator.Tests.csproj", "{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.API", "EnvelopeGenerator.API\EnvelopeGenerator.API.csproj", "{EC768913-6270-14F4-1DD3-69C87A659462}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.DependencyInjection", "EnvelopeGenerator.DependencyInjection\EnvelopeGenerator.DependencyInjection.csproj", "{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.DependencyInjection", "EnvelopeGenerator.DependencyInjection\EnvelopeGenerator.DependencyInjection.csproj", "{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
EnvelopeGenerator.Server\publish.bat = EnvelopeGenerator.Server\publish.bat
|
EnvelopeGenerator.Server\publish.bat = EnvelopeGenerator.Server\publish.bat
|
||||||
EnvelopeGenerator.Server\SF-74-PLAN.md = EnvelopeGenerator.Server\SF-74-PLAN.md
|
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj", "{4E6C54DA-576D-0955-2564-9EC890BB8279}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server\EnvelopeGenerator.Server\EnvelopeGenerator.Server.csproj", "{4E6C54DA-576D-0955-2564-9EC890BB8279}"
|
||||||
@@ -98,10 +93,6 @@ Global
|
|||||||
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Release|Any CPU.Build.0 = Release|Any CPU
|
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{EC768913-6270-14F4-1DD3-69C87A659462}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{EC768913-6270-14F4-1DD3-69C87A659462}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
@@ -139,7 +130,6 @@ Global
|
|||||||
{6D56C01F-D6CB-4D8A-BD3D-4FD34326998C} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
{6D56C01F-D6CB-4D8A-BD3D-4FD34326998C} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||||
{211619F5-AE25-4BA5-A552-BACAFE0632D3} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
{211619F5-AE25-4BA5-A552-BACAFE0632D3} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
||||||
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8} = {0CBC2432-A561-4440-89BC-671B66A24146}
|
{224C4845-1CDE-22B7-F3A9-1FF9297F70E8} = {0CBC2432-A561-4440-89BC-671B66A24146}
|
||||||
{EC768913-6270-14F4-1DD3-69C87A659462} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
|
||||||
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
{5DCCF9A1-C03F-90E6-87D3-E96DB25250C2} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||||
{BF1700D5-592E-4FFA-84E8-5480E289A1F0} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
{BF1700D5-592E-4FFA-84E8-5480E289A1F0} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||||
{4E6C54DA-576D-0955-2564-9EC890BB8279} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
|
{4E6C54DA-576D-0955-2564-9EC890BB8279} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
# Fix: Report Label "Read and confirmed" for Read and Sign Envelopes
|
|
||||||
|
|
||||||
## Status
|
|
||||||
✅ **COMPLETED**
|
|
||||||
|
|
||||||
## Git Branch
|
|
||||||
```
|
|
||||||
fix/report-label-read-and-confirmed
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
In the **Signature Certificate report** (`Signierungszertifikat`), the history event
|
|
||||||
list always showed **"Document signed"** for status `DocumentSigned` (code `2005`) —
|
|
||||||
even when the envelope type was **"Read and Sign"** (`EnvelopeTypeId = 2`).
|
|
||||||
|
|
||||||
For "Read and Sign" envelopes the correct label must be **"Read and confirmed"**
|
|
||||||
(DE: **"Gelesen und bestätigt"**).
|
|
||||||
|
|
||||||
### Screenshot reference
|
|
||||||
The report shows a table with columns *Ereignis | Benutzer | Zeitstempel*.
|
|
||||||
The row with status 2005 displayed "Document signed" where "Read and confirmed"
|
|
||||||
was expected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Root Cause
|
|
||||||
|
|
||||||
`ReportItem.ItemStatusTranslated` in
|
|
||||||
`EnvelopeGenerator.CommonServices\Jobs\FinalizeDocument\ReportItem.vb`
|
|
||||||
resolved the display label by calling:
|
|
||||||
|
|
||||||
```vb
|
|
||||||
My.Resources.Model.ResourceManager.GetString(ItemStatus.ToString())
|
|
||||||
```
|
|
||||||
|
|
||||||
There was **no branching logic** to handle the Read and Sign envelope type.
|
|
||||||
The same `DocumentSigned` key was always used regardless of envelope type.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
### Convention followed
|
|
||||||
The project already uses a `RaC` (**R**ead **a**nd **C**onfirm) suffix for
|
|
||||||
Read-and-Sign label variants across all resource files:
|
|
||||||
|
|
||||||
| Existing RaC keys |
|
|
||||||
|---|
|
|
||||||
| `DocumentRejectedRaC` |
|
|
||||||
| `EnvelopeReportCreatedRaC` |
|
|
||||||
| `MessageConfirmationSentRaC` |
|
|
||||||
|
|
||||||
### New resource key added: `DocumentSignedRaC`
|
|
||||||
|
|
||||||
| Resource file | Value |
|
|
||||||
|---|---|
|
|
||||||
| `Model.resx` (DE, default) | `Gelesen und bestätigt` |
|
|
||||||
| `Model.en.resx` (EN) | `Read and confirmed` |
|
|
||||||
|
|
||||||
### Logic change in `ReportItem.vb`
|
|
||||||
|
|
||||||
```vb
|
|
||||||
Public ReadOnly Property ItemStatusTranslated As String
|
|
||||||
Get
|
|
||||||
Dim oStatusKey = ItemStatus.ToString()
|
|
||||||
|
|
||||||
If ItemStatus = EnvelopeStatus.DocumentSigned AndAlso
|
|
||||||
Envelope IsNot Nothing AndAlso
|
|
||||||
Envelope.IsReadAndConfirm() Then
|
|
||||||
oStatusKey = "DocumentSignedRaC"
|
|
||||||
End If
|
|
||||||
|
|
||||||
Return My.Resources.Model.ResourceManager.GetString(oStatusKey)
|
|
||||||
End Get
|
|
||||||
End Property
|
|
||||||
```
|
|
||||||
|
|
||||||
`Envelope.IsReadAndConfirm()` is defined in
|
|
||||||
`EnvelopeGenerator.Domain\Interfaces\IEnvelope.cs` and returns `true`
|
|
||||||
when `EnvelopeTypeId == 2`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
|
|
||||||
| # | File | Change | Status |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | `EnvelopeGenerator.CommonServices\Jobs\FinalizeDocument\ReportItem.vb` | Added `RaC` branching logic to `ItemStatusTranslated` | ? Done |
|
|
||||||
| 2 | `EnvelopeGenerator.Domain\Resources\Model.resx` | Added `DocumentSignedRaC` = `Gelesen und bestätigt` | ✅ Done |
|
|
||||||
| 3 | `EnvelopeGenerator.CommonServices\Strings\Model.resx` | Added `DocumentSignedRaC` = `Gelesen und bestätigt` | ✅ Done |
|
|
||||||
| 4 | `EnvelopeGenerator.CommonServices\Strings\Model.en.resx` | Added `DocumentSignedRaC` = `Read and confirmed` | ✅ Done |
|
|
||||||
| 5 | `EnvelopeGenerator.Domain\Resources\Model.Designer.cs` | Added `DocumentSignedRaC` strongly-typed property | ✅ Done |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to resume (for next agent)
|
|
||||||
|
|
||||||
All pending items are **resource file additions only**.
|
|
||||||
Each file needs one new `<data>` block inserted **directly after** the existing
|
|
||||||
`DocumentSigned` entry:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<data name="DocumentSignedRaC" xml:space="preserve">
|
|
||||||
<value>Gelesen und bestätigt</value> <!-- DE files -->
|
|
||||||
<!-- OR -->
|
|
||||||
<value>Read and confirmed</value> <!-- EN file -->
|
|
||||||
</data>
|
|
||||||
```
|
|
||||||
|
|
||||||
`Model.Designer.cs` also needs a new strongly-typed property added after
|
|
||||||
`DocumentSigned`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
/// <summary>
|
|
||||||
/// Looks up a localized string similar to Gelesen und bestätigt.
|
|
||||||
/// </summary>
|
|
||||||
public static string DocumentSignedRaC {
|
|
||||||
get {
|
|
||||||
return ResourceManager.GetString("DocumentSignedRaC", resourceCulture);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
After changes, generate a Signature Certificate report for a **Read and Sign**
|
|
||||||
envelope and verify:
|
|
||||||
- History row with status 2005 shows **"Read and confirmed"** (EN)
|
|
||||||
- History row with status 2005 shows **"Gelesen und bestätigt"** (DE)
|
|
||||||
- Regular (non-RaC) envelopes still show **"Document signed"** / **"Dokument unterzeichnet"**
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# Signature Field Formatting — WISAG Feedback
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Customer feedback received from the new WISAG signFLOW project lead (email dated 3/7/2026).
|
|
||||||
The signature stamp rendered inside the finalized PDF has layout issues that need to be addressed.
|
|
||||||
|
|
||||||
### Current State (screenshot from customer email)
|
|
||||||
|
|
||||||
```
|
|
||||||
Signiert von ___________
|
|
||||||
[ signature image ]
|
|
||||||
Armin Schneider
|
|
||||||
C6B85E96A520C60... ← certificate hash, too close to name
|
|
||||||
Bereichsleiter Kern- ← position text wraps, no spacing to date
|
|
||||||
Anwendungen
|
|
||||||
Frankfurt am Main
|
|
||||||
23.06.2026, 17:13:42 GMT+2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issues Reported
|
|
||||||
|
|
||||||
1. **Spacing between name and certificate number** is too small — needs more vertical gap.
|
|
||||||
2. **Spacing between position/title and date/time** is too small — should be dynamic (expand with content).
|
|
||||||
3. *(Secondary, out of scope here)* Multiple signature fields side-by-side scale individually, causing uneven heights.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agreed Solution
|
|
||||||
|
|
||||||
> **"Height stays fixed. Text stretches horizontally. Beyond a certain character limit, truncate with '...'"**
|
|
||||||
> *(Original: "Höhe bleibt fix. Text dehnt sich horizontal aus. Ab einem bestimmten Limit wird mit '...' abgekürzt.")*
|
|
||||||
|
|
||||||
### Rationale
|
|
||||||
|
|
||||||
- The signature field dimensions are **fixed** (`1.77" × 1.96"`). Allowing text to wrap causes position and date rows to collide vertically.
|
|
||||||
- Truncating long strings with ellipsis (`...`) keeps the layout deterministic and prevents overflow.
|
|
||||||
- Per-field max-length limits allow tuning each text row independently (name, certificate, position, city, date).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
### 1. `PDFBurnerParams.vb` — Add per-field max-length dictionary
|
|
||||||
|
|
||||||
Add a new property `TextMaxLengths` that maps each field name (matching `EGName` keys) to a maximum character count.
|
|
||||||
|
|
||||||
```vbnet
|
|
||||||
' EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurnerParams.vb
|
|
||||||
|
|
||||||
Public Property TextMaxLengths As New Dictionary(Of String, Integer) From {
|
|
||||||
{"name", 28},
|
|
||||||
{"position", 32},
|
|
||||||
{"city", 24},
|
|
||||||
{"date", 28}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Default values are starting points and should be tuned against real customer data.
|
|
||||||
|
|
||||||
### 2. `PDFBurner.vb` — Add truncation helper and apply in `AddFormFieldValue`
|
|
||||||
|
|
||||||
#### 2a. Add a private helper function
|
|
||||||
|
|
||||||
```vbnet
|
|
||||||
Private Function TruncateText(value As String, fieldName As String) As String
|
|
||||||
If String.IsNullOrEmpty(value) Then Return value
|
|
||||||
|
|
||||||
Dim maxLen As Integer = 0
|
|
||||||
If _pdfBurnerParams.TextMaxLengths IsNot Nothing AndAlso
|
|
||||||
_pdfBurnerParams.TextMaxLengths.TryGetValue(fieldName, maxLen) AndAlso
|
|
||||||
maxLen > 0 AndAlso value.Length > maxLen Then
|
|
||||||
Return value.Substring(0, maxLen - 3) & "..."
|
|
||||||
End If
|
|
||||||
|
|
||||||
Return value
|
|
||||||
End Function
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2b. Apply truncation in the `egName`-aware overload
|
|
||||||
|
|
||||||
In `AddFormFieldValue(pAnnotation As Annotation, formFieldValue As FormFieldValue)`:
|
|
||||||
|
|
||||||
```vbnet
|
|
||||||
' Before passing value to AddTextAnnot, truncate:
|
|
||||||
Dim displayValue As String = TruncateText(formFieldValue.value, pAnnotation.egName)
|
|
||||||
|
|
||||||
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, displayValue)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2c. Apply truncation in the element-based overload (optional)
|
|
||||||
|
|
||||||
In `AddFormFieldValue(x, y, width, height, page, value)` — if a field name is available in calling context, pass it through; otherwise this path does not use `egName` and truncation can be skipped or applied with a generic fallback key.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Affected Files
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurnerParams.vb` | Add `TextMaxLengths As Dictionary(Of String, Integer)` |
|
|
||||||
| `EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurner.vb` | Add `TruncateText()` helper; apply in `AddFormFieldValue` overloads |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Field names (`"name"`, `"position"`, `"city"`, `"date"`) must match the keys used in `EGName.Index` dictionary (`PDFBurner.vb:443–450`).
|
|
||||||
- The certificate hash field (e.g. `C6B85E96A520C60...`) is already truncated upstream — verify its `egName` mapping if it needs its own limit.
|
|
||||||
- The `YOffset` / `TopMargin` values in `PDFBurnerParams` may also need adjustment to increase vertical spacing between rows. That is a separate (simpler) change.
|
|
||||||
- This issue is **not** related to the Blazor WebUI rendering — it is purely in the GdPicture14 PDF burning pipeline.
|
|
||||||
Reference in New Issue
Block a user