Compare commits
12 Commits
6d2ec4cc0b
...
feat/blazo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af28844714 | ||
|
|
8257ed573d | ||
|
|
93488ba83e | ||
|
|
4aa889f178 | ||
|
|
60d7043164 | ||
|
|
7aa9853756 | ||
|
|
0a544cfe85 | ||
|
|
4f3c66b4f7 | ||
|
|
7271a92d32 | ||
|
|
c7275ad966 | ||
|
|
bf8115259a | ||
|
|
590ab9bf02 |
406
EnvelopeGenerator.API/Controllers/ReceiverAuthController.cs
Normal file
406
EnvelopeGenerator.API/Controllers/ReceiverAuthController.cs
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
using EnvelopeGenerator.API.Models;
|
||||||
|
using EnvelopeGenerator.Application.Common.Extensions;
|
||||||
|
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||||
|
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.API.Extensions;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using OtpNet;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// REST-API für den Empfänger-Authentifizierungs-Flow.
|
||||||
|
///
|
||||||
|
/// Entspricht der Logik in EnvelopeGenerator.Web.Controllers.EnvelopeController
|
||||||
|
/// (Main + LogInEnvelope), aber gibt JSON statt Views zurück.
|
||||||
|
///
|
||||||
|
/// Der Blazor-Client (ReceiverUI) ruft diese Endpunkte auf.
|
||||||
|
///
|
||||||
|
/// FLOW:
|
||||||
|
/// 1. Client ruft GET /api/receiverauth/{key}/status → Prüft Status
|
||||||
|
/// 2. Client ruft POST /api/receiverauth/{key}/access-code → Sendet AccessCode
|
||||||
|
/// 3. Client ruft POST /api/receiverauth/{key}/tfa → Sendet TFA-Code
|
||||||
|
///
|
||||||
|
/// Nach erfolgreicher Authentifizierung wird ein Cookie gesetzt (SignInEnvelopeAsync).
|
||||||
|
/// Danach kann der Client die Dokument-Daten über die bestehenden Envelope-Endpunkte laden.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public class ReceiverAuthController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<ReceiverAuthController> _logger;
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
private readonly IEnvelopeReceiverService _envRcvService;
|
||||||
|
private readonly IEnvelopeHistoryService _historyService;
|
||||||
|
private readonly IAuthenticator _authenticator;
|
||||||
|
private readonly IReceiverService _rcvService;
|
||||||
|
private readonly IEnvelopeSmsHandler _envSmsHandler;
|
||||||
|
|
||||||
|
public ReceiverAuthController(
|
||||||
|
ILogger<ReceiverAuthController> logger,
|
||||||
|
IMediator mediator,
|
||||||
|
IEnvelopeReceiverService envRcvService,
|
||||||
|
IEnvelopeHistoryService historyService,
|
||||||
|
IAuthenticator authenticator,
|
||||||
|
IReceiverService rcvService,
|
||||||
|
IEnvelopeSmsHandler envSmsHandler)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_mediator = mediator;
|
||||||
|
_envRcvService = envRcvService;
|
||||||
|
_historyService = historyService;
|
||||||
|
_authenticator = authenticator;
|
||||||
|
_rcvService = rcvService;
|
||||||
|
_envSmsHandler = envSmsHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// ENDPUNKT 1: STATUS PRÜFEN
|
||||||
|
// Entspricht: Web.EnvelopeController.Main()
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft den aktuellen Status eines Umschlags für den Empfänger.
|
||||||
|
/// Entscheidet ob: NotFound, Rejected, Signed, AccessCode nötig, oder direkt anzeigen.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="key">Der EnvelopeReceiver-Key aus der URL (Base64-kodiert)</param>
|
||||||
|
/// <param name="cancel">Cancellation-Token</param>
|
||||||
|
/// <returns>ReceiverAuthResponse mit dem aktuellen Status</returns>
|
||||||
|
[HttpGet("{key}/status")]
|
||||||
|
public async Task<IActionResult> GetStatus([FromRoute] string key, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// ── Key dekodieren ──
|
||||||
|
if (!key.TryDecode(out var decoded))
|
||||||
|
return NotFound(new ReceiverAuthResponse { Status = "not_found" });
|
||||||
|
|
||||||
|
// ── ReadOnly-Links ──
|
||||||
|
if (decoded.GetEncodeType() == EncodeType.EnvelopeReceiverReadOnly)
|
||||||
|
{
|
||||||
|
return Ok(new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "show_document",
|
||||||
|
ReadOnly = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EnvelopeReceiver laden ──
|
||||||
|
var er = await _mediator.ReadEnvelopeReceiverAsync(key, cancel);
|
||||||
|
if (er is null)
|
||||||
|
return NotFound(new ReceiverAuthResponse { Status = "not_found" });
|
||||||
|
|
||||||
|
// ── Abgelehnt? ──
|
||||||
|
var rejRcvrs = await _historyService.ReadRejectingReceivers(er.Envelope!.Id);
|
||||||
|
if (rejRcvrs.Any())
|
||||||
|
{
|
||||||
|
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return Ok(new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "rejected",
|
||||||
|
Title = er.Envelope.Title,
|
||||||
|
SenderEmail = er.Envelope.User?.Email
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bereits signiert? ──
|
||||||
|
if (await _historyService.IsSigned(
|
||||||
|
envelopeId: er.Envelope.Id,
|
||||||
|
userReference: er.Receiver!.EmailAddress))
|
||||||
|
{
|
||||||
|
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return Ok(new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "already_signed",
|
||||||
|
Title = er.Envelope.Title,
|
||||||
|
SenderEmail = er.Envelope.User?.Email
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kein AccessCode nötig? → Direkt SignIn ──
|
||||||
|
if (!er.Envelope.UseAccessCode)
|
||||||
|
{
|
||||||
|
(string? uuid, string? signature) = decoded.ParseEnvelopeReceiverId();
|
||||||
|
var erSecretRes = await _envRcvService.ReadWithSecretByUuidSignatureAsync(
|
||||||
|
uuid: uuid!, signature: signature!);
|
||||||
|
|
||||||
|
if (erSecretRes.IsFailed)
|
||||||
|
return NotFound(new ReceiverAuthResponse { Status = "not_found" });
|
||||||
|
|
||||||
|
await HttpContext.SignInEnvelopeAsync(erSecretRes.Data, Role.ReceiverFull);
|
||||||
|
|
||||||
|
return Ok(new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "show_document",
|
||||||
|
Title = er.Envelope.Title,
|
||||||
|
Message = er.Envelope.Message,
|
||||||
|
SenderEmail = er.Envelope.User?.Email,
|
||||||
|
ReadOnly = er.Envelope.ReadOnly
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AccessCode nötig ──
|
||||||
|
// HINWEIS: Die E-Mail mit dem AccessCode wird NICHT hier gesendet.
|
||||||
|
// Das passiert bereits im Web-Projekt, wenn der Link generiert wird.
|
||||||
|
// Der Blazor-Flow übernimmt erst NACH dem E-Mail-Versand.
|
||||||
|
bool accessCodeAlreadyRequested = await _historyService.AccessCodeAlreadyRequested(
|
||||||
|
envelopeId: er.Envelope.Id,
|
||||||
|
userReference: er.Receiver.EmailAddress);
|
||||||
|
|
||||||
|
if (!accessCodeAlreadyRequested)
|
||||||
|
{
|
||||||
|
// AccessCode wurde noch nie angefordert — das bedeutet der Empfänger
|
||||||
|
// kommt zum ersten Mal. Wir zeichnen es auf, aber die E-Mail
|
||||||
|
// wurde bereits vom Web-Projekt gesendet.
|
||||||
|
await _historyService.RecordAsync(
|
||||||
|
er.EnvelopeId, er.Receiver.EmailAddress, EnvelopeStatus.AccessCodeRequested);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Prüfe ob der Nutzer bereits eingeloggt ist ──
|
||||||
|
if (User.IsInRole(Role.ReceiverFull))
|
||||||
|
{
|
||||||
|
return Ok(new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "show_document",
|
||||||
|
Title = er.Envelope.Title,
|
||||||
|
Message = er.Envelope.Message,
|
||||||
|
SenderEmail = er.Envelope.User?.Email,
|
||||||
|
ReadOnly = er.Envelope.ReadOnly
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "requires_access_code",
|
||||||
|
Title = er.Envelope.Title,
|
||||||
|
SenderEmail = er.Envelope.User?.Email,
|
||||||
|
TfaEnabled = er.Envelope.TFAEnabled,
|
||||||
|
HasPhoneNumber = er.HasPhoneNumber
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error checking status for key {Key}", key);
|
||||||
|
return StatusCode(500, new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "error",
|
||||||
|
ErrorMessage = "Ein unerwarteter Fehler ist aufgetreten."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// ENDPUNKT 2: ACCESS-CODE PRÜFEN
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft den eingegebenen Zugangscode.
|
||||||
|
/// Bei Erfolg: SignIn oder TFA-Weiterleitung.
|
||||||
|
/// Bei Fehler: Fehlermeldung zurückgeben.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{key}/access-code")]
|
||||||
|
public async Task<IActionResult> SubmitAccessCode(
|
||||||
|
[FromRoute] string key,
|
||||||
|
[FromBody] AccessCodeRequest request,
|
||||||
|
CancellationToken cancel)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// ── Key dekodieren + Daten laden ──
|
||||||
|
(string? uuid, string? signature) = key.DecodeEnvelopeReceiverId();
|
||||||
|
if (uuid is null || signature is null)
|
||||||
|
return NotFound(new ReceiverAuthResponse { Status = "not_found" });
|
||||||
|
|
||||||
|
var erSecretRes = await _envRcvService.ReadWithSecretByUuidSignatureAsync(
|
||||||
|
uuid: uuid, signature: signature);
|
||||||
|
|
||||||
|
if (erSecretRes.IsFailed)
|
||||||
|
return NotFound(new ReceiverAuthResponse { Status = "not_found" });
|
||||||
|
|
||||||
|
var erSecret = erSecretRes.Data;
|
||||||
|
|
||||||
|
// ── AccessCode prüfen ──
|
||||||
|
if (erSecret.AccessCode != request.AccessCode)
|
||||||
|
{
|
||||||
|
await _historyService.RecordAsync(
|
||||||
|
erSecret.EnvelopeId,
|
||||||
|
erSecret.Receiver!.EmailAddress,
|
||||||
|
EnvelopeStatus.AccessCodeIncorrect);
|
||||||
|
|
||||||
|
return Unauthorized(new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "requires_access_code",
|
||||||
|
Title = erSecret.Envelope!.Title,
|
||||||
|
SenderEmail = erSecret.Envelope.User?.Email,
|
||||||
|
TfaEnabled = erSecret.Envelope.TFAEnabled,
|
||||||
|
HasPhoneNumber = erSecret.HasPhoneNumber,
|
||||||
|
ErrorMessage = "Falscher Zugangscode."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AccessCode korrekt ──
|
||||||
|
await _historyService.RecordAsync(
|
||||||
|
erSecret.EnvelopeId,
|
||||||
|
erSecret.Receiver!.EmailAddress,
|
||||||
|
EnvelopeStatus.AccessCodeCorrect);
|
||||||
|
|
||||||
|
// ── TFA erforderlich? ──
|
||||||
|
if (erSecret.Envelope!.TFAEnabled)
|
||||||
|
{
|
||||||
|
var rcv = erSecret.Receiver;
|
||||||
|
if (rcv.TotpSecretkey is null)
|
||||||
|
{
|
||||||
|
rcv.TotpSecretkey = _authenticator.GenerateTotpSecretKey();
|
||||||
|
await _rcvService.UpdateAsync(rcv);
|
||||||
|
}
|
||||||
|
|
||||||
|
await HttpContext.SignInEnvelopeAsync(erSecret, Role.ReceiverTFA);
|
||||||
|
|
||||||
|
if (request.PreferSms)
|
||||||
|
{
|
||||||
|
var (smsRes, expiration) = await _envSmsHandler.SendTotpAsync(erSecret);
|
||||||
|
|
||||||
|
return Ok(new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "requires_tfa",
|
||||||
|
TfaType = "sms",
|
||||||
|
TfaExpiration = expiration,
|
||||||
|
Title = erSecret.Envelope.Title,
|
||||||
|
SenderEmail = erSecret.Envelope.User?.Email,
|
||||||
|
HasPhoneNumber = erSecret.HasPhoneNumber
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return Ok(new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "requires_tfa",
|
||||||
|
TfaType = "authenticator",
|
||||||
|
Title = erSecret.Envelope.Title,
|
||||||
|
SenderEmail = erSecret.Envelope.User?.Email,
|
||||||
|
HasPhoneNumber = erSecret.HasPhoneNumber
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Kein TFA → Direkt SignIn ──
|
||||||
|
await HttpContext.SignInEnvelopeAsync(erSecret, Role.ReceiverFull);
|
||||||
|
|
||||||
|
return Ok(new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "show_document",
|
||||||
|
Title = erSecret.Envelope.Title,
|
||||||
|
Message = erSecret.Envelope.Message,
|
||||||
|
SenderEmail = erSecret.Envelope.User?.Email,
|
||||||
|
ReadOnly = erSecret.Envelope.ReadOnly
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error submitting access code for key {Key}", key);
|
||||||
|
return StatusCode(500, new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "error",
|
||||||
|
ErrorMessage = "Ein unerwarteter Fehler ist aufgetreten."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// ENDPUNKT 3: TFA-CODE PRÜFEN
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft den TFA-Code (SMS oder Authenticator).
|
||||||
|
/// Setzt voraus, dass der Nutzer bereits mit ReceiverTFA-Rolle eingeloggt ist.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost("{key}/tfa")]
|
||||||
|
public async Task<IActionResult> SubmitTfaCode(
|
||||||
|
[FromRoute] string key,
|
||||||
|
[FromBody] TfaCodeRequest request,
|
||||||
|
CancellationToken cancel)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!User.IsInRole(Role.ReceiverTFA))
|
||||||
|
return Unauthorized(new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "requires_access_code",
|
||||||
|
ErrorMessage = "Bitte zuerst den Zugangscode eingeben."
|
||||||
|
});
|
||||||
|
|
||||||
|
(string? uuid, string? signature) = key.DecodeEnvelopeReceiverId();
|
||||||
|
if (uuid is null || signature is null)
|
||||||
|
return NotFound(new ReceiverAuthResponse { Status = "not_found" });
|
||||||
|
|
||||||
|
var erSecretRes = await _envRcvService.ReadWithSecretByUuidSignatureAsync(
|
||||||
|
uuid: uuid, signature: signature);
|
||||||
|
|
||||||
|
if (erSecretRes.IsFailed)
|
||||||
|
return NotFound(new ReceiverAuthResponse { Status = "not_found" });
|
||||||
|
|
||||||
|
var erSecret = erSecretRes.Data;
|
||||||
|
|
||||||
|
if (erSecret.Receiver!.TotpSecretkey is null)
|
||||||
|
{
|
||||||
|
_logger.LogError("TotpSecretkey is null for receiver {Signature}", signature);
|
||||||
|
return StatusCode(500, new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "error",
|
||||||
|
ErrorMessage = "TFA-Konfiguration fehlt."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool codeValid;
|
||||||
|
|
||||||
|
if (request.Type == "sms")
|
||||||
|
{
|
||||||
|
codeValid = _envSmsHandler.VerifyTotp(request.Code, erSecret.Receiver.TotpSecretkey);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
codeValid = _authenticator.VerifyTotp(
|
||||||
|
request.Code,
|
||||||
|
erSecret.Receiver.TotpSecretkey,
|
||||||
|
window: VerificationWindow.RfcSpecifiedNetworkDelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!codeValid)
|
||||||
|
{
|
||||||
|
return Unauthorized(new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "requires_tfa",
|
||||||
|
TfaType = request.Type,
|
||||||
|
Title = erSecret.Envelope!.Title,
|
||||||
|
SenderEmail = erSecret.Envelope.User?.Email,
|
||||||
|
HasPhoneNumber = erSecret.HasPhoneNumber,
|
||||||
|
ErrorMessage = "Falscher Code."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await HttpContext.SignInEnvelopeAsync(erSecret, Role.ReceiverFull);
|
||||||
|
|
||||||
|
return Ok(new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "show_document",
|
||||||
|
Title = erSecret.Envelope!.Title,
|
||||||
|
Message = erSecret.Envelope.Message,
|
||||||
|
SenderEmail = erSecret.Envelope.User?.Email,
|
||||||
|
ReadOnly = erSecret.Envelope.ReadOnly
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error submitting TFA code for key {Key}", key);
|
||||||
|
return StatusCode(500, new ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
Status = "error",
|
||||||
|
ErrorMessage = "Ein unerwarteter Fehler ist aufgetreten."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
EnvelopeGenerator.API/Models/ReceiverAuthResponse.cs
Normal file
78
EnvelopeGenerator.API/Models/ReceiverAuthResponse.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
namespace EnvelopeGenerator.API.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Einheitliche Antwort des ReceiverAuthControllers.
|
||||||
|
///
|
||||||
|
/// WARUM ein einziges Response-Objekt für alle Endpunkte?
|
||||||
|
/// - Der Client braucht nur ein Format zu verstehen
|
||||||
|
/// - Der Status-String bestimmt, welche Felder relevant sind
|
||||||
|
/// - Entspricht dem, was der Web-Controller bisher über ViewData verteilt hat
|
||||||
|
///
|
||||||
|
/// Status-Werte und was sie bedeuten:
|
||||||
|
/// - "requires_access_code" → AccessCode-Eingabe zeigen
|
||||||
|
/// - "requires_tfa" → TFA-Code-Eingabe zeigen (nach AccessCode)
|
||||||
|
/// - "show_document" → Dokument laden und anzeigen
|
||||||
|
/// - "already_signed" → Info-Seite "Bereits unterschrieben"
|
||||||
|
/// - "rejected" → Info-Seite "Abgelehnt"
|
||||||
|
/// - "not_found" → Fehler-Seite "Nicht gefunden"
|
||||||
|
/// - "expired" → Fehler-Seite "Link abgelaufen"
|
||||||
|
/// </summary>
|
||||||
|
public class ReceiverAuthResponse
|
||||||
|
{
|
||||||
|
/// <summary>Aktueller Status des Empfänger-Flows</summary>
|
||||||
|
public required string Status { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Titel des Umschlags (z.B. "Vertragsdokument")</summary>
|
||||||
|
public string? Title { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Nachricht des Absenders</summary>
|
||||||
|
public string? Message { get; init; }
|
||||||
|
|
||||||
|
/// <summary>E-Mail des Absenders (für Rückfragen-Hinweis)</summary>
|
||||||
|
public string? SenderEmail { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Name des Empfängers</summary>
|
||||||
|
public string? ReceiverName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Ob TFA für diesen Umschlag aktiviert ist</summary>
|
||||||
|
public bool TfaEnabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Ob der Empfänger eine Telefonnummer hat (für SMS-TFA)</summary>
|
||||||
|
public bool HasPhoneNumber { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Ob das Dokument nur gelesen werden soll (ReadAndConfirm)</summary>
|
||||||
|
public bool ReadOnly { get; init; }
|
||||||
|
|
||||||
|
/// <summary>TFA-Typ: "sms" oder "authenticator" (wenn Status = "requires_tfa")</summary>
|
||||||
|
public string? TfaType { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Ablaufzeit des SMS-Codes (für Countdown-Timer)</summary>
|
||||||
|
public DateTime? TfaExpiration { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Fehlermeldung (z.B. "Falscher Zugangscode")</summary>
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request-Body für POST /api/receiverauth/{key}/access-code
|
||||||
|
/// </summary>
|
||||||
|
public class AccessCodeRequest
|
||||||
|
{
|
||||||
|
/// <summary>Der vom Empfänger eingegebene Zugangscode</summary>
|
||||||
|
public required string AccessCode { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Ob SMS statt Authenticator bevorzugt wird</summary>
|
||||||
|
public bool PreferSms { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request-Body für POST /api/receiverauth/{key}/tfa
|
||||||
|
/// </summary>
|
||||||
|
public class TfaCodeRequest
|
||||||
|
{
|
||||||
|
/// <summary>Der eingegebene TFA-Code (6-stellig)</summary>
|
||||||
|
public required string Code { get; init; }
|
||||||
|
|
||||||
|
/// <summary>"sms" oder "authenticator"</summary>
|
||||||
|
public required string Type { get; init; }
|
||||||
|
}
|
||||||
@@ -7,10 +7,11 @@ Imports GdPicture14
|
|||||||
Imports Newtonsoft.Json.Linq
|
Imports Newtonsoft.Json.Linq
|
||||||
Imports EnvelopeGenerator.Infrastructure
|
Imports EnvelopeGenerator.Infrastructure
|
||||||
Imports Microsoft.EntityFrameworkCore
|
Imports Microsoft.EntityFrameworkCore
|
||||||
|
Imports System.Text
|
||||||
Imports DigitalData.Core.Abstractions
|
Imports DigitalData.Core.Abstractions
|
||||||
|
|
||||||
Public Class frmFinalizePDF
|
Public Class frmFinalizePDF
|
||||||
Private Const CONNECTIONSTRING = "Server=sDD-VMP04-SQL17\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=+bk8oAbbQP1AzoHtvZUbd+Mbok2f8Fl4miEx1qssJ5yEaEWoQJ9prg4L14fURpPnqi1WMNs9fE4=;" + "Encrypt=True;TrustServerCertificate=True;"
|
Private Const CONNECTIONSTRING = "Server=sDD-VMP04-SQL17\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=+bk8oAbbQP1AzoHtvZUbd+Mbok2f8Fl4miEx1qssJ5yEaEWoQJ9prg4L14fURpPnqi1WMNs9fE4=;"
|
||||||
|
|
||||||
Private Database As MSSQLServer
|
Private Database As MSSQLServer
|
||||||
Private LogConfig As LogConfig
|
Private LogConfig As LogConfig
|
||||||
@@ -92,36 +93,56 @@ Public Class frmFinalizePDF
|
|||||||
End Function
|
End Function
|
||||||
|
|
||||||
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
|
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
|
||||||
Dim oTable = LoadAnnotationDataForEnvelope()
|
Try
|
||||||
Dim oJsonList = oTable.Rows.
|
|
||||||
Cast(Of DataRow).
|
|
||||||
Select(Function(r As DataRow) r.Item("VALUE").ToString()).
|
|
||||||
ToList()
|
|
||||||
|
|
||||||
Dim envelopeId As Integer = CInt(txtEnvelope.Text)
|
Dim oTable = LoadAnnotationDataForEnvelope()
|
||||||
Dim oBuffer As Byte() = ReadEnvelope(envelopeId)
|
Dim oJsonList = oTable.Rows.
|
||||||
Dim oNewBuffer = PDFBurner.BurnAnnotsToPDF(oBuffer, oJsonList, envelopeId)
|
Cast(Of DataRow).
|
||||||
Dim desktopPath As String = Environment.GetFolderPath(Environment.SpecialFolder.Desktop)
|
Select(Function(r As DataRow) r.Item("VALUE").ToString()).
|
||||||
Dim oNewPath = Path.Combine(desktopPath, $"E{txtEnvelope.Text}R{txtReceiver.Text}.burned.pdf")
|
ToList()
|
||||||
|
|
||||||
File.WriteAllBytes(oNewPath, oNewBuffer)
|
Dim envelopeId As Integer = CInt(txtEnvelope.Text)
|
||||||
|
Dim oBuffer As Byte() = ReadEnvelope(envelopeId)
|
||||||
|
Dim oNewBuffer = PDFBurner.BurnAnnotsToPDF(oBuffer, oJsonList, envelopeId)
|
||||||
|
Dim desktopPath As String = Environment.GetFolderPath(Environment.SpecialFolder.Desktop)
|
||||||
|
Dim oNewPath = Path.Combine(desktopPath, $"E{txtEnvelope.Text}R{txtReceiver.Text}.burned.pdf")
|
||||||
|
|
||||||
|
File.WriteAllBytes(oNewPath, oNewBuffer)
|
||||||
|
|
||||||
|
Process.Start(oNewPath)
|
||||||
|
Catch ex As Exception
|
||||||
|
Dim exMsg As StringBuilder = New StringBuilder(ex.Message).AppendLine()
|
||||||
|
|
||||||
|
Dim innerEx = ex.InnerException
|
||||||
|
While (innerEx IsNot Nothing)
|
||||||
|
exMsg.AppendLine(innerEx.Message)
|
||||||
|
innerEx = innerEx.InnerException
|
||||||
|
End While
|
||||||
|
|
||||||
|
MsgBox(exMsg.ToString(), MsgBoxStyle.Critical)
|
||||||
|
End Try
|
||||||
|
|
||||||
Process.Start(oNewPath)
|
|
||||||
End Sub
|
End Sub
|
||||||
|
|
||||||
Private Sub Button2_Click(sender As Object, e As EventArgs) Handles Button2.Click
|
Private Sub Button2_Click(sender As Object, e As EventArgs) Handles Button2.Click
|
||||||
Dim oTable = LoadAnnotationDataForEnvelope()
|
Try
|
||||||
Dim oJsonList = oTable.Rows.
|
Dim oTable = LoadAnnotationDataForEnvelope()
|
||||||
Cast(Of DataRow).
|
Dim oJsonList = oTable.Rows.
|
||||||
Select(Function(r As DataRow) r.Item("VALUE").ToString()).
|
Cast(Of DataRow).
|
||||||
Select(Function(s As String) JObject.Parse(s)).
|
Select(Function(r As DataRow) r.Item("VALUE").ToString()).
|
||||||
ToList()
|
Select(Function(s As String) JObject.Parse(s)).
|
||||||
|
ToList()
|
||||||
|
|
||||||
Dim oJObject1 = oJsonList.First()
|
Dim oJObject1 = oJsonList.First()
|
||||||
Dim oJObject2 = oJsonList.ElementAt(1)
|
Dim oJObject2 = oJsonList.ElementAt(1)
|
||||||
|
|
||||||
oJObject1.Merge(oJObject2)
|
oJObject1.Merge(oJObject2)
|
||||||
|
|
||||||
txtResult.Text = oJObject1.ToString()
|
txtResult.Text = oJObject1.ToString()
|
||||||
|
|
||||||
|
|
||||||
|
Catch ex As Exception
|
||||||
|
MsgBox(ex.Message, MsgBoxStyle.Critical)
|
||||||
|
End Try
|
||||||
End Sub
|
End Sub
|
||||||
End Class
|
End Class
|
||||||
@@ -35,10 +35,8 @@ namespace EnvelopeGenerator.Domain.Entities
|
|||||||
public DateTime AddedWhen { get; set; }
|
public DateTime AddedWhen { get; set; }
|
||||||
|
|
||||||
[Column("ACTION_DATE", TypeName = "datetime")]
|
[Column("ACTION_DATE", TypeName = "datetime")]
|
||||||
public DateTime? ActionDate { get; set; }
|
public DateTime? ChangedWhen { get; set; }
|
||||||
|
|
||||||
public DateTime? ChangedWhen { get => ActionDate; set => ActionDate = value; }
|
|
||||||
|
|
||||||
[Column("COMMENT", TypeName = "nvarchar(max)")]
|
[Column("COMMENT", TypeName = "nvarchar(max)")]
|
||||||
public string
|
public string
|
||||||
#if nullable
|
#if nullable
|
||||||
|
|||||||
26
EnvelopeGenerator.Jobs/EnvelopeGenerator.Jobs.csproj
Normal file
26
EnvelopeGenerator.Jobs/EnvelopeGenerator.Jobs.csproj
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||||
|
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
|
||||||
|
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
|
||||||
|
<PackageReference Include="Quartz" Version="3.9.0" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
||||||
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
151
EnvelopeGenerator.Jobs/Jobs/APIBackendJobs/APIEnvelopeJob.cs
Normal file
151
EnvelopeGenerator.Jobs/Jobs/APIBackendJobs/APIEnvelopeJob.cs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Jobs.APIBackendJobs;
|
||||||
|
|
||||||
|
public class APIEnvelopeJob(ILogger<APIEnvelopeJob>? logger = null) : IJob
|
||||||
|
{
|
||||||
|
private readonly ILogger<APIEnvelopeJob> _logger = logger ?? NullLogger<APIEnvelopeJob>.Instance;
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var jobId = context.JobDetail.Key.ToString();
|
||||||
|
_logger.LogDebug("API Envelopes - Starting job {JobId}", jobId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var connectionString = context.MergedJobDataMap.GetString(Value.DATABASE);
|
||||||
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("API Envelopes - Connection string missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var connection = new SqlConnection(connectionString);
|
||||||
|
await connection.OpenAsync(context.CancellationToken);
|
||||||
|
|
||||||
|
await ProcessInvitationsAsync(connection, context.CancellationToken);
|
||||||
|
await ProcessWithdrawnAsync(connection, context.CancellationToken);
|
||||||
|
|
||||||
|
_logger.LogDebug("API Envelopes - Completed job {JobId} successfully", jobId);
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "API Envelopes job failed");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_logger.LogDebug("API Envelopes execution for {JobId} ended", jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessInvitationsAsync(SqlConnection connection, System.Threading.CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql = "SELECT GUID FROM TBSIG_ENVELOPE WHERE SOURCE = 'API' AND STATUS = 1003 ORDER BY GUID";
|
||||||
|
var envelopeIds = new List<int>();
|
||||||
|
|
||||||
|
await using (var command = new SqlCommand(sql, connection))
|
||||||
|
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
if (reader[0] is int id)
|
||||||
|
{
|
||||||
|
envelopeIds.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelopeIds.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("SendInvMail - No envelopes found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("SendInvMail - Found {Count} envelopes", envelopeIds.Count);
|
||||||
|
var total = envelopeIds.Count;
|
||||||
|
var current = 1;
|
||||||
|
|
||||||
|
foreach (var id in envelopeIds)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("SendInvMail - Processing Envelope {EnvelopeId} ({Current}/{Total})", id, current, total);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Placeholder for invitation email sending logic.
|
||||||
|
_logger.LogDebug("SendInvMail - Marking envelope {EnvelopeId} as queued", id);
|
||||||
|
const string updateSql = "UPDATE TBSIG_ENVELOPE SET CURRENT_WORK_APP = @App WHERE GUID = @Id";
|
||||||
|
await using var updateCommand = new SqlCommand(updateSql, connection);
|
||||||
|
updateCommand.Parameters.AddWithValue("@App", "signFLOW_API_EnvJob_InvMail");
|
||||||
|
updateCommand.Parameters.AddWithValue("@Id", id);
|
||||||
|
await updateCommand.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "SendInvMail - Unhandled exception while working envelope {EnvelopeId}", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
current++;
|
||||||
|
_logger.LogInformation("SendInvMail - Envelope finalized");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessWithdrawnAsync(SqlConnection connection, System.Threading.CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql = @"SELECT ENV.GUID, REJ.COMMENT AS REJECTION_REASON FROM
|
||||||
|
(SELECT * FROM TBSIG_ENVELOPE WHERE STATUS = 1009 AND SOURCE = 'API') ENV INNER JOIN
|
||||||
|
(SELECT MAX(GUID) GUID, ENVELOPE_ID, MAX(ADDED_WHEN) ADDED_WHEN, MAX(ACTION_DATE) ACTION_DATE, COMMENT FROM TBSIG_ENVELOPE_HISTORY WHERE STATUS = 1009 GROUP BY ENVELOPE_ID, COMMENT ) REJ ON ENV.GUID = REJ.ENVELOPE_ID LEFT JOIN
|
||||||
|
(SELECT * FROM TBSIG_ENVELOPE_HISTORY WHERE STATUS = 3004 ) M_Send ON ENV.GUID = M_Send.ENVELOPE_ID
|
||||||
|
WHERE M_Send.GUID IS NULL";
|
||||||
|
|
||||||
|
var withdrawn = new List<(int EnvelopeId, string Reason)>();
|
||||||
|
await using (var command = new SqlCommand(sql, connection))
|
||||||
|
await using (var reader = await command.ExecuteReaderAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
var id = reader.GetInt32(0);
|
||||||
|
var reason = reader.IsDBNull(1) ? string.Empty : reader.GetString(1);
|
||||||
|
withdrawn.Add((id, reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withdrawn.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("WithdrawnEnv - No envelopes found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("WithdrawnEnv - Found {Count} envelopes", withdrawn.Count);
|
||||||
|
var total = withdrawn.Count;
|
||||||
|
var current = 1;
|
||||||
|
|
||||||
|
foreach (var (envelopeId, reason) in withdrawn)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("WithdrawnEnv - Processing Envelope {EnvelopeId} ({Current}/{Total})", envelopeId, current, total);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Log withdrawn mail trigger placeholder
|
||||||
|
const string insertHistory = "INSERT INTO TBSIG_ENVELOPE_HISTORY (ENVELOPE_ID, STATUS, USER_REFERENCE, ADDED_WHEN, ACTION_DATE, COMMENT) VALUES (@EnvelopeId, @Status, @UserReference, GETDATE(), GETDATE(), @Comment)";
|
||||||
|
await using var insertCommand = new SqlCommand(insertHistory, connection);
|
||||||
|
insertCommand.Parameters.AddWithValue("@EnvelopeId", envelopeId);
|
||||||
|
insertCommand.Parameters.AddWithValue("@Status", 3004);
|
||||||
|
insertCommand.Parameters.AddWithValue("@UserReference", "API");
|
||||||
|
insertCommand.Parameters.AddWithValue("@Comment", reason ?? string.Empty);
|
||||||
|
await insertCommand.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "WithdrawnEnv - Unhandled exception while working envelope {EnvelopeId}", envelopeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
current++;
|
||||||
|
_logger.LogInformation("WithdrawnEnv - Envelope finalized");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
EnvelopeGenerator.Jobs/Jobs/DataRowExtensions.cs
Normal file
30
EnvelopeGenerator.Jobs/Jobs/DataRowExtensions.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using System;
|
||||||
|
using System.Data;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Jobs;
|
||||||
|
|
||||||
|
public static class DataRowExtensions
|
||||||
|
{
|
||||||
|
public static T? GetValueOrDefault<T>(this DataRow row, string columnName, T? defaultValue = default)
|
||||||
|
{
|
||||||
|
if (!row.Table.Columns.Contains(columnName))
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var value = row[columnName];
|
||||||
|
if (value == DBNull.Value)
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (T)Convert.ChangeType(value, typeof(T));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace EnvelopeGenerator.Jobs.FinalizeDocument;
|
||||||
|
|
||||||
|
public static class FinalizeDocumentExceptions
|
||||||
|
{
|
||||||
|
public class MergeDocumentException : ApplicationException
|
||||||
|
{
|
||||||
|
public MergeDocumentException(string message) : base(message) { }
|
||||||
|
public MergeDocumentException(string message, Exception innerException) : base(message, innerException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BurnAnnotationException : ApplicationException
|
||||||
|
{
|
||||||
|
public BurnAnnotationException(string message) : base(message) { }
|
||||||
|
public BurnAnnotationException(string message, Exception innerException) : base(message, innerException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateReportException : ApplicationException
|
||||||
|
{
|
||||||
|
public CreateReportException(string message) : base(message) { }
|
||||||
|
public CreateReportException(string message, Exception innerException) : base(message, innerException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExportDocumentException : ApplicationException
|
||||||
|
{
|
||||||
|
public ExportDocumentException(string message) : base(message) { }
|
||||||
|
public ExportDocumentException(string message, Exception innerException) : base(message, innerException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Data;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Quartz;
|
||||||
|
using static EnvelopeGenerator.Jobs.FinalizeDocument.FinalizeDocumentExceptions;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Jobs.FinalizeDocument;
|
||||||
|
|
||||||
|
public class FinalizeDocumentJob : IJob
|
||||||
|
{
|
||||||
|
private readonly ILogger<FinalizeDocumentJob> _logger;
|
||||||
|
private readonly PDFBurner _pdfBurner;
|
||||||
|
private readonly PDFMerger _pdfMerger;
|
||||||
|
private readonly ReportCreator _reportCreator;
|
||||||
|
|
||||||
|
private record ConfigSettings(string DocumentPath, string DocumentPathOrigin, string ExportPath);
|
||||||
|
|
||||||
|
public FinalizeDocumentJob(
|
||||||
|
ILogger<FinalizeDocumentJob> logger,
|
||||||
|
PDFBurner pdfBurner,
|
||||||
|
PDFMerger pdfMerger,
|
||||||
|
ReportCreator reportCreator)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_pdfBurner = pdfBurner;
|
||||||
|
_pdfMerger = pdfMerger;
|
||||||
|
_reportCreator = reportCreator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Execute(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
var jobId = context.JobDetail.Key.ToString();
|
||||||
|
_logger.LogDebug("Starting job {JobId}", jobId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var connectionString = context.MergedJobDataMap.GetString(Value.DATABASE);
|
||||||
|
if (string.IsNullOrWhiteSpace(connectionString))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("FinalizeDocument - Connection string missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var connection = new SqlConnection(connectionString);
|
||||||
|
await connection.OpenAsync(context.CancellationToken);
|
||||||
|
|
||||||
|
var config = await LoadConfigurationAsync(connection, context.CancellationToken);
|
||||||
|
var envelopes = await LoadCompletedEnvelopesAsync(connection, context.CancellationToken);
|
||||||
|
|
||||||
|
if (envelopes.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("No completed envelopes found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = envelopes.Count;
|
||||||
|
var current = 1;
|
||||||
|
|
||||||
|
foreach (var envelopeId in envelopes)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Finalizing Envelope {EnvelopeId} ({Current}/{Total})", envelopeId, current, total);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var envelopeData = await GetEnvelopeDataAsync(connection, envelopeId, context.CancellationToken);
|
||||||
|
if (envelopeData is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Envelope data not found for {EnvelopeId}", envelopeId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = envelopeData.Value;
|
||||||
|
|
||||||
|
var envelope = new Envelope
|
||||||
|
{
|
||||||
|
Id = envelopeId,
|
||||||
|
Uuid = data.EnvelopeUuid ?? string.Empty,
|
||||||
|
Title = data.Title ?? string.Empty,
|
||||||
|
FinalEmailToCreator = (int)FinalEmailType.No,
|
||||||
|
FinalEmailToReceivers = (int)FinalEmailType.No
|
||||||
|
};
|
||||||
|
|
||||||
|
var burned = _pdfBurner.BurnAnnotsToPDF(data.DocumentBytes, data.AnnotationData, envelopeId);
|
||||||
|
var report = _reportCreator.CreateReport(connection, envelope);
|
||||||
|
var merged = _pdfMerger.MergeDocuments(burned, report);
|
||||||
|
|
||||||
|
var outputDirectory = Path.Combine(config.ExportPath, data.ParentFolderUid);
|
||||||
|
Directory.CreateDirectory(outputDirectory);
|
||||||
|
var outputPath = Path.Combine(outputDirectory, $"{envelope.Uuid}.pdf");
|
||||||
|
await File.WriteAllBytesAsync(outputPath, merged, context.CancellationToken);
|
||||||
|
|
||||||
|
await UpdateDocumentResultAsync(connection, envelopeId, merged, context.CancellationToken);
|
||||||
|
await ArchiveEnvelopeAsync(connection, envelopeId, context.CancellationToken);
|
||||||
|
}
|
||||||
|
catch (MergeDocumentException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Certificate Document job failed at merging documents");
|
||||||
|
}
|
||||||
|
catch (ExportDocumentException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Certificate Document job failed at exporting document");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Unhandled exception while working envelope {EnvelopeId}", envelopeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
current++;
|
||||||
|
_logger.LogInformation("Envelope {EnvelopeId} finalized", envelopeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Completed job {JobId} successfully", jobId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Certificate Document job failed");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Job execution for {JobId} ended", jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ConfigSettings> LoadConfigurationAsync(SqlConnection connection, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql = "SELECT TOP 1 DOCUMENT_PATH, EXPORT_PATH FROM TBSIG_CONFIG";
|
||||||
|
await using var command = new SqlCommand(sql, connection);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
if (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
var documentPath = reader.IsDBNull(0) ? string.Empty : reader.GetString(0);
|
||||||
|
var exportPath = reader.IsDBNull(1) ? string.Empty : reader.GetString(1);
|
||||||
|
return new ConfigSettings(documentPath, documentPath, exportPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ConfigSettings(string.Empty, string.Empty, Path.GetTempPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<int>> LoadCompletedEnvelopesAsync(SqlConnection connection, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql = "SELECT GUID FROM TBSIG_ENVELOPE WHERE STATUS = @Status AND DATEDIFF(minute, CHANGED_WHEN, GETDATE()) >= 1 ORDER BY GUID";
|
||||||
|
var ids = new List<int>();
|
||||||
|
await using var command = new SqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("@Status", (int)EnvelopeStatus.EnvelopeCompletelySigned);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
ids.Add(reader.GetInt32(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(int EnvelopeId, string? EnvelopeUuid, string? Title, byte[] DocumentBytes, List<string> AnnotationData, string ParentFolderUid)?> GetEnvelopeDataAsync(SqlConnection connection, int envelopeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql = @"SELECT T.GUID, T.ENVELOPE_UUID, T.TITLE, T2.FILEPATH, T2.BYTE_DATA FROM [dbo].[TBSIG_ENVELOPE] T
|
||||||
|
JOIN TBSIG_ENVELOPE_DOCUMENT T2 ON T.GUID = T2.ENVELOPE_ID
|
||||||
|
WHERE T.GUID = @EnvelopeId";
|
||||||
|
|
||||||
|
await using var command = new SqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("@EnvelopeId", envelopeId);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SingleRow, cancellationToken);
|
||||||
|
if (!await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelopeUuid = reader.IsDBNull(1) ? string.Empty : reader.GetString(1);
|
||||||
|
var title = reader.IsDBNull(2) ? string.Empty : reader.GetString(2);
|
||||||
|
var filePath = reader.IsDBNull(3) ? string.Empty : reader.GetString(3);
|
||||||
|
var bytes = reader.IsDBNull(4) ? Array.Empty<byte>() : (byte[])reader[4];
|
||||||
|
await reader.CloseAsync();
|
||||||
|
|
||||||
|
if (bytes.Length == 0 && !string.IsNullOrWhiteSpace(filePath) && File.Exists(filePath))
|
||||||
|
{
|
||||||
|
bytes = await File.ReadAllBytesAsync(filePath, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var annotations = await GetAnnotationDataAsync(connection, envelopeId, cancellationToken);
|
||||||
|
|
||||||
|
var parentFolderUid = !string.IsNullOrWhiteSpace(filePath)
|
||||||
|
? Path.GetFileName(Path.GetDirectoryName(filePath) ?? string.Empty)
|
||||||
|
: envelopeUuid;
|
||||||
|
|
||||||
|
return (envelopeId, envelopeUuid, title, bytes, annotations, parentFolderUid ?? envelopeUuid ?? envelopeId.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<string>> GetAnnotationDataAsync(SqlConnection connection, int envelopeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql = "SELECT VALUE FROM TBSIG_DOCUMENT_STATUS WHERE ENVELOPE_ID = @EnvelopeId";
|
||||||
|
var result = new List<string>();
|
||||||
|
await using var command = new SqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("@EnvelopeId", envelopeId);
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
if (!reader.IsDBNull(0))
|
||||||
|
{
|
||||||
|
result.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task UpdateDocumentResultAsync(SqlConnection connection, int envelopeId, byte[] bytes, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql = "UPDATE TBSIG_ENVELOPE SET DOC_RESULT = @ImageData WHERE GUID = @EnvelopeId";
|
||||||
|
await using var command = new SqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("@ImageData", bytes);
|
||||||
|
command.Parameters.AddWithValue("@EnvelopeId", envelopeId);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ArchiveEnvelopeAsync(SqlConnection connection, int envelopeId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const string sql = "UPDATE TBSIG_ENVELOPE SET STATUS = @Status, CHANGED_WHEN = GETDATE() WHERE GUID = @EnvelopeId";
|
||||||
|
await using var command = new SqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("@Status", (int)EnvelopeStatus.EnvelopeArchived);
|
||||||
|
command.Parameters.AddWithValue("@EnvelopeId", envelopeId);
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
277
EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/PDFBurner.cs
Normal file
277
EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/PDFBurner.cs
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
|
using System.Linq;
|
||||||
|
using iText.IO.Image;
|
||||||
|
using iText.Kernel.Colors;
|
||||||
|
using iText.Kernel.Pdf;
|
||||||
|
using iText.Kernel.Pdf.Canvas;
|
||||||
|
using iText.Layout;
|
||||||
|
using iText.Layout.Element;
|
||||||
|
using iText.Layout.Font;
|
||||||
|
using iText.Layout.Properties;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using static EnvelopeGenerator.Jobs.FinalizeDocument.FinalizeDocumentExceptions;
|
||||||
|
using LayoutImage = iText.Layout.Element.Image;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Jobs.FinalizeDocument;
|
||||||
|
|
||||||
|
public class PDFBurner
|
||||||
|
{
|
||||||
|
private static readonly FontProvider FontProvider = CreateFontProvider();
|
||||||
|
private readonly ILogger<PDFBurner> _logger;
|
||||||
|
private readonly PDFBurnerParams _pdfBurnerParams;
|
||||||
|
|
||||||
|
public PDFBurner() : this(NullLogger<PDFBurner>.Instance, new PDFBurnerParams())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public PDFBurner(ILogger<PDFBurner> logger, PDFBurnerParams? pdfBurnerParams = null)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_pdfBurnerParams = pdfBurnerParams ?? new PDFBurnerParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] BurnAnnotsToPDF(byte[] sourceBuffer, IList<string> instantJsonList, int envelopeId)
|
||||||
|
{
|
||||||
|
if (sourceBuffer is null || sourceBuffer.Length == 0)
|
||||||
|
{
|
||||||
|
throw new BurnAnnotationException("Source document is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var inputStream = new MemoryStream(sourceBuffer);
|
||||||
|
using var outputStream = new MemoryStream();
|
||||||
|
using var reader = new PdfReader(inputStream);
|
||||||
|
using var writer = new PdfWriter(outputStream);
|
||||||
|
using var pdf = new PdfDocument(reader, writer);
|
||||||
|
|
||||||
|
foreach (var json in instantJsonList ?? Enumerable.Empty<string>())
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var annotationData = JsonConvert.DeserializeObject<AnnotationData>(json);
|
||||||
|
if (annotationData?.annotations is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
annotationData.annotations.Reverse();
|
||||||
|
|
||||||
|
foreach (var annotation in annotationData.annotations)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
switch (annotation.type)
|
||||||
|
{
|
||||||
|
case AnnotationType.Image:
|
||||||
|
AddImageAnnotation(pdf, annotation, annotationData.attachments);
|
||||||
|
break;
|
||||||
|
case AnnotationType.Ink:
|
||||||
|
AddInkAnnotation(pdf, annotation);
|
||||||
|
break;
|
||||||
|
case AnnotationType.Widget:
|
||||||
|
var formFieldValue = annotationData.formFieldValues?.FirstOrDefault(fv => fv.name == annotation.id);
|
||||||
|
if (formFieldValue is not null && !_pdfBurnerParams.IgnoredLabels.Contains(formFieldValue.value))
|
||||||
|
{
|
||||||
|
AddFormFieldValue(pdf, annotation, formFieldValue.value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Error applying annotation {AnnotationId} on envelope {EnvelopeId}", annotation.id, envelopeId);
|
||||||
|
throw new BurnAnnotationException("Adding annotation failed", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.Close();
|
||||||
|
return outputStream.ToArray();
|
||||||
|
}
|
||||||
|
catch (BurnAnnotationException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to burn annotations for envelope {EnvelopeId}", envelopeId);
|
||||||
|
throw new BurnAnnotationException("Annotations could not be burned", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddImageAnnotation(PdfDocument pdf, Annotation annotation, Dictionary<string, Attachment>? attachments)
|
||||||
|
{
|
||||||
|
if (attachments is null || string.IsNullOrWhiteSpace(annotation.imageAttachmentId) || !attachments.TryGetValue(annotation.imageAttachmentId, out var attachment))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = pdf.GetPage(annotation.pageIndex + 1);
|
||||||
|
var bounds = annotation.bbox.Select(ToInches).ToList();
|
||||||
|
var x = (float)bounds[0];
|
||||||
|
var y = (float)bounds[1];
|
||||||
|
var width = (float)bounds[2];
|
||||||
|
var height = (float)bounds[3];
|
||||||
|
|
||||||
|
var imageBytes = Convert.FromBase64String(attachment.binary);
|
||||||
|
var imageData = ImageDataFactory.Create(imageBytes);
|
||||||
|
var image = new LayoutImage(imageData)
|
||||||
|
.ScaleAbsolute(width, height)
|
||||||
|
.SetFixedPosition(annotation.pageIndex + 1, x, y);
|
||||||
|
|
||||||
|
using var canvas = new Canvas(new PdfCanvas(page), page.GetPageSize());
|
||||||
|
canvas.Add(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddInkAnnotation(PdfDocument pdf, Annotation annotation)
|
||||||
|
{
|
||||||
|
if (annotation.lines?.points is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var page = pdf.GetPage(annotation.pageIndex + 1);
|
||||||
|
var canvas = new PdfCanvas(page);
|
||||||
|
var color = ParseColor(annotation.strokeColor);
|
||||||
|
canvas.SetStrokeColor(color);
|
||||||
|
canvas.SetLineWidth(1);
|
||||||
|
|
||||||
|
foreach (var segment in annotation.lines.points)
|
||||||
|
{
|
||||||
|
var first = true;
|
||||||
|
foreach (var point in segment)
|
||||||
|
{
|
||||||
|
var (px, py) = (ToInches(point[0]), ToInches(point[1]));
|
||||||
|
if (first)
|
||||||
|
{
|
||||||
|
canvas.MoveTo(px, py);
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
canvas.LineTo(px, py);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canvas.Stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FontProvider CreateFontProvider()
|
||||||
|
{
|
||||||
|
var provider = new FontProvider();
|
||||||
|
provider.AddStandardPdfFonts();
|
||||||
|
provider.AddSystemFonts();
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddFormFieldValue(PdfDocument pdf, Annotation annotation, string value)
|
||||||
|
{
|
||||||
|
var bounds = annotation.bbox.Select(ToInches).ToList();
|
||||||
|
var x = (float)bounds[0];
|
||||||
|
var y = (float)bounds[1];
|
||||||
|
var width = (float)bounds[2];
|
||||||
|
var height = (float)bounds[3];
|
||||||
|
|
||||||
|
var page = pdf.GetPage(annotation.pageIndex + 1);
|
||||||
|
var canvas = new Canvas(new PdfCanvas(page), page.GetPageSize());
|
||||||
|
canvas.SetProperty(Property.FONT_PROVIDER, FontProvider);
|
||||||
|
canvas.SetProperty(Property.FONT, FontProvider.GetFontSet());
|
||||||
|
|
||||||
|
var paragraph = new Paragraph(value)
|
||||||
|
.SetFontSize(_pdfBurnerParams.FontSize)
|
||||||
|
.SetFontColor(ColorConstants.BLACK)
|
||||||
|
.SetFontFamily(_pdfBurnerParams.FontName);
|
||||||
|
|
||||||
|
if (_pdfBurnerParams.FontStyle.HasFlag(FontStyle.Italic))
|
||||||
|
{
|
||||||
|
paragraph.SetItalic();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_pdfBurnerParams.FontStyle.HasFlag(FontStyle.Bold))
|
||||||
|
{
|
||||||
|
paragraph.SetBold();
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.ShowTextAligned(
|
||||||
|
paragraph,
|
||||||
|
x + (float)_pdfBurnerParams.TopMargin,
|
||||||
|
y + (float)_pdfBurnerParams.YOffset,
|
||||||
|
annotation.pageIndex + 1,
|
||||||
|
iText.Layout.Properties.TextAlignment.LEFT,
|
||||||
|
iText.Layout.Properties.VerticalAlignment.TOP,
|
||||||
|
0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DeviceRgb ParseColor(string? color)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(color))
|
||||||
|
{
|
||||||
|
return new DeviceRgb(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var drawingColor = ColorTranslator.FromHtml(color);
|
||||||
|
return new DeviceRgb(drawingColor.R, drawingColor.G, drawingColor.B);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new DeviceRgb(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ToInches(double value) => value / 72d;
|
||||||
|
private static double ToInches(float value) => value / 72d;
|
||||||
|
|
||||||
|
#region Model
|
||||||
|
private static class AnnotationType
|
||||||
|
{
|
||||||
|
public const string Image = "pspdfkit/image";
|
||||||
|
public const string Ink = "pspdfkit/ink";
|
||||||
|
public const string Widget = "pspdfkit/widget";
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AnnotationData
|
||||||
|
{
|
||||||
|
public List<Annotation>? annotations { get; set; }
|
||||||
|
public Dictionary<string, Attachment>? attachments { get; set; }
|
||||||
|
public List<FormFieldValue>? formFieldValues { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Annotation
|
||||||
|
{
|
||||||
|
public string id { get; set; } = string.Empty;
|
||||||
|
public List<double> bbox { get; set; } = new();
|
||||||
|
public string type { get; set; } = string.Empty;
|
||||||
|
public string imageAttachmentId { get; set; } = string.Empty;
|
||||||
|
public Lines? lines { get; set; }
|
||||||
|
public int pageIndex { get; set; }
|
||||||
|
public string strokeColor { get; set; } = string.Empty;
|
||||||
|
public string egName { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Lines
|
||||||
|
{
|
||||||
|
public List<List<List<float>>> points { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class Attachment
|
||||||
|
{
|
||||||
|
public string binary { get; set; } = string.Empty;
|
||||||
|
public string contentType { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FormFieldValue
|
||||||
|
{
|
||||||
|
public string name { get; set; } = string.Empty;
|
||||||
|
public string value { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Jobs.FinalizeDocument;
|
||||||
|
|
||||||
|
public class PDFBurnerParams
|
||||||
|
{
|
||||||
|
public List<string> IgnoredLabels { get; } = new() { "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" };
|
||||||
|
|
||||||
|
public double TopMargin { get; set; } = 0.1;
|
||||||
|
|
||||||
|
public double YOffset { get; set; } = -0.3;
|
||||||
|
|
||||||
|
public string FontName { get; set; } = "Arial";
|
||||||
|
|
||||||
|
public int FontSize { get; set; } = 8;
|
||||||
|
|
||||||
|
public FontStyle FontStyle { get; set; } = FontStyle.Italic;
|
||||||
|
}
|
||||||
46
EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/PDFMerger.cs
Normal file
46
EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/PDFMerger.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
using System.IO;
|
||||||
|
using iText.Kernel.Pdf;
|
||||||
|
using iText.Kernel.Utils;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using static EnvelopeGenerator.Jobs.FinalizeDocument.FinalizeDocumentExceptions;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Jobs.FinalizeDocument;
|
||||||
|
|
||||||
|
public class PDFMerger
|
||||||
|
{
|
||||||
|
private readonly ILogger<PDFMerger> _logger;
|
||||||
|
|
||||||
|
public PDFMerger() : this(NullLogger<PDFMerger>.Instance)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public PDFMerger(ILogger<PDFMerger> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] MergeDocuments(byte[] document, byte[] report)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var finalStream = new MemoryStream();
|
||||||
|
using var documentReader = new PdfReader(new MemoryStream(document));
|
||||||
|
using var reportReader = new PdfReader(new MemoryStream(report));
|
||||||
|
using var writer = new PdfWriter(finalStream);
|
||||||
|
using var targetDoc = new PdfDocument(documentReader, writer);
|
||||||
|
using var reportDoc = new PdfDocument(reportReader);
|
||||||
|
|
||||||
|
var merger = new PdfMerger(targetDoc);
|
||||||
|
merger.Merge(reportDoc, 1, reportDoc.GetNumberOfPages());
|
||||||
|
|
||||||
|
targetDoc.Close();
|
||||||
|
return finalStream.ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to merge PDF documents");
|
||||||
|
throw new MergeDocumentException("Documents could not be merged", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.IO;
|
||||||
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
|
using iText.Kernel.Pdf;
|
||||||
|
using iText.Layout.Element;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using static EnvelopeGenerator.Jobs.FinalizeDocument.FinalizeDocumentExceptions;
|
||||||
|
using LayoutDocument = iText.Layout.Document;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Jobs.FinalizeDocument;
|
||||||
|
|
||||||
|
public class ReportCreator
|
||||||
|
{
|
||||||
|
private readonly ILogger<ReportCreator> _logger;
|
||||||
|
|
||||||
|
public ReportCreator() : this(NullLogger<ReportCreator>.Instance)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReportCreator(ILogger<ReportCreator> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] CreateReport(SqlConnection connection, Envelope envelope)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var reportItems = LoadReportItems(connection, envelope.Id);
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
using var writer = new PdfWriter(stream);
|
||||||
|
using var pdf = new PdfDocument(writer);
|
||||||
|
using var document = new LayoutDocument(pdf);
|
||||||
|
|
||||||
|
document.Add(new Paragraph("Envelope Finalization Report").SetFontSize(16));
|
||||||
|
document.Add(new Paragraph($"Envelope Id: {envelope.Id}"));
|
||||||
|
document.Add(new Paragraph($"UUID: {envelope.Uuid}"));
|
||||||
|
document.Add(new Paragraph($"Title: {envelope.Title}"));
|
||||||
|
document.Add(new Paragraph($"Subject: {envelope.Comment}"));
|
||||||
|
document.Add(new Paragraph($"Generated: {DateTime.UtcNow:O}"));
|
||||||
|
document.Add(new Paragraph(" "));
|
||||||
|
|
||||||
|
var table = new Table(4).UseAllAvailableWidth();
|
||||||
|
table.AddHeaderCell("Date");
|
||||||
|
table.AddHeaderCell("Status");
|
||||||
|
table.AddHeaderCell("User");
|
||||||
|
table.AddHeaderCell("EnvelopeId");
|
||||||
|
|
||||||
|
foreach (var item in reportItems.OrderByDescending(r => r.ItemDate))
|
||||||
|
{
|
||||||
|
table.AddCell(item.ItemDate.ToString("u"));
|
||||||
|
table.AddCell(item.ItemStatus.ToString());
|
||||||
|
table.AddCell(item.ItemUserReference);
|
||||||
|
table.AddCell(item.EnvelopeId.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
document.Add(table);
|
||||||
|
document.Close();
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Could not create report for envelope {EnvelopeId}", envelope.Id);
|
||||||
|
throw new CreateReportException("Could not prepare report data", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ReportItem> LoadReportItems(SqlConnection connection, int envelopeId)
|
||||||
|
{
|
||||||
|
const string sql = "SELECT ENVELOPE_ID, POS_WHEN, POS_STATUS, POS_WHO FROM VWSIG_ENVELOPE_REPORT WHERE ENVELOPE_ID = @EnvelopeId";
|
||||||
|
var result = new List<ReportItem>();
|
||||||
|
|
||||||
|
using var command = new SqlCommand(sql, connection);
|
||||||
|
command.Parameters.AddWithValue("@EnvelopeId", envelopeId);
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
result.Add(new ReportItem
|
||||||
|
{
|
||||||
|
EnvelopeId = reader.GetInt32(0),
|
||||||
|
ItemDate = reader.IsDBNull(1) ? DateTime.MinValue : reader.GetDateTime(1),
|
||||||
|
ItemStatus = reader.IsDBNull(2) ? default : (EnvelopeGenerator.Domain.Constants.EnvelopeStatus)reader.GetInt32(2),
|
||||||
|
ItemUserReference = reader.IsDBNull(3) ? string.Empty : reader.GetString(3)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/ReportItem.cs
Normal file
19
EnvelopeGenerator.Jobs/Jobs/FinalizeDocument/ReportItem.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Jobs.FinalizeDocument;
|
||||||
|
|
||||||
|
public class ReportItem
|
||||||
|
{
|
||||||
|
public Envelope? Envelope { get; set; }
|
||||||
|
public int EnvelopeId { get; set; }
|
||||||
|
public string EnvelopeTitle { get; set; } = string.Empty;
|
||||||
|
public string EnvelopeSubject { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public EnvelopeStatus ItemStatus { get; set; }
|
||||||
|
|
||||||
|
public string ItemStatusTranslated => ItemStatus.ToString();
|
||||||
|
|
||||||
|
public string ItemUserReference { get; set; } = string.Empty;
|
||||||
|
public DateTime ItemDate { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Jobs.FinalizeDocument;
|
||||||
|
|
||||||
|
public class ReportSource
|
||||||
|
{
|
||||||
|
public List<ReportItem> Items { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Services;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fragt die API, ob der Nutzer eingeloggt ist.
|
||||||
|
///
|
||||||
|
/// WARUM nicht selbst Token lesen?
|
||||||
|
/// - Das Auth-Cookie ist HttpOnly → JavaScript/WASM kann es nicht lesen
|
||||||
|
/// - Stattdessen: Frage die API "bin ich eingeloggt?" → GET /api/auth/check
|
||||||
|
/// - Die API prüft das Cookie serverseitig und antwortet mit 200 oder 401
|
||||||
|
/// </summary>
|
||||||
|
public class ApiAuthStateProvider : AuthenticationStateProvider
|
||||||
|
{
|
||||||
|
private readonly IAuthService _authService;
|
||||||
|
|
||||||
|
public ApiAuthStateProvider(IAuthService authService)
|
||||||
|
{
|
||||||
|
_authService = authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||||
|
{
|
||||||
|
var result = await _authService.CheckAuthAsync();
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
// Eingeloggt → Erstelle einen authentifizierten ClaimsPrincipal
|
||||||
|
var identity = new ClaimsIdentity("cookie");
|
||||||
|
return new AuthenticationState(new ClaimsPrincipal(identity));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nicht eingeloggt
|
||||||
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wird nach Login/Logout aufgerufen, damit Blazor den Auth-State aktualisiert.
|
||||||
|
/// </summary>
|
||||||
|
public void NotifyAuthChanged()
|
||||||
|
{
|
||||||
|
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
@* AccessCodeForm: Zeigt das Zugangscode-Eingabeformular.
|
||||||
|
Entspricht dem AccessCode-Teil von EnvelopeLocked.cshtml im Web-Projekt.
|
||||||
|
|
||||||
|
"Dumme" Komponente: Keine Services, keine API-Calls.
|
||||||
|
- Bekommt Daten über [Parameter]
|
||||||
|
- Gibt Eingaben über EventCallback an die Eltern-Page zurück
|
||||||
|
- Die Page entscheidet, was mit dem Code passiert *@
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
@* ── Header: Icon + Titel ── *@
|
||||||
|
<header class="text-center">
|
||||||
|
<div class="status-icon locked mt-4 mb-1">
|
||||||
|
<i class="bi bi-shield-lock"></i>
|
||||||
|
</div>
|
||||||
|
<h1>Zugangscode eingeben</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@* ── Erklärungstext ── *@
|
||||||
|
<section class="text-center mb-4">
|
||||||
|
<p class="text-muted">
|
||||||
|
Ein Zugangscode wurde an Ihre E-Mail-Adresse gesendet.
|
||||||
|
Bitte geben Sie diesen unten ein.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* ── Formular ── *@
|
||||||
|
<div class="access-code-container">
|
||||||
|
<EditForm Model="_model" OnValidSubmit="Submit">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
|
@* Code-Eingabefeld: type="password" damit niemand mitlesen kann *@
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<InputText @bind-Value="_model.Code"
|
||||||
|
type="password"
|
||||||
|
class="form-control code-input"
|
||||||
|
id="accessCodeInput"
|
||||||
|
placeholder="Zugangscode" />
|
||||||
|
<label for="accessCodeInput">Zugangscode</label>
|
||||||
|
<ValidationMessage For="() => _model.Code" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* TFA-Switch: Nur sichtbar wenn der Umschlag TFA aktiviert hat.
|
||||||
|
Im Web-Projekt ist das der "2FA per SMS"-Toggle.
|
||||||
|
Disabled wenn der Empfänger keine Telefonnummer hat. *@
|
||||||
|
@if (TfaEnabled)
|
||||||
|
{
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="preferSmsSwitch"
|
||||||
|
checked="@_preferSms"
|
||||||
|
disabled="@(!HasPhoneNumber)"
|
||||||
|
@onchange="ToggleSms" />
|
||||||
|
<label class="form-check-label" for="preferSmsSwitch">
|
||||||
|
2FA per SMS
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Fehlermeldung: Wird von der Eltern-Page gesetzt
|
||||||
|
wenn der AccessCode falsch war *@
|
||||||
|
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@ErrorMessage</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Submit-Button mit Loading-State *@
|
||||||
|
<button type="submit" class="btn btn-primary w-100" disabled="@_isSubmitting">
|
||||||
|
@if (_isSubmitting)
|
||||||
|
{
|
||||||
|
<LoadingIndicator Small="true" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-box-arrow-in-right me-2"></i>
|
||||||
|
<span>Bestätigen</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</EditForm>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Hilfe-Bereich: Wer hat dieses Dokument gesendet? ──
|
||||||
|
Im Web-Projekt ist das der <details>-Bereich ganz unten.
|
||||||
|
Zeigt Sender-Mail und Umschlag-Titel, damit der Empfänger
|
||||||
|
bei Problemen weiß, an wen er sich wenden kann. *@
|
||||||
|
@if (!string.IsNullOrEmpty(SenderEmail))
|
||||||
|
{
|
||||||
|
<section class="text-center mt-4">
|
||||||
|
<details>
|
||||||
|
<summary class="text-muted">Woher kommt dieser Code?</summary>
|
||||||
|
<p class="text-muted mt-2">
|
||||||
|
Dieses Dokument
|
||||||
|
@if (!string.IsNullOrEmpty(Title))
|
||||||
|
{
|
||||||
|
<span>«@Title» </span>
|
||||||
|
}
|
||||||
|
wurde Ihnen von <a href="mailto:@SenderEmail">@SenderEmail</a> zugesendet.
|
||||||
|
Der Zugangscode wurde ebenfalls an Ihre E-Mail-Adresse geschickt.
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// ── Parameter von der Eltern-Page ──
|
||||||
|
|
||||||
|
/// <summary>Der Envelope-Key aus der URL</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string EnvelopeKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Fehlermeldung (z.B. "Falscher Zugangscode")</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>E-Mail des Absenders — für den Hilfe-Bereich unten</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? SenderEmail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Titel des Umschlags — für den Hilfe-Bereich unten</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ob TFA für diesen Umschlag aktiviert ist — zeigt den SMS-Switch</summary>
|
||||||
|
[Parameter]
|
||||||
|
public bool TfaEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ob der Empfänger eine Telefonnummer hat — sonst ist SMS-Switch disabled</summary>
|
||||||
|
[Parameter]
|
||||||
|
public bool HasPhoneNumber { get; set; }
|
||||||
|
|
||||||
|
// ── Event: Gibt Code + SMS-Präferenz an die Page zurück ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback wenn der Benutzer das Formular abschickt.
|
||||||
|
/// Gibt ein Tuple zurück: (Code, PreferSms)
|
||||||
|
/// Die Page entscheidet dann, was damit passiert (API-Call etc.)
|
||||||
|
///
|
||||||
|
/// WARUM ein Tuple statt nur string?
|
||||||
|
/// Weil die Page auch wissen muss, ob der Benutzer SMS bevorzugt.
|
||||||
|
/// Im Web-Projekt wird das als separates Form-Feld "userSelectSMS" gesendet.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<(string Code, bool PreferSms)> OnSubmit { get; set; }
|
||||||
|
|
||||||
|
// ── Interner State ──
|
||||||
|
|
||||||
|
private AccessCodeModel _model = new();
|
||||||
|
private bool _isSubmitting;
|
||||||
|
private bool _preferSms;
|
||||||
|
|
||||||
|
private void ToggleSms(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
_preferSms = (bool)(e.Value ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Submit()
|
||||||
|
{
|
||||||
|
_isSubmitting = true;
|
||||||
|
await OnSubmit.InvokeAsync((_model.Code, _preferSms));
|
||||||
|
_isSubmitting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validierungs-Model ──
|
||||||
|
|
||||||
|
private class AccessCodeModel
|
||||||
|
{
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Bitte Zugangscode eingeben")]
|
||||||
|
[System.ComponentModel.DataAnnotations.StringLength(6, MinimumLength = 4)]
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
@* ActionPanel: Fixierte Button-Leiste am unteren Bildschirmrand.
|
||||||
|
Entspricht dem <div id="flex-action-panel"> in ShowEnvelope.cshtml.
|
||||||
|
|
||||||
|
Wird angezeigt wenn der Empfänger das Dokument sieht (Status = ShowDocument).
|
||||||
|
Bei ReadOnly-Dokumenten wird nichts angezeigt.
|
||||||
|
|
||||||
|
Die Buttons lösen EventCallbacks aus. Die Eltern-Komponente (EnvelopePage)
|
||||||
|
entscheidet, was passiert (API-Call etc.).
|
||||||
|
|
||||||
|
Der ConfirmDialog wird über eine Referenz aufgerufen:
|
||||||
|
var confirmed = await _confirmDialog.ShowAsync("Titel", "Text");
|
||||||
|
|
||||||
|
WARUM EventCallbacks statt direkte API-Calls?
|
||||||
|
- Die Komponente bleibt "dumm" (keine Services, kein API-Wissen)
|
||||||
|
- Die Eltern-Page kann den gleichen Button-Click anders behandeln
|
||||||
|
- Einfacher zu testen *@
|
||||||
|
|
||||||
|
@if (!ReadOnly)
|
||||||
|
{
|
||||||
|
@* position-fixed: Bleibt am unteren Rand auch beim Scrollen.
|
||||||
|
Gleiche Positionierung wie im Web-Projekt. *@
|
||||||
|
<div class="position-fixed bottom-0 end-0 p-3 d-flex gap-2" style="z-index: 1050;">
|
||||||
|
|
||||||
|
@* Zurücksetzen-Button: Setzt alle Signaturen zurück.
|
||||||
|
Im Web-Projekt ist das der graue Button mit dem
|
||||||
|
Pfeil-gegen-den-Uhrzeigersinn-Icon. *@
|
||||||
|
<button class="btn btn-secondary" @onclick="HandleRefresh" title="Zurücksetzen">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@* Ablehnen-Button: Öffnet ConfirmDialog, dann EventCallback.
|
||||||
|
Im Web-Projekt ist das der rote Button mit dem X-Icon. *@
|
||||||
|
<button class="btn btn-danger" @onclick="HandleReject" title="Ablehnen">
|
||||||
|
<i class="bi bi-x-lg me-1"></i>
|
||||||
|
<span class="d-none d-md-inline">Ablehnen</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@* Unterschreiben-Button: Öffnet ConfirmDialog, dann EventCallback.
|
||||||
|
Im Web-Projekt ist das der grüne Button mit dem Briefumschlag-Icon. *@
|
||||||
|
<button class="btn btn-success" @onclick="HandleSign" title="Unterschreiben">
|
||||||
|
<i class="bi bi-pen me-1"></i>
|
||||||
|
<span class="d-none d-md-inline">Unterschreiben</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ConfirmDialog: Wird nur gerendert wenn nötig (wenn ShowAsync aufgerufen wird).
|
||||||
|
Die Referenz (_confirmDialog) erlaubt uns, ShowAsync von den Button-Handlern aufzurufen. *@
|
||||||
|
<ConfirmDialog @ref="_confirmDialog" />
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// ── Parameter ──
|
||||||
|
|
||||||
|
/// <summary>Bei ReadOnly wird das gesamte Panel ausgeblendet</summary>
|
||||||
|
[Parameter]
|
||||||
|
public bool ReadOnly { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Wird ausgelöst wenn der Benutzer "Unterschreiben" bestätigt</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnSign { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Wird ausgelöst wenn der Benutzer "Ablehnen" bestätigt</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnReject { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Wird ausgelöst wenn der Benutzer "Zurücksetzen" klickt</summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback OnRefresh { get; set; }
|
||||||
|
|
||||||
|
// ── Referenz auf den ConfirmDialog ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Referenz auf die ConfirmDialog-Komponente.
|
||||||
|
///
|
||||||
|
/// WAS IST @ref?
|
||||||
|
/// Mit @ref speichert Blazor eine Referenz auf eine Kind-Komponente.
|
||||||
|
/// Dann kann man Methoden darauf aufrufen: _confirmDialog.ShowAsync(...)
|
||||||
|
/// Das ist wie document.getElementById() in JavaScript, nur typsicher.
|
||||||
|
/// </summary>
|
||||||
|
private ConfirmDialog _confirmDialog = default!;
|
||||||
|
|
||||||
|
// ── Button-Handler ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unterschreiben: Erst bestätigen, dann Event auslösen.
|
||||||
|
/// Der ConfirmDialog zeigt "Möchten Sie das Dokument unterschreiben?"
|
||||||
|
/// Nur wenn der Benutzer "Ja" klickt, wird OnSign ausgelöst.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleSign()
|
||||||
|
{
|
||||||
|
var confirmed = await _confirmDialog.ShowAsync(
|
||||||
|
"Unterschreiben",
|
||||||
|
"Möchten Sie das Dokument wirklich unterschreiben? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
confirmText: "Unterschreiben",
|
||||||
|
confirmColor: "success");
|
||||||
|
|
||||||
|
if (confirmed)
|
||||||
|
await OnSign.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ablehnen: Erst bestätigen, dann Event auslösen.
|
||||||
|
/// Roter Button im ConfirmDialog, weil Ablehnen destruktiv ist.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleReject()
|
||||||
|
{
|
||||||
|
var confirmed = await _confirmDialog.ShowAsync(
|
||||||
|
"Ablehnen",
|
||||||
|
"Möchten Sie das Dokument wirklich ablehnen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
confirmText: "Ablehnen",
|
||||||
|
confirmColor: "danger");
|
||||||
|
|
||||||
|
if (confirmed)
|
||||||
|
await OnReject.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zurücksetzen: Kein ConfirmDialog nötig, weil es nicht destruktiv ist.
|
||||||
|
/// Setzt nur die Signatur-Positionen zurück, der Empfänger kann danach
|
||||||
|
/// erneut signieren.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleRefresh()
|
||||||
|
{
|
||||||
|
await OnRefresh.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
@* EnvelopeInfoCard: Zeigt die Umschlag-Infos oberhalb des PDF-Viewers.
|
||||||
|
Entspricht dem Card-Bereich in ShowEnvelope.cshtml im Web-Projekt.
|
||||||
|
|
||||||
|
Wird angezeigt wenn der Empfänger erfolgreich authentifiziert ist
|
||||||
|
und das Dokument sehen darf.
|
||||||
|
|
||||||
|
"Dumme" Komponente: Keine Services, nur Parameter → Anzeige. *@
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
@* ── Card Header: Logo + Fortschrittsbalken ──
|
||||||
|
Im Web-Projekt gibt es hier das signFLOW-Logo und darunter
|
||||||
|
"2/3 Signatures". Wir zeigen den Fortschritt nur wenn es
|
||||||
|
KEIN ReadOnly-Dokument ist (ReadOnly = nur lesen, nicht signieren). *@
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<strong class="text-primary">signFLOW</strong>
|
||||||
|
|
||||||
|
@if (!ReadOnly && SignatureTotal > 0)
|
||||||
|
{
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="progress" style="width: 120px; height: 8px;">
|
||||||
|
<div class="progress-bar bg-success"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: @ProgressPercent%"
|
||||||
|
aria-valuenow="@SignaturesDone"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="@SignatureTotal">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">@SignaturesDone/@SignatureTotal</small>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (ReadOnly)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Nur Ansicht</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Card Body: Titel, Nachricht, Sender-Info ── *@
|
||||||
|
<div class="card-body">
|
||||||
|
@* Titel des Umschlags *@
|
||||||
|
<h5 class="card-title">@Title</h5>
|
||||||
|
|
||||||
|
@* Nachricht des Absenders — im Web-Projekt wird das mit Marked.js
|
||||||
|
als Markdown gerendert. Hier erstmal als Plain Text.
|
||||||
|
Markdown-Rendering kommt in Phase 6 (Feinschliff). *@
|
||||||
|
@if (!string.IsNullOrEmpty(Message))
|
||||||
|
{
|
||||||
|
<p class="card-text">@Message</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Sender-Info: Wer hat es gesendet, wann?
|
||||||
|
Im Web-Projekt steht hier:
|
||||||
|
"Gesendet am 15.03.2026 von Max Mustermann (max@firma.de)" *@
|
||||||
|
<p class="card-text">
|
||||||
|
<small class="text-muted">
|
||||||
|
@if (!string.IsNullOrEmpty(SenderName) && !string.IsNullOrEmpty(SenderEmail))
|
||||||
|
{
|
||||||
|
<span>
|
||||||
|
Gesendet
|
||||||
|
@if (SentDate is not null)
|
||||||
|
{
|
||||||
|
<span>am @SentDate.Value.ToString("dd.MM.yyyy")</span>
|
||||||
|
}
|
||||||
|
von @SenderName
|
||||||
|
(<a href="mailto:@SenderEmail">@SenderEmail</a>)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// ── Parameter ──
|
||||||
|
|
||||||
|
/// <summary>Titel des Umschlags (z.B. "Vertragsdokument")</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Nachricht des Absenders</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Name des Absenders (z.B. "Max Mustermann")</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? SenderName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>E-Mail des Absenders</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? SenderEmail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Datum an dem der Umschlag gesendet wurde</summary>
|
||||||
|
[Parameter]
|
||||||
|
public DateTime? SentDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ob das Dokument nur zum Lesen ist (kein Signieren)</summary>
|
||||||
|
[Parameter]
|
||||||
|
public bool ReadOnly { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Anzahl bereits geleisteter Unterschriften</summary>
|
||||||
|
[Parameter]
|
||||||
|
public int SignaturesDone { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Gesamtanzahl benötigter Unterschriften</summary>
|
||||||
|
[Parameter]
|
||||||
|
public int SignatureTotal { get; set; }
|
||||||
|
|
||||||
|
// ── Berechnete Werte ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fortschritt in Prozent für den Balken.
|
||||||
|
/// Vermeidet Division durch Null.
|
||||||
|
/// </summary>
|
||||||
|
private int ProgressPercent =>
|
||||||
|
SignatureTotal > 0
|
||||||
|
? (int)((double)SignaturesDone / SignatureTotal * 100)
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
@inject IJSRuntime JS
|
||||||
|
@implements IAsyncDisposable
|
||||||
|
|
||||||
|
<div id="pspdfkit-container" class="pdf-container" style="width: 100%; height: 80vh;"></div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public byte[]? DocumentBytes { get; set; }
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender && DocumentBytes is not null)
|
||||||
|
{
|
||||||
|
// TODO: PSPDFKit JS-Interop implementieren (Phase 6)
|
||||||
|
// await JS.InvokeVoidAsync("initPdfViewer", DocumentBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
// TODO: PSPDFKit aufräumen
|
||||||
|
// await JS.InvokeVoidAsync("destroyPdfViewer");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h3>SignaturePanel</h3>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
@* TfaForm: Zwei-Faktor-Authentifizierung — SMS oder Authenticator.
|
||||||
|
Entspricht dem TFA-Teil von EnvelopeLocked.cshtml im Web-Projekt.
|
||||||
|
|
||||||
|
Wird angezeigt NACH dem AccessCode-Schritt, wenn der Umschlag TFA erfordert.
|
||||||
|
|
||||||
|
Zwei Varianten:
|
||||||
|
- TfaType="sms" → SMS wurde gesendet, Countdown-Timer zeigt verbleibende Zeit
|
||||||
|
- TfaType="authenticator" → Benutzer gibt Code aus seiner Authenticator-App ein
|
||||||
|
|
||||||
|
"Dumme" Komponente: Keine Services, keine API-Calls. *@
|
||||||
|
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
@* ── Header: Icon + Titel ── *@
|
||||||
|
<header class="text-center">
|
||||||
|
<div class="status-icon locked mt-4 mb-1">
|
||||||
|
<i class="bi bi-shield-check"></i>
|
||||||
|
</div>
|
||||||
|
<h1>Zwei-Faktor-Authentifizierung</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@* ── Erklärungstext: Unterschiedlich je nach TFA-Typ ── *@
|
||||||
|
<section class="text-center mb-4">
|
||||||
|
@if (TfaType == "sms")
|
||||||
|
{
|
||||||
|
<p class="text-muted">
|
||||||
|
Ein Bestätigungscode wurde per SMS an Ihre Telefonnummer gesendet.
|
||||||
|
</p>
|
||||||
|
@* SMS-Countdown-Timer: Zeigt wie lange der Code noch gültig ist.
|
||||||
|
Im Web-Projekt ist das JavaScript (setInterval).
|
||||||
|
Hier machen wir es mit einem C#-Timer — kein JS nötig. *@
|
||||||
|
@if (_remainingTime is not null)
|
||||||
|
{
|
||||||
|
<div class="alert @(_remainingTime > TimeSpan.Zero ? "alert-primary" : "alert-warning")">
|
||||||
|
@if (_remainingTime > TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
<span>Code gültig für: @_remainingTime.Value.ToString("mm\\:ss")</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Code abgelaufen. Bitte fordern Sie einen neuen Code an.</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="text-muted">
|
||||||
|
Öffnen Sie Ihre Authenticator-App und geben Sie den angezeigten Code ein.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@* ── Formular ── *@
|
||||||
|
<div class="access-code-container">
|
||||||
|
<EditForm Model="_model" OnValidSubmit="Submit">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
|
||||||
|
<div class="form-floating mb-3">
|
||||||
|
<InputText @bind-Value="_model.Code"
|
||||||
|
type="password"
|
||||||
|
class="form-control code-input"
|
||||||
|
id="tfaCodeInput"
|
||||||
|
placeholder="Code" />
|
||||||
|
<label for="tfaCodeInput">
|
||||||
|
@(TfaType == "sms" ? "SMS-Code" : "Authenticator-Code")
|
||||||
|
</label>
|
||||||
|
<ValidationMessage For="() => _model.Code" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">@ErrorMessage</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-primary w-100"
|
||||||
|
disabled="@(_isSubmitting || _remainingTime <= TimeSpan.Zero)">
|
||||||
|
@if (_isSubmitting)
|
||||||
|
{
|
||||||
|
<LoadingIndicator Small="true" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-unlock me-2"></i>
|
||||||
|
<span>Bestätigen</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</EditForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// ── Parameter von der Eltern-Page ──
|
||||||
|
|
||||||
|
/// <summary>Der Envelope-Key aus der URL</summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string EnvelopeKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "sms" oder "authenticator" — bestimmt welche Variante angezeigt wird.
|
||||||
|
/// Kommt aus der API-Antwort nach dem AccessCode-Schritt.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string TfaType { get; set; } = "authenticator";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ablaufzeit des SMS-Codes. Nur bei TfaType="sms" relevant.
|
||||||
|
/// Der Timer zählt von jetzt bis zu diesem Zeitpunkt runter.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public DateTime? TfaExpiration { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Ob der Empfänger eine Telefonnummer hat</summary>
|
||||||
|
[Parameter]
|
||||||
|
public bool HasPhoneNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Fehlermeldung (z.B. "Falscher Code")</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Callback: Gibt (Code, Type) an die Page zurück.
|
||||||
|
/// Die Page sendet das dann an die API.
|
||||||
|
/// </summary>
|
||||||
|
[Parameter]
|
||||||
|
public EventCallback<(string Code, string Type)> OnSubmit { get; set; }
|
||||||
|
|
||||||
|
// ── Interner State ──
|
||||||
|
|
||||||
|
private TfaCodeModel _model = new();
|
||||||
|
private bool _isSubmitting;
|
||||||
|
private TimeSpan? _remainingTime;
|
||||||
|
private System.Threading.Timer? _timer;
|
||||||
|
|
||||||
|
// ── Lifecycle ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wird aufgerufen wenn die Komponente initialisiert wird.
|
||||||
|
/// Startet den SMS-Countdown-Timer falls nötig.
|
||||||
|
///
|
||||||
|
/// OnInitialized (nicht Async) reicht hier, weil wir keinen
|
||||||
|
/// await brauchen — der Timer läuft über einen Callback.
|
||||||
|
/// </summary>
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
if (TfaType == "sms" && TfaExpiration is not null)
|
||||||
|
{
|
||||||
|
// Restzeit berechnen
|
||||||
|
_remainingTime = TfaExpiration.Value - DateTime.Now;
|
||||||
|
|
||||||
|
// Timer: Jede Sekunde _remainingTime aktualisieren.
|
||||||
|
// InvokeAsync + StateHasChanged sagt Blazor: "Zeichne die UI neu".
|
||||||
|
// Das ist das Blazor-Äquivalent zu setInterval + DOM-Update in JavaScript.
|
||||||
|
_timer = new System.Threading.Timer(_ =>
|
||||||
|
{
|
||||||
|
_remainingTime = TfaExpiration.Value - DateTime.Now;
|
||||||
|
|
||||||
|
if (_remainingTime <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
_remainingTime = TimeSpan.Zero;
|
||||||
|
_timer?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvokeAsync ist nötig, weil der Timer auf einem anderen Thread läuft.
|
||||||
|
// Blazor erlaubt UI-Updates nur auf dem UI-Thread.
|
||||||
|
InvokeAsync(StateHasChanged);
|
||||||
|
},
|
||||||
|
state: null,
|
||||||
|
dueTime: TimeSpan.Zero, // Sofort starten
|
||||||
|
period: TimeSpan.FromSeconds(1) // Jede Sekunde
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Submit()
|
||||||
|
{
|
||||||
|
_isSubmitting = true;
|
||||||
|
await OnSubmit.InvokeAsync((_model.Code, TfaType));
|
||||||
|
_isSubmitting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aufräumen: Timer stoppen wenn die Komponente entfernt wird.
|
||||||
|
/// Ohne Dispose würde der Timer weiterlaufen und Fehler verursachen,
|
||||||
|
/// weil er versucht eine nicht mehr existierende UI zu aktualisieren.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_timer?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validierungs-Model ──
|
||||||
|
|
||||||
|
private class TfaCodeModel
|
||||||
|
{
|
||||||
|
[System.ComponentModel.DataAnnotations.Required(ErrorMessage = "Bitte Code eingeben")]
|
||||||
|
[System.ComponentModel.DataAnnotations.StringLength(6, MinimumLength = 6, ErrorMessage = "Der Code muss 6 Zeichen lang sein")]
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h3>TwoFactorForm</h3>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h3>NavHeader</h3>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h3>AlertMessage</h3>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
@* ConfirmDialog: Ersetzt SweetAlert2 aus dem Web-Projekt.
|
||||||
|
|
||||||
|
Zeigt einen modalen Bestätigungsdialog mit Titel, Text und Ja/Nein-Buttons.
|
||||||
|
Wird NICHT über Parameter gesteuert, sondern über eine Methode:
|
||||||
|
|
||||||
|
var confirmed = await _dialog.ShowAsync("Titel", "Text");
|
||||||
|
|
||||||
|
WARUM eine Methode statt Parameter?
|
||||||
|
- Ein Dialog ist ein "einmaliges Ereignis", kein dauerhafter Zustand
|
||||||
|
- Die aufrufende Komponente will auf das Ergebnis WARTEN (await)
|
||||||
|
- Mit Parametern müsste man den State manuell hin- und herschalten
|
||||||
|
|
||||||
|
WARUM kein SweetAlert2?
|
||||||
|
- SweetAlert2 ist eine JavaScript-Bibliothek
|
||||||
|
- In Blazor können wir das nativ in C# lösen, ohne JS-Interop
|
||||||
|
- Weniger Abhängigkeiten = weniger Wartung = weniger Fehlerquellen *@
|
||||||
|
|
||||||
|
@if (_isVisible)
|
||||||
|
{
|
||||||
|
@* Hintergrund-Overlay: Dunkler Hintergrund hinter dem Dialog.
|
||||||
|
Im Web-Projekt macht SweetAlert2 das automatisch.
|
||||||
|
Hier bauen wir es selbst mit CSS. *@
|
||||||
|
<div class="modal-backdrop fade show"></div>
|
||||||
|
|
||||||
|
@* Modal-Dialog: Bootstrap 5 Modal-Markup.
|
||||||
|
Normalerweise braucht Bootstrap-Modal JavaScript (bootstrap.js)
|
||||||
|
um zu öffnen/schließen. Wir steuern die Sichtbarkeit stattdessen
|
||||||
|
über die _isVisible-Variable — Blazor macht das DOM-Update. *@
|
||||||
|
<div class="modal fade show d-block" tabindex="-1" role="dialog">
|
||||||
|
<div class="modal-dialog modal-dialog-centered" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
|
||||||
|
@* Header: Titel + Schließen-Button *@
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">@_title</h5>
|
||||||
|
<button type="button" class="btn-close" @onclick="Cancel"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Body: Beschreibungstext *@
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>@_message</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Footer: Abbrechen + Bestätigen *@
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" @onclick="Cancel">
|
||||||
|
@_cancelText
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-@_confirmColor" @onclick="Confirm">
|
||||||
|
@_confirmText
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// ── Interner State (NICHT Parameter — wird über ShowAsync gesetzt) ──
|
||||||
|
|
||||||
|
private bool _isVisible;
|
||||||
|
private string _title = string.Empty;
|
||||||
|
private string _message = string.Empty;
|
||||||
|
private string _confirmText = "Ja";
|
||||||
|
private string _cancelText = "Abbrechen";
|
||||||
|
private string _confirmColor = "primary";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// TaskCompletionSource: Das Herzstück dieser Komponente.
|
||||||
|
///
|
||||||
|
/// WAS IST DAS?
|
||||||
|
/// Ein TaskCompletionSource erstellt einen Task, der erst dann
|
||||||
|
/// "fertig" wird, wenn jemand SetResult() aufruft.
|
||||||
|
///
|
||||||
|
/// WIE FUNKTIONIERT ES?
|
||||||
|
/// 1. ShowAsync() erstellt ein neues TaskCompletionSource
|
||||||
|
/// 2. ShowAsync() gibt dessen Task zurück → der Aufrufer wartet (await)
|
||||||
|
/// 3. Der Benutzer klickt "Ja" → Confirm() ruft SetResult(true) auf
|
||||||
|
/// 4. Der await in der aufrufenden Komponente kommt zurück mit true
|
||||||
|
///
|
||||||
|
/// Das ist wie ein "Promise" in JavaScript, nur in C#.
|
||||||
|
/// </summary>
|
||||||
|
private TaskCompletionSource<bool>? _tcs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zeigt den Dialog und wartet auf die Benutzer-Entscheidung.
|
||||||
|
///
|
||||||
|
/// Beispiel-Aufruf:
|
||||||
|
/// var confirmed = await _dialog.ShowAsync(
|
||||||
|
/// "Unterschreiben",
|
||||||
|
/// "Möchten Sie das Dokument wirklich unterschreiben?");
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="title">Dialog-Überschrift</param>
|
||||||
|
/// <param name="message">Beschreibungstext</param>
|
||||||
|
/// <param name="confirmText">Text auf dem Bestätigen-Button (Standard: "Ja")</param>
|
||||||
|
/// <param name="cancelText">Text auf dem Abbrechen-Button (Standard: "Abbrechen")</param>
|
||||||
|
/// <param name="confirmColor">Bootstrap-Farbe des Bestätigen-Buttons (Standard: "primary")</param>
|
||||||
|
/// <returns>true wenn bestätigt, false wenn abgebrochen</returns>
|
||||||
|
public Task<bool> ShowAsync(
|
||||||
|
string title,
|
||||||
|
string message,
|
||||||
|
string confirmText = "Ja",
|
||||||
|
string cancelText = "Abbrechen",
|
||||||
|
string confirmColor = "primary")
|
||||||
|
{
|
||||||
|
_title = title;
|
||||||
|
_message = message;
|
||||||
|
_confirmText = confirmText;
|
||||||
|
_cancelText = cancelText;
|
||||||
|
_confirmColor = confirmColor;
|
||||||
|
|
||||||
|
_tcs = new TaskCompletionSource<bool>();
|
||||||
|
_isVisible = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
return _tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Confirm()
|
||||||
|
{
|
||||||
|
_isVisible = false;
|
||||||
|
_tcs?.TrySetResult(true);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Cancel()
|
||||||
|
{
|
||||||
|
_isVisible = false;
|
||||||
|
_tcs?.TrySetResult(false);
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<div class="text-center py-5">
|
||||||
|
@if (!string.IsNullOrEmpty(Icon))
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<i class="bi bi-@Icon" style="font-size: 3rem;"></i>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<h2>@Title</h2>
|
||||||
|
@if (!string.IsNullOrEmpty(Message))
|
||||||
|
{
|
||||||
|
<p class="text-muted">@Message</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string Title { get; set; } = "Fehler";
|
||||||
|
[Parameter] public string? Message { get; set; }
|
||||||
|
[Parameter] public string? Icon { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h3>LanguageSelector</h3>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<div class="d-flex justify-content-center align-items-center @(Small ? "" : "py-5")" style="@(Small ? "" : "min-height: 40vh;")">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="spinner-border @(Small ? "spinner-border-sm" : "text-primary")"
|
||||||
|
style="@(Small ? "" : "width: 3rem; height: 3rem;")"
|
||||||
|
role="status">
|
||||||
|
<span class="visually-hidden">Laden...</span>
|
||||||
|
</div>
|
||||||
|
@if (!Small && Message is not null)
|
||||||
|
{
|
||||||
|
<p class="mt-3 text-muted">@Message</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool Small { get; set; }
|
||||||
|
[Parameter] public string? Message { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
@* StatusPage: Wiederverwendbare Status-Seite für alle Endzustände.
|
||||||
|
Ersetzt EnvelopeSigned.cshtml, EnvelopeRejected.cshtml, Error-Views und Expired.
|
||||||
|
"Dumme" Komponente: Keine Services, keine API-Calls, nur Parameter → Anzeige. *@
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<header class="text-center">
|
||||||
|
@switch (Type)
|
||||||
|
{
|
||||||
|
case "signed":
|
||||||
|
<div class="status-icon signed">
|
||||||
|
<i class="bi bi-check-circle"></i>
|
||||||
|
</div>
|
||||||
|
<h1>Dokument erfolgreich unterschrieben</h1>
|
||||||
|
<p class="text-muted">
|
||||||
|
Sie erhalten eine Bestätigung per E-Mail, sobald alle Empfänger unterschrieben haben.
|
||||||
|
</p>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "rejected":
|
||||||
|
<div class="status-icon rejected">
|
||||||
|
<i class="bi bi-x-circle"></i>
|
||||||
|
</div>
|
||||||
|
<h1>Dokument wurde abgelehnt</h1>
|
||||||
|
<p class="text-muted">
|
||||||
|
@if (!string.IsNullOrEmpty(Title) && !string.IsNullOrEmpty(SenderEmail))
|
||||||
|
{
|
||||||
|
<span>
|
||||||
|
Das Dokument «@Title» wurde abgelehnt.
|
||||||
|
Bei Fragen wenden Sie sich an
|
||||||
|
<a href="mailto:@SenderEmail">@SenderEmail</a>.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>Dieses Dokument wurde von einem Empfänger abgelehnt.</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "not_found":
|
||||||
|
<div class="status-icon locked">
|
||||||
|
<i class="bi bi-question-circle"></i>
|
||||||
|
</div>
|
||||||
|
<h1>Dokument nicht gefunden</h1>
|
||||||
|
<p class="text-muted">
|
||||||
|
Dieses Dokument existiert nicht oder ist nicht mehr verfügbar.
|
||||||
|
Wenn Sie diese URL per E-Mail erhalten haben, wenden Sie sich bitte an das IT-Team.
|
||||||
|
</p>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "expired":
|
||||||
|
<div class="status-icon locked">
|
||||||
|
<i class="bi bi-clock-history"></i>
|
||||||
|
</div>
|
||||||
|
<h1>Link abgelaufen</h1>
|
||||||
|
<p class="text-muted">
|
||||||
|
Der Zugang zu diesem Dokument ist abgelaufen.
|
||||||
|
</p>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>
|
||||||
|
/// Bestimmt welche Status-Variante angezeigt wird.
|
||||||
|
/// Erlaubte Werte: "signed", "rejected", "not_found", "expired"
|
||||||
|
/// </summary>
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>E-Mail des Absenders — nur bei "rejected" relevant.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? SenderEmail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Titel des Umschlags — nur bei "rejected" relevant.</summary>
|
||||||
|
[Parameter]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
@* Toast: Zeigt kurze Benachrichtigungen an. Ersetzt AlertifyJS aus dem Web-Projekt.
|
||||||
|
|
||||||
|
Wird einmal im MainLayout platziert. Hört auf den ToastService.
|
||||||
|
Wenn eine Komponente irgendwo _toast.ShowSuccess("Text") aufruft,
|
||||||
|
erscheint hier ein Toast und verschwindet nach 4 Sekunden automatisch.
|
||||||
|
|
||||||
|
Bootstrap Toast-Klassen werden genutzt:
|
||||||
|
- toast-container: Positionierung (oben rechts)
|
||||||
|
- toast: Einzelner Toast
|
||||||
|
- bg-success/bg-danger/etc: Farbe basierend auf Typ *@
|
||||||
|
|
||||||
|
@inject ToastService ToastService
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
@if (ToastService.Messages.Count > 0)
|
||||||
|
{
|
||||||
|
@* toast-container: Bootstrap-Klasse die Toasts oben rechts positioniert.
|
||||||
|
position-fixed: Bleibt an der Stelle auch beim Scrollen.
|
||||||
|
z-index 1080: Über dem Modal-Backdrop (1070) damit Toasts
|
||||||
|
auch während eines ConfirmDialogs sichtbar sind. *@
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1080;">
|
||||||
|
@foreach (var message in ToastService.Messages)
|
||||||
|
{
|
||||||
|
<div class="toast show align-items-center text-white bg-@message.Type border-0"
|
||||||
|
role="alert">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body">
|
||||||
|
<i class="bi @message.IconClass me-2"></i>
|
||||||
|
@message.Text
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="btn-close btn-close-white me-2 m-auto"
|
||||||
|
@onclick="() => ToastService.Dismiss(message)">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
// Subscriber: Wenn der ToastService eine Änderung meldet,
|
||||||
|
// zeichnet diese Komponente sich neu (StateHasChanged).
|
||||||
|
// So erscheinen/verschwinden Toasts automatisch.
|
||||||
|
ToastService.OnChange += HandleChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// InvokeAsync ist nötig weil OnChange von einem
|
||||||
|
/// async void (dem Timer im ToastService) ausgelöst wird.
|
||||||
|
/// Das kann auf einem anderen Thread passieren.
|
||||||
|
/// InvokeAsync wechselt zurück auf den Blazor UI-Thread.
|
||||||
|
/// </summary>
|
||||||
|
private async void HandleChange()
|
||||||
|
{
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event-Handler abmelden wenn die Komponente entfernt wird.
|
||||||
|
/// Ohne das würde der ToastService eine Referenz auf eine
|
||||||
|
/// nicht mehr existierende Komponente halten → Memory Leak.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
ToastService.OnChange -= HandleChange;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||||
|
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="9.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="9.0.3" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hält den aktuellen Authentifizierungs-Zustand im Client.
|
||||||
|
/// Wird vom ApiAuthStateProvider gesetzt und von Komponenten gelesen.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthState
|
||||||
|
{
|
||||||
|
public bool IsAuthenticated { get; set; }
|
||||||
|
public string? Role { get; set; }
|
||||||
|
public string? EnvelopeUuid { get; set; }
|
||||||
|
public string? ReceiverEmail { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-seitiges DTO für Dokument-Daten.
|
||||||
|
/// </summary>
|
||||||
|
public record DocumentModel
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int EnvelopeId { get; init; }
|
||||||
|
public DateTime AddedWhen { get; init; }
|
||||||
|
public byte[]? ByteData { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-seitiges DTO für Umschlag-Daten.
|
||||||
|
/// Muss nur die JSON-Properties matchen, die die API zurückgibt
|
||||||
|
/// und die der Client tatsächlich braucht.
|
||||||
|
///
|
||||||
|
/// WARUM eigene DTOs statt die aus EnvelopeGenerator.Application?
|
||||||
|
/// - Application hat Server-Abhängigkeiten (SqlClient, JwtBearer, EF Core)
|
||||||
|
/// - Diese Pakete existieren nicht für browser-wasm → Build-Fehler
|
||||||
|
/// - Der Client braucht nur eine Teilmenge der Felder
|
||||||
|
/// - Eigene DTOs machen den Client unabhängig vom Server
|
||||||
|
/// </summary>
|
||||||
|
public record EnvelopeModel
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public string Uuid { get; init; } = string.Empty;
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
public string Message { get; init; } = string.Empty;
|
||||||
|
public bool UseAccessCode { get; init; }
|
||||||
|
public bool TFAEnabled { get; init; }
|
||||||
|
public bool ReadOnly { get; init; }
|
||||||
|
public string Language { get; init; } = "de-DE";
|
||||||
|
public DateTime AddedWhen { get; init; }
|
||||||
|
public UserModel? User { get; init; }
|
||||||
|
public IEnumerable<DocumentModel>? Documents { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-seitiges DTO für die Envelope-Receiver-Zuordnung.
|
||||||
|
/// </summary>
|
||||||
|
public record EnvelopeReceiverModel
|
||||||
|
{
|
||||||
|
public EnvelopeModel? Envelope { get; init; }
|
||||||
|
public ReceiverModel? Receiver { get; init; }
|
||||||
|
public int EnvelopeId { get; init; }
|
||||||
|
public int ReceiverId { get; init; }
|
||||||
|
public int Sequence { get; init; }
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public bool HasPhoneNumber { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Models
|
||||||
|
{
|
||||||
|
public class EnvelopeViewModel
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-seitiges DTO für die Antwort des ReceiverAuthControllers.
|
||||||
|
/// Wird 1:1 aus dem JSON deserialisiert.
|
||||||
|
///
|
||||||
|
/// WARUM ein eigenes Client-Model statt das API-Model zu referenzieren?
|
||||||
|
/// - Das API-Projekt hat Server-Abhängigkeiten (EF Core, SqlClient, etc.)
|
||||||
|
/// - Diese Pakete existieren nicht für browser-wasm → Build-Fehler
|
||||||
|
/// - Die Property-Namen müssen nur zum JSON passen (case-insensitive)
|
||||||
|
/// </summary>
|
||||||
|
public record ReceiverAuthModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Aktueller Status des Empfänger-Flows.
|
||||||
|
/// Werte: "requires_access_code", "requires_tfa", "show_document",
|
||||||
|
/// "already_signed", "rejected", "not_found", "expired", "error"
|
||||||
|
/// </summary>
|
||||||
|
public string Status { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public string? Title { get; init; }
|
||||||
|
public string? Message { get; init; }
|
||||||
|
public string? SenderEmail { get; init; }
|
||||||
|
public string? ReceiverName { get; init; }
|
||||||
|
public bool TfaEnabled { get; init; }
|
||||||
|
public bool HasPhoneNumber { get; init; }
|
||||||
|
public bool ReadOnly { get; init; }
|
||||||
|
public string? TfaType { get; init; }
|
||||||
|
public DateTime? TfaExpiration { get; init; }
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-seitiges DTO für Empfänger-Daten.
|
||||||
|
/// </summary>
|
||||||
|
public record ReceiverModel
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public string EmailAddress { get; init; } = string.Empty;
|
||||||
|
public string Signature { get; init; } = string.Empty;
|
||||||
|
public DateTime AddedWhen { get; init; }
|
||||||
|
public DateTime? TfaRegDeadline { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-seitiges DTO für Benutzer-Daten (Absender).
|
||||||
|
/// </summary>
|
||||||
|
public record UserModel
|
||||||
|
{
|
||||||
|
public string? Email { get; init; }
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h3>EnvelopeExpired</h3>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h3>EnvelopeLocked</h3>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
@page "/envelope/{EnvelopeKey}"
|
||||||
|
@rendermode InteractiveAuto
|
||||||
|
@inject IReceiverAuthService ReceiverAuthService
|
||||||
|
@inject EnvelopeState State
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<PageTitle>Dokument</PageTitle>
|
||||||
|
|
||||||
|
@switch (State.Status)
|
||||||
|
{
|
||||||
|
case EnvelopePageStatus.Loading:
|
||||||
|
<LoadingIndicator Message="Dokument wird geladen..." />
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EnvelopePageStatus.NotFound:
|
||||||
|
<StatusPage Type="not_found" />
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EnvelopePageStatus.AlreadySigned:
|
||||||
|
<StatusPage Type="signed"
|
||||||
|
Title="@State.Title"
|
||||||
|
SenderEmail="@State.SenderEmail" />
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EnvelopePageStatus.Rejected:
|
||||||
|
<StatusPage Type="rejected"
|
||||||
|
Title="@State.Title"
|
||||||
|
SenderEmail="@State.SenderEmail" />
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EnvelopePageStatus.Expired:
|
||||||
|
<StatusPage Type="expired" />
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EnvelopePageStatus.RequiresAccessCode:
|
||||||
|
<AccessCodeForm EnvelopeKey="@EnvelopeKey"
|
||||||
|
ErrorMessage="@State.ErrorMessage"
|
||||||
|
SenderEmail="@State.SenderEmail"
|
||||||
|
Title="@State.Title"
|
||||||
|
TfaEnabled="@State.TfaEnabled"
|
||||||
|
HasPhoneNumber="@State.HasPhoneNumber"
|
||||||
|
OnSubmit="HandleAccessCodeSubmit" />
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EnvelopePageStatus.RequiresTwoFactor:
|
||||||
|
<TfaForm EnvelopeKey="@EnvelopeKey"
|
||||||
|
TfaType="@(State.TfaType ?? "authenticator")"
|
||||||
|
TfaExpiration="@State.TfaExpiration"
|
||||||
|
HasPhoneNumber="@State.HasPhoneNumber"
|
||||||
|
ErrorMessage="@State.ErrorMessage"
|
||||||
|
OnSubmit="HandleTfaSubmit" />
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EnvelopePageStatus.ShowDocument:
|
||||||
|
@* Phase 4 (PSPDFKit) kommt später — vorerst Platzhalter *@
|
||||||
|
<div class="text-center mt-5">
|
||||||
|
<div class="status-icon signed">
|
||||||
|
<i class="bi bi-file-earmark-check"></i>
|
||||||
|
</div>
|
||||||
|
<h2>Dokument bereit</h2>
|
||||||
|
<p class="text-muted">
|
||||||
|
«@State.Title» — PDF-Viewer wird in Phase 4 integriert.
|
||||||
|
</p>
|
||||||
|
@if (State.ReadOnly)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Nur Lesen</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EnvelopePageStatus.Error:
|
||||||
|
<ErrorDisplay Title="Fehler" Message="@State.ErrorMessage" />
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string EnvelopeKey { get; set; } = default!;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
State.OnChange += StateHasChanged;
|
||||||
|
await LoadStatusAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Erster API-Call: Status prüfen.
|
||||||
|
/// Entspricht dem GET /Envelope/{key} im Web-Projekt.
|
||||||
|
/// Die API entscheidet, was passiert (AccessCode nötig? Bereits signiert? etc.)
|
||||||
|
/// </summary>
|
||||||
|
private async Task LoadStatusAsync()
|
||||||
|
{
|
||||||
|
State.SetLoading();
|
||||||
|
|
||||||
|
var result = await ReceiverAuthService.GetStatusAsync(EnvelopeKey);
|
||||||
|
|
||||||
|
if (result.IsSuccess && result.Data is not null)
|
||||||
|
{
|
||||||
|
State.ApplyApiResponse(result.Data);
|
||||||
|
}
|
||||||
|
else if (result.StatusCode == 404)
|
||||||
|
{
|
||||||
|
State.SetNotFound();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
State.SetError(result.ErrorMessage ?? "Verbindung zum Server fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zweiter API-Call: AccessCode senden.
|
||||||
|
/// Wird von AccessCodeForm aufgerufen (OnSubmit-Callback).
|
||||||
|
/// Die API prüft den Code und antwortet mit dem nächsten Status.
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleAccessCodeSubmit((string Code, bool PreferSms) submission)
|
||||||
|
{
|
||||||
|
var result = await ReceiverAuthService.SubmitAccessCodeAsync(
|
||||||
|
EnvelopeKey, submission.Code, submission.PreferSms);
|
||||||
|
|
||||||
|
if (result.IsSuccess && result.Data is not null)
|
||||||
|
{
|
||||||
|
State.ApplyApiResponse(result.Data);
|
||||||
|
}
|
||||||
|
else if (result.Data is not null)
|
||||||
|
{
|
||||||
|
// 401 mit Body → falscher Code, API gibt trotzdem ReceiverAuthModel zurück
|
||||||
|
State.ApplyApiResponse(result.Data);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
State.SetError(result.ErrorMessage ?? "Fehler bei der Code-Prüfung.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dritter API-Call: TFA-Code senden.
|
||||||
|
/// Wird von TfaForm aufgerufen (OnSubmit-Callback).
|
||||||
|
/// </summary>
|
||||||
|
private async Task HandleTfaSubmit((string Code, string Type) submission)
|
||||||
|
{
|
||||||
|
var result = await ReceiverAuthService.SubmitTfaCodeAsync(
|
||||||
|
EnvelopeKey, submission.Code, submission.Type);
|
||||||
|
|
||||||
|
if (result.IsSuccess && result.Data is not null)
|
||||||
|
{
|
||||||
|
State.ApplyApiResponse(result.Data);
|
||||||
|
}
|
||||||
|
else if (result.Data is not null)
|
||||||
|
{
|
||||||
|
State.ApplyApiResponse(result.Data);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
State.SetError(result.ErrorMessage ?? "Fehler bei der TFA-Prüfung.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => State.OnChange -= StateHasChanged;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h3>EnvelopeRejected</h3>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h3>EnvelopeSigned</h3>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h3>Home</h3>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h3>NotFound</h3>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Auth;
|
||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Services;
|
||||||
|
using EnvelopeGenerator.ReceiverUI.Client.State;
|
||||||
|
|
||||||
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
|
||||||
|
// HttpClient: BaseAddress zeigt auf den ReceiverUI-Server (gleiche Domain)
|
||||||
|
// Von dort werden alle /api/* Calls via YARP an die echte API weitergeleitet
|
||||||
|
builder.Services.AddScoped(sp =>
|
||||||
|
new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
|
||||||
|
|
||||||
|
// Auth: Blazor fragt über diesen Provider "Ist der Nutzer eingeloggt?"
|
||||||
|
builder.Services.AddAuthorizationCore();
|
||||||
|
builder.Services.AddScoped<ApiAuthStateProvider>();
|
||||||
|
builder.Services.AddScoped<AuthenticationStateProvider>(sp =>
|
||||||
|
sp.GetRequiredService<ApiAuthStateProvider>());
|
||||||
|
|
||||||
|
// API-Services: Je ein Service pro API-Controller
|
||||||
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
|
builder.Services.AddScoped<IEnvelopeService, EnvelopeService>();
|
||||||
|
builder.Services.AddScoped<IReceiverAuthService, ReceiverAuthService>();
|
||||||
|
|
||||||
|
// State: Ein State-Objekt pro Browser-Tab
|
||||||
|
builder.Services.AddScoped<EnvelopeState>();
|
||||||
|
|
||||||
|
builder.Services.AddScoped<ToastService>();
|
||||||
|
|
||||||
|
await builder.Build().RunAsync();
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Services.Base;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spricht mit dem bestehenden AuthController der API.
|
||||||
|
/// Die API erkennt den Nutzer über das Cookie "AuthToken" automatisch.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthService : ApiServiceBase, IAuthService
|
||||||
|
{
|
||||||
|
public AuthService(HttpClient http, ILogger<AuthService> logger) : base(http, logger) { }
|
||||||
|
|
||||||
|
public async Task<ApiResponse> CheckAuthAsync(string? role = null, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var endpoint = role is not null ? $"api/auth/check?role={role}" : "api/auth/check";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await Http.GetAsync(endpoint, ct);
|
||||||
|
return response.IsSuccessStatusCode
|
||||||
|
? ApiResponse.Success((int)response.StatusCode)
|
||||||
|
: ApiResponse.Failure((int)response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "HTTP error calling GET {Endpoint}", endpoint);
|
||||||
|
return ApiResponse.Failure(0, "Verbindung zum Server fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
return ApiResponse.Failure(0, "Anfrage abgebrochen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApiResponse> LogoutAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
const string endpoint = "api/auth/logout";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await Http.PostAsync(endpoint, null, ct);
|
||||||
|
return response.IsSuccessStatusCode
|
||||||
|
? ApiResponse.Success((int)response.StatusCode)
|
||||||
|
: ApiResponse.Failure((int)response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "HTTP error calling POST {Endpoint}", endpoint);
|
||||||
|
return ApiResponse.Failure(0, "Verbindung zum Server fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
return ApiResponse.Failure(0, "Anfrage abgebrochen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Services.Base;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Einheitliches Response-Objekt für ALLE API-Aufrufe.
|
||||||
|
///
|
||||||
|
/// WARUM: Jeder API-Aufruf kann fehlschlagen (Netzwerk, 401, 500...).
|
||||||
|
/// Statt überall try-catch zu haben, kapselt dieses Objekt Erfolg/Fehler einheitlich.
|
||||||
|
/// So kann jede Blazor-Komponente einheitlich darauf reagieren.
|
||||||
|
/// </summary>
|
||||||
|
public record ApiResponse<T>
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; init; }
|
||||||
|
public T? Data { get; init; }
|
||||||
|
public int StatusCode { get; init; }
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
|
||||||
|
public static ApiResponse<T> Success(T data, int statusCode = 200)
|
||||||
|
=> new() { IsSuccess = true, Data = data, StatusCode = statusCode };
|
||||||
|
|
||||||
|
public static ApiResponse<T> Failure(int statusCode, string? error = null)
|
||||||
|
=> new() { IsSuccess = false, StatusCode = statusCode, ErrorMessage = error };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Failure mit deserialisiertem Body — für Fälle wo die API
|
||||||
|
/// bei 401/404 trotzdem ein strukturiertes JSON zurückgibt
|
||||||
|
/// (z.B. ReceiverAuthResponse mit ErrorMessage + Status).
|
||||||
|
/// </summary>
|
||||||
|
public static ApiResponse<T> Failure(int statusCode, string? error, T? data)
|
||||||
|
=> new() { IsSuccess = false, StatusCode = statusCode, ErrorMessage = error, Data = data };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response ohne Daten (für POST/PUT/DELETE die nur Status zurückgeben).
|
||||||
|
/// </summary>
|
||||||
|
public record ApiResponse
|
||||||
|
{
|
||||||
|
public bool IsSuccess { get; init; }
|
||||||
|
public int StatusCode { get; init; }
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
|
||||||
|
public static ApiResponse Success(int statusCode = 200)
|
||||||
|
=> new() { IsSuccess = true, StatusCode = statusCode };
|
||||||
|
|
||||||
|
public static ApiResponse Failure(int statusCode, string? error = null)
|
||||||
|
=> new() { IsSuccess = false, StatusCode = statusCode, ErrorMessage = error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Services.Base;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Basisklasse für ALLE API-Services.
|
||||||
|
///
|
||||||
|
/// WARUM eine Basisklasse?
|
||||||
|
/// - Einheitliches Error-Handling: Jeder API-Aufruf wird gleich behandelt
|
||||||
|
/// - DRY (Don't Repeat Yourself): Logging, Fehlerbehandlung, Serialisierung nur einmal
|
||||||
|
/// - Einfache Erweiterung: Retry-Logik, Token-Refresh etc. nur hier ändern
|
||||||
|
/// </summary>
|
||||||
|
public abstract class ApiServiceBase
|
||||||
|
{
|
||||||
|
protected readonly HttpClient Http;
|
||||||
|
protected readonly ILogger Logger;
|
||||||
|
|
||||||
|
protected ApiServiceBase(HttpClient http, ILogger logger)
|
||||||
|
{
|
||||||
|
Http = http;
|
||||||
|
Logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GET-Request mit Deserialisierung.
|
||||||
|
/// Alle API GET-Aufrufe gehen durch diese Methode.
|
||||||
|
/// </summary>
|
||||||
|
protected async Task<ApiResponse<T>> GetAsync<T>(string endpoint, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await Http.GetAsync(endpoint, ct);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
Logger.LogWarning("GET {Endpoint} failed: {Status} - {Body}",
|
||||||
|
endpoint, (int)response.StatusCode, errorBody);
|
||||||
|
|
||||||
|
// Versuche den Body trotzdem zu deserialisieren —
|
||||||
|
// die API gibt bei 401/404 oft strukturierte JSON-Antworten zurück
|
||||||
|
// (z.B. ReceiverAuthResponse mit ErrorMessage + Status)
|
||||||
|
var errorData = await TryDeserializeAsync<T>(response, ct);
|
||||||
|
|
||||||
|
return ApiResponse<T>.Failure((int)response.StatusCode, errorBody, errorData);
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await response.Content.ReadFromJsonAsync<T>(cancellationToken: ct);
|
||||||
|
return ApiResponse<T>.Success(data!, (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "HTTP error calling GET {Endpoint}", endpoint);
|
||||||
|
return ApiResponse<T>.Failure(0, "Verbindung zum Server fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
Logger.LogWarning("GET {Endpoint} was cancelled", endpoint);
|
||||||
|
return ApiResponse<T>.Failure(0, "Anfrage abgebrochen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POST-Request mit Body und Response-Deserialisierung.
|
||||||
|
/// </summary>
|
||||||
|
protected async Task<ApiResponse<TResponse>> PostAsync<TRequest, TResponse>(
|
||||||
|
string endpoint, TRequest body, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await Http.PostAsJsonAsync(endpoint, body, ct);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
Logger.LogWarning("POST {Endpoint} failed: {Status} - {Body}",
|
||||||
|
endpoint, (int)response.StatusCode, errorBody);
|
||||||
|
|
||||||
|
var errorData = await TryDeserializeAsync<TResponse>(response, ct);
|
||||||
|
|
||||||
|
return ApiResponse<TResponse>.Failure((int)response.StatusCode, errorBody, errorData);
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = await response.Content.ReadFromJsonAsync<TResponse>(cancellationToken: ct);
|
||||||
|
return ApiResponse<TResponse>.Success(data!, (int)response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "HTTP error calling POST {Endpoint}", endpoint);
|
||||||
|
return ApiResponse<TResponse>.Failure(0, "Verbindung zum Server fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POST-Request ohne Response-Body (z.B. Logout).
|
||||||
|
/// </summary>
|
||||||
|
protected async Task<ApiResponse> PostAsync<TRequest>(
|
||||||
|
string endpoint, TRequest body, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await Http.PostAsJsonAsync(endpoint, body, ct);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorBody = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
return ApiResponse.Failure((int)response.StatusCode, errorBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApiResponse.Success((int)response.StatusCode);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "HTTP error calling POST {Endpoint}", endpoint);
|
||||||
|
return ApiResponse.Failure(0, "Verbindung zum Server fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Versucht den Response-Body als JSON zu deserialisieren.
|
||||||
|
/// Gibt null zurück wenn es nicht klappt (z.B. bei HTML-Fehlerseiten).
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<T?> TryDeserializeAsync<T>(HttpResponseMessage response, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Nur versuchen wenn der Content-Type JSON ist
|
||||||
|
if (response.Content.Headers.ContentType?.MediaType == "application/json")
|
||||||
|
{
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(cancellationToken: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignorieren — der Body war kein valides JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Models;
|
||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Services.Base;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
|
||||||
|
|
||||||
|
public class EnvelopeService : ApiServiceBase, IEnvelopeService
|
||||||
|
{
|
||||||
|
public EnvelopeService(HttpClient http, ILogger<EnvelopeService> logger) : base(http, logger) { }
|
||||||
|
|
||||||
|
public Task<ApiResponse<IEnumerable<EnvelopeModel>>> GetEnvelopesAsync(CancellationToken ct = default)
|
||||||
|
=> GetAsync<IEnumerable<EnvelopeModel>>("api/envelope", ct);
|
||||||
|
|
||||||
|
public Task<ApiResponse<IEnumerable<EnvelopeReceiverModel>>> GetEnvelopeReceiversAsync(
|
||||||
|
CancellationToken ct = default)
|
||||||
|
=> GetAsync<IEnumerable<EnvelopeReceiverModel>>("api/envelopereceiver", ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Services
|
||||||
|
{
|
||||||
|
public class HistoryService
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Services.Base;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kommuniziert mit dem AuthController der API.
|
||||||
|
///
|
||||||
|
/// WARUM Interface + Implementierung?
|
||||||
|
/// - Testbarkeit: In Unit-Tests kann man einen Mock verwenden
|
||||||
|
/// - Austauschbarkeit: Wenn sich die API ändert, ändert sich nur die Implementierung
|
||||||
|
/// - Blazor-Konvention: Services werden über Interfaces per DI registriert
|
||||||
|
/// </summary>
|
||||||
|
public interface IAuthService
|
||||||
|
{
|
||||||
|
/// <summary>Prüft ob der Nutzer eingeloggt ist → GET /api/auth/check</summary>
|
||||||
|
Task<ApiResponse> CheckAuthAsync(string? role = null, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Logout → POST /api/auth/logout</summary>
|
||||||
|
Task<ApiResponse> LogoutAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Models;
|
||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Services.Base;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kommuniziert mit EnvelopeController und EnvelopeReceiverController.
|
||||||
|
/// Verwendet Client-eigene Models statt der Server-DTOs.
|
||||||
|
/// </summary>
|
||||||
|
public interface IEnvelopeService
|
||||||
|
{
|
||||||
|
/// <summary>Lädt Umschläge → GET /api/envelope</summary>
|
||||||
|
Task<ApiResponse<IEnumerable<EnvelopeModel>>> GetEnvelopesAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Lädt EnvelopeReceiver → GET /api/envelopereceiver</summary>
|
||||||
|
Task<ApiResponse<IEnumerable<EnvelopeReceiverModel>>> GetEnvelopeReceiversAsync(
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Services
|
||||||
|
{
|
||||||
|
public interface IHistoryService
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Models;
|
||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Services.Base;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kommuniziert mit dem ReceiverAuthController der API.
|
||||||
|
///
|
||||||
|
/// Drei Methoden — eine pro Endpunkt:
|
||||||
|
/// 1. GetStatusAsync → GET /api/receiverauth/{key}/status
|
||||||
|
/// 2. SubmitAccessCodeAsync → POST /api/receiverauth/{key}/access-code
|
||||||
|
/// 3. SubmitTfaCodeAsync → POST /api/receiverauth/{key}/tfa
|
||||||
|
/// </summary>
|
||||||
|
public interface IReceiverAuthService
|
||||||
|
{
|
||||||
|
/// <summary>Prüft den aktuellen Status des Empfänger-Flows</summary>
|
||||||
|
Task<ApiResponse<ReceiverAuthModel>> GetStatusAsync(string key, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Sendet den Zugangscode zur Prüfung</summary>
|
||||||
|
Task<ApiResponse<ReceiverAuthModel>> SubmitAccessCodeAsync(
|
||||||
|
string key, string accessCode, bool preferSms, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Sendet den TFA-Code (SMS oder Authenticator) zur Prüfung</summary>
|
||||||
|
Task<ApiResponse<ReceiverAuthModel>> SubmitTfaCodeAsync(
|
||||||
|
string key, string code, string type, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Models;
|
||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Services.Base;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spricht mit dem ReceiverAuthController der API.
|
||||||
|
///
|
||||||
|
/// Nutzt die Basisklasse ApiServiceBase für einheitliches Error-Handling.
|
||||||
|
/// Jede Methode gibt ApiResponse<ReceiverAuthModel> zurück —
|
||||||
|
/// egal ob Erfolg oder Fehler. Die aufrufende Komponente prüft dann
|
||||||
|
/// result.IsSuccess und result.Data.Status.
|
||||||
|
///
|
||||||
|
/// WARUM gibt die API bei 401 trotzdem ein ReceiverAuthModel zurück?
|
||||||
|
/// Weil auch bei "falscher Code" der Client wissen muss, welchen
|
||||||
|
/// Status er anzeigen soll (z.B. "requires_access_code" + ErrorMessage).
|
||||||
|
/// Deshalb deserialisieren wir auch bei Fehler-Statuscodes den Body.
|
||||||
|
/// </summary>
|
||||||
|
public class ReceiverAuthService : ApiServiceBase, IReceiverAuthService
|
||||||
|
{
|
||||||
|
public ReceiverAuthService(HttpClient http, ILogger<ReceiverAuthService> logger)
|
||||||
|
: base(http, logger) { }
|
||||||
|
|
||||||
|
public Task<ApiResponse<ReceiverAuthModel>> GetStatusAsync(
|
||||||
|
string key, CancellationToken ct = default)
|
||||||
|
=> GetAsync<ReceiverAuthModel>($"api/receiverauth/{key}/status", ct);
|
||||||
|
|
||||||
|
public Task<ApiResponse<ReceiverAuthModel>> SubmitAccessCodeAsync(
|
||||||
|
string key, string accessCode, bool preferSms, CancellationToken ct = default)
|
||||||
|
=> PostAsync<object, ReceiverAuthModel>(
|
||||||
|
$"api/receiverauth/{key}/access-code",
|
||||||
|
new { AccessCode = accessCode, PreferSms = preferSms },
|
||||||
|
ct);
|
||||||
|
|
||||||
|
public Task<ApiResponse<ReceiverAuthModel>> SubmitTfaCodeAsync(
|
||||||
|
string key, string code, string type, CancellationToken ct = default)
|
||||||
|
=> PostAsync<object, ReceiverAuthModel>(
|
||||||
|
$"api/receiverauth/{key}/tfa",
|
||||||
|
new { Code = code, Type = type },
|
||||||
|
ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service für Toast-Benachrichtigungen. Ersetzt AlertifyJS aus dem Web-Projekt.
|
||||||
|
///
|
||||||
|
/// WARUM ein Service und keine Komponente mit Parametern?
|
||||||
|
/// - Toasts können von ÜBERALL ausgelöst werden (Pages, Services, andere Komponenten)
|
||||||
|
/// - Ein Service ist über Dependency Injection überall verfügbar
|
||||||
|
/// - Die Toast-Komponente im Layout hört auf diesen Service und rendert die Nachrichten
|
||||||
|
///
|
||||||
|
/// PATTERN: Pub/Sub (Publisher/Subscriber)
|
||||||
|
/// - Publisher: Jede Komponente die _toast.ShowSuccess("...") aufruft
|
||||||
|
/// - Subscriber: Die Toast-Komponente im MainLayout, die auf OnChange hört
|
||||||
|
///
|
||||||
|
/// Das ist das gleiche Pattern wie beim EnvelopeState.
|
||||||
|
/// </summary>
|
||||||
|
public class ToastService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Liste aller aktuell sichtbaren Toasts.
|
||||||
|
/// Mehrere Toasts können gleichzeitig angezeigt werden (gestapelt).
|
||||||
|
/// </summary>
|
||||||
|
public List<ToastMessage> Messages { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Event: Informiert die Toast-Komponente über Änderungen</summary>
|
||||||
|
public event Action? OnChange;
|
||||||
|
|
||||||
|
public void ShowSuccess(string text) => Show(text, "success");
|
||||||
|
public void ShowError(string text) => Show(text, "danger");
|
||||||
|
public void ShowInfo(string text) => Show(text, "info");
|
||||||
|
public void ShowWarning(string text) => Show(text, "warning");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fügt einen Toast hinzu und entfernt ihn nach der angegebenen Dauer automatisch.
|
||||||
|
///
|
||||||
|
/// WARUM async void?
|
||||||
|
/// Normalerweise vermeidet man async void. Hier ist es ok, weil:
|
||||||
|
/// - Es ist ein Fire-and-Forget-Timer (wir warten nicht auf das Ergebnis)
|
||||||
|
/// - Fehler im Delay können die App nicht zum Absturz bringen
|
||||||
|
/// - Das ist ein gängiges Pattern für Auto-Dismiss-Logik
|
||||||
|
/// </summary>
|
||||||
|
private async void Show(string text, string type, int durationMs = 4000)
|
||||||
|
{
|
||||||
|
var message = new ToastMessage(text, type);
|
||||||
|
Messages.Add(message);
|
||||||
|
OnChange?.Invoke();
|
||||||
|
|
||||||
|
// Nach Ablauf der Dauer automatisch entfernen
|
||||||
|
await Task.Delay(durationMs);
|
||||||
|
Messages.Remove(message);
|
||||||
|
OnChange?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Entfernt einen Toast sofort (z.B. wenn der Benutzer auf X klickt)</summary>
|
||||||
|
public void Dismiss(ToastMessage message)
|
||||||
|
{
|
||||||
|
Messages.Remove(message);
|
||||||
|
OnChange?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ein einzelner Toast-Eintrag.
|
||||||
|
///
|
||||||
|
/// WARUM ein record statt class?
|
||||||
|
/// - Records haben automatisch Equals/GetHashCode basierend auf allen Properties
|
||||||
|
/// - Wir brauchen das für Messages.Remove() — es vergleicht über Referenz-Gleichheit
|
||||||
|
/// - Die Id (Guid) macht jeden Toast einzigartig, auch bei gleichem Text
|
||||||
|
/// </summary>
|
||||||
|
public record ToastMessage(string Text, string Type)
|
||||||
|
{
|
||||||
|
/// <summary>Eindeutige Id — damit zwei Toasts mit gleichem Text unterscheidbar sind</summary>
|
||||||
|
public Guid Id { get; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gibt die Bootstrap-Icon-Klasse basierend auf dem Typ zurück.
|
||||||
|
/// success → check-circle, danger → x-circle, etc.
|
||||||
|
/// </summary>
|
||||||
|
public string IconClass => Type switch
|
||||||
|
{
|
||||||
|
"success" => "bi-check-circle-fill",
|
||||||
|
"danger" => "bi-x-circle-fill",
|
||||||
|
"warning" => "bi-exclamation-triangle-fill",
|
||||||
|
_ => "bi-info-circle-fill"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.State
|
||||||
|
{
|
||||||
|
public class AuthState
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Models;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.ReceiverUI.Client.State;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hält den aktuellen Zustand des geladenen Umschlags.
|
||||||
|
///
|
||||||
|
/// WARUM ein eigenes State-Objekt?
|
||||||
|
/// - Mehrere Komponenten auf einer Seite brauchen die gleichen Daten
|
||||||
|
/// - Ohne State müsste jede Komponente die Daten selbst laden → doppelte API-Calls
|
||||||
|
/// - StateHasChanged() informiert automatisch alle Subscriber
|
||||||
|
///
|
||||||
|
/// PATTERN: "Observable State" — Services setzen den State, Komponenten reagieren darauf.
|
||||||
|
///
|
||||||
|
/// Die Set-Methoden nehmen jetzt ein ReceiverAuthModel entgegen,
|
||||||
|
/// damit alle Felder (Title, SenderEmail, TfaType etc.) zentral gespeichert werden.
|
||||||
|
/// </summary>
|
||||||
|
public class EnvelopeState
|
||||||
|
{
|
||||||
|
private EnvelopePageStatus _status = EnvelopePageStatus.Loading;
|
||||||
|
|
||||||
|
/// <summary>Aktueller Seitenstatus</summary>
|
||||||
|
public EnvelopePageStatus Status
|
||||||
|
{
|
||||||
|
get => _status;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
_status = value;
|
||||||
|
NotifyStateChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Felder aus ReceiverAuthModel ──
|
||||||
|
|
||||||
|
/// <summary>Titel des Umschlags (z.B. "Vertragsdokument")</summary>
|
||||||
|
public string? Title { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Nachricht des Absenders</summary>
|
||||||
|
public string? Message { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>E-Mail des Absenders (für Rückfragen-Hinweis)</summary>
|
||||||
|
public string? SenderEmail { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Ob TFA für diesen Umschlag aktiviert ist</summary>
|
||||||
|
public bool TfaEnabled { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Ob der Empfänger eine Telefonnummer hat (für SMS-TFA)</summary>
|
||||||
|
public bool HasPhoneNumber { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Ob das Dokument nur gelesen werden soll (ReadAndConfirm)</summary>
|
||||||
|
public bool ReadOnly { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>TFA-Typ: "sms" oder "authenticator"</summary>
|
||||||
|
public string? TfaType { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Ablaufzeit des SMS-Codes (für Countdown-Timer)</summary>
|
||||||
|
public DateTime? TfaExpiration { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Fehlermeldung (z.B. "Falscher Zugangscode")</summary>
|
||||||
|
public string? ErrorMessage { get; private set; }
|
||||||
|
|
||||||
|
// ── Zustandsübergänge ──
|
||||||
|
|
||||||
|
public void SetLoading()
|
||||||
|
{
|
||||||
|
ErrorMessage = null;
|
||||||
|
Status = EnvelopePageStatus.Loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setzt den State aus einer API-Antwort.
|
||||||
|
/// Zentrale Methode — alle Endpunkte liefern ReceiverAuthModel,
|
||||||
|
/// und diese Methode mappt den Status-String auf das richtige Enum.
|
||||||
|
/// </summary>
|
||||||
|
public void ApplyApiResponse(ReceiverAuthModel model)
|
||||||
|
{
|
||||||
|
// Gemeinsame Felder immer übernehmen
|
||||||
|
Title = model.Title ?? Title;
|
||||||
|
Message = model.Message ?? Message;
|
||||||
|
SenderEmail = model.SenderEmail ?? SenderEmail;
|
||||||
|
TfaEnabled = model.TfaEnabled;
|
||||||
|
HasPhoneNumber = model.HasPhoneNumber;
|
||||||
|
ReadOnly = model.ReadOnly;
|
||||||
|
TfaType = model.TfaType ?? TfaType;
|
||||||
|
TfaExpiration = model.TfaExpiration ?? TfaExpiration;
|
||||||
|
ErrorMessage = model.ErrorMessage;
|
||||||
|
|
||||||
|
// Status-String → Enum
|
||||||
|
Status = model.Status switch
|
||||||
|
{
|
||||||
|
"requires_access_code" => EnvelopePageStatus.RequiresAccessCode,
|
||||||
|
"requires_tfa" => EnvelopePageStatus.RequiresTwoFactor,
|
||||||
|
"show_document" => EnvelopePageStatus.ShowDocument,
|
||||||
|
"already_signed" => EnvelopePageStatus.AlreadySigned,
|
||||||
|
"rejected" => EnvelopePageStatus.Rejected,
|
||||||
|
"not_found" => EnvelopePageStatus.NotFound,
|
||||||
|
"expired" => EnvelopePageStatus.Expired,
|
||||||
|
"error" => EnvelopePageStatus.Error,
|
||||||
|
_ => EnvelopePageStatus.Error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Setzt Fehler wenn der API-Call selbst fehlschlägt (Netzwerk etc.)</summary>
|
||||||
|
public void SetError(string message)
|
||||||
|
{
|
||||||
|
ErrorMessage = message;
|
||||||
|
Status = EnvelopePageStatus.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Setzt NotFound (z.B. bei 404 ohne Body)</summary>
|
||||||
|
public void SetNotFound() => Status = EnvelopePageStatus.NotFound;
|
||||||
|
|
||||||
|
// ── Event ──
|
||||||
|
public event Action? OnChange;
|
||||||
|
private void NotifyStateChanged() => OnChange?.Invoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Alle möglichen Zustände der Umschlag-Seite</summary>
|
||||||
|
public enum EnvelopePageStatus
|
||||||
|
{
|
||||||
|
Loading,
|
||||||
|
RequiresAccessCode,
|
||||||
|
RequiresTwoFactor,
|
||||||
|
ShowDocument,
|
||||||
|
AlreadySigned,
|
||||||
|
Rejected,
|
||||||
|
NotFound,
|
||||||
|
Expired,
|
||||||
|
Error
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@using System.Net.Http
|
||||||
|
@using System.Net.Http.Json
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using EnvelopeGenerator.ReceiverUI.Client
|
||||||
|
@using EnvelopeGenerator.ReceiverUI.Client.Models
|
||||||
|
@using EnvelopeGenerator.ReceiverUI.Client.Services
|
||||||
|
@using EnvelopeGenerator.ReceiverUI.Client.Services.Base
|
||||||
|
@using EnvelopeGenerator.ReceiverUI.Client.State
|
||||||
|
@using EnvelopeGenerator.ReceiverUI.Client.Auth
|
||||||
|
@using EnvelopeGenerator.ReceiverUI.Client.Components.Shared
|
||||||
|
@using EnvelopeGenerator.ReceiverUI.Client.Components.Envelope
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
body {
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<base href="/" />
|
||||||
|
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
|
||||||
|
<link rel="stylesheet" href="bootstrap-icons/bootstrap-icons.min.css" />
|
||||||
|
<link rel="stylesheet" href="app.css" />
|
||||||
|
<link rel="stylesheet" href="EnvelopeGenerator.ReceiverUI.styles.css" />
|
||||||
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
|
<HeadOutlet />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<Routes />
|
||||||
|
<script src="_framework/blazor.web.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<h3>AuthLayout</h3>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
@* MainLayout: Das Grundgerüst jeder Seite.
|
||||||
|
Entspricht _Layout.cshtml im Web-Projekt.
|
||||||
|
|
||||||
|
Aufbau:
|
||||||
|
- Header: signFLOW-Logo/Titel (oben, sticky)
|
||||||
|
- Main: Der Seiteninhalt (@Body) mit ErrorBoundary
|
||||||
|
- Footer: Copyright + Privacy-Link (unten)
|
||||||
|
|
||||||
|
Sticky Footer Pattern: Der Footer klebt immer am unteren Rand,
|
||||||
|
auch wenn der Inhalt wenig Platz braucht. Das funktioniert über
|
||||||
|
Flexbox in app.css (.app-container mit min-height: 100vh). *@
|
||||||
|
|
||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
<div class="app-container">
|
||||||
|
<Toast />
|
||||||
|
|
||||||
|
@* ── Header ── *@
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
@* Im Web-Projekt steht hier ein <img> mit dem signFLOW-Logo.
|
||||||
|
Wir nutzen erstmal Text. Das Logo kommt in Phase 6
|
||||||
|
wenn wir die Bilder aus dem Web-Projekt portieren. *@
|
||||||
|
<span class="app-title">signFLOW</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@* ── Main: Seiteninhalt mit Error-Schutz ──
|
||||||
|
ErrorBoundary fängt unbehandelte Exceptions in Komponenten ab.
|
||||||
|
Ohne ErrorBoundary würde die gesamte App abstürzen.
|
||||||
|
Mit ErrorBoundary zeigen wir stattdessen eine Fehlermeldung
|
||||||
|
und einen "Erneut versuchen"-Button. *@
|
||||||
|
<main class="app-main">
|
||||||
|
<ErrorBoundary @ref="_errorBoundary">
|
||||||
|
<ChildContent>
|
||||||
|
@Body
|
||||||
|
</ChildContent>
|
||||||
|
<ErrorContent Context="ex">
|
||||||
|
<div class="error-container text-center py-5">
|
||||||
|
<div class="status-icon locked mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
</div>
|
||||||
|
<h2>Ein unerwarteter Fehler ist aufgetreten</h2>
|
||||||
|
<p class="text-muted">Bitte versuchen Sie es erneut.</p>
|
||||||
|
<button class="btn btn-primary" @onclick="Recover">
|
||||||
|
<i class="bi bi-arrow-counterclockwise me-2"></i>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ErrorContent>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
@* ── Footer ──
|
||||||
|
Im Web-Projekt gibt es hier drei Elemente:
|
||||||
|
1. Copyright + Link zur Firmenwebsite
|
||||||
|
2. Sprachauswahl (Dropdown mit Flaggen) → kommt in Phase 6
|
||||||
|
3. Privacy-Link (Datenschutzerklärung)
|
||||||
|
|
||||||
|
Die Datenschutz-HTML-Dateien existieren im Web-Projekt unter
|
||||||
|
wwwroot/privacy-policy.de-DE.html. Wir verlinken vorerst
|
||||||
|
auf eine statische URL. Die Datei selbst portieren wir in Phase 6. *@
|
||||||
|
<footer class="app-footer">
|
||||||
|
<small>
|
||||||
|
© signFLOW @DateTime.Now.Year
|
||||||
|
<a href="https://digitaldata.works" target="_blank" class="text-muted text-decoration-none">
|
||||||
|
Digital Data GmbH
|
||||||
|
</a>
|
||||||
|
</small>
|
||||||
|
|
||||||
|
@* Platzhalter für Sprachauswahl — kommt in Phase 6 *@
|
||||||
|
|
||||||
|
<a href="/privacy-policy.de-DE.html" target="_blank" class="text-muted text-decoration-none">
|
||||||
|
<small>Datenschutz</small>
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private ErrorBoundary? _errorBoundary;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setzt die ErrorBoundary zurück.
|
||||||
|
/// Blazor rendert dann @Body erneut statt der Fehlermeldung.
|
||||||
|
/// </summary>
|
||||||
|
private void Recover() => _errorBoundary?.Recover();
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
.page {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-row {
|
||||||
|
background-color: #f7f7f7;
|
||||||
|
border-bottom: 1px solid #d6d5d5;
|
||||||
|
justify-content: flex-end;
|
||||||
|
height: 3.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-row ::deep a:first-child {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640.98px) {
|
||||||
|
.top-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 641px) {
|
||||||
|
.page {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 250px;
|
||||||
|
height: 100vh;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-row {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-row.auth ::deep a:first-child {
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-row, article {
|
||||||
|
padding-left: 2rem !important;
|
||||||
|
padding-right: 1.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#blazor-error-ui {
|
||||||
|
background: lightyellow;
|
||||||
|
bottom: 0;
|
||||||
|
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
display: none;
|
||||||
|
left: 0;
|
||||||
|
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blazor-error-ui .dismiss {
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 0.5rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
@page "/Error"
|
||||||
|
@using System.Diagnostics
|
||||||
|
|
||||||
|
<PageTitle>Error</PageTitle>
|
||||||
|
|
||||||
|
<h1 class="text-danger">Error.</h1>
|
||||||
|
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||||
|
|
||||||
|
@if (ShowRequestId)
|
||||||
|
{
|
||||||
|
<p>
|
||||||
|
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h3>Development Mode</h3>
|
||||||
|
<p>
|
||||||
|
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||||
|
It can result in displaying sensitive information from exceptions to end users.
|
||||||
|
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||||
|
and restarting the app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@code{
|
||||||
|
[CascadingParameter]
|
||||||
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
private string? RequestId { get; set; }
|
||||||
|
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||||
|
|
||||||
|
protected override void OnInitialized() =>
|
||||||
|
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@page "/"
|
||||||
|
|
||||||
|
<PageTitle>Home</PageTitle>
|
||||||
|
|
||||||
|
<h1>Hello, world!</h1>
|
||||||
|
|
||||||
|
Welcome to your new app.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||||
|
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
<NotFound>
|
||||||
|
<LayoutView Layout="typeof(Layout.MainLayout)">
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<h1>404</h1>
|
||||||
|
<p>Diese Seite wurde nicht gefunden.</p>
|
||||||
|
</div>
|
||||||
|
</LayoutView>
|
||||||
|
</NotFound>
|
||||||
|
</Router>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@using System.Net.Http
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using EnvelopeGenerator.ReceiverUI.Components
|
||||||
|
@using EnvelopeGenerator.ReceiverUI.Components.Layout
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EnvelopeGenerator.ReceiverUI.Client\EnvelopeGenerator.ReceiverUI.Client.csproj" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="9.0.3" />
|
||||||
|
<PackageReference Include="Yarp.ReverseProxy" Version="2.1.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="wwwroot\bootstrap\" />
|
||||||
|
<Folder Include="wwwroot\bootstrap-icons\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Auth;
|
||||||
|
using EnvelopeGenerator.ReceiverUI.Client.Services;
|
||||||
|
using EnvelopeGenerator.ReceiverUI.Client.State;
|
||||||
|
using EnvelopeGenerator.ReceiverUI.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddRazorComponents()
|
||||||
|
.AddInteractiveServerComponents()
|
||||||
|
.AddInteractiveWebAssemblyComponents();
|
||||||
|
|
||||||
|
// API-Proxy: Alle /api/* Aufrufe an die echte API weiterleiten
|
||||||
|
// WARUM: Der Blazor-Client ruft /api/envelope auf. Diese Anfrage geht an den
|
||||||
|
// ReceiverUI-Server (gleiche Domain, kein CORS), der sie an die echte API weiterleitet.
|
||||||
|
var apiBaseUrl = builder.Configuration["ApiBaseUrl"]
|
||||||
|
?? throw new InvalidOperationException("ApiBaseUrl is not configured in appsettings.json.");
|
||||||
|
|
||||||
|
builder.Services.AddHttpForwarder();
|
||||||
|
|
||||||
|
// ── Services: Müssen AUCH auf dem Server registriert sein ──
|
||||||
|
// WARUM? Bei @rendermode InteractiveAuto rendert Blazor die Seite zuerst
|
||||||
|
// auf dem Server (SSR/Prerendering). Dabei resolved es @inject-Properties.
|
||||||
|
// Wenn ein Service nur im Client-Projekt (WASM) registriert ist, aber nicht
|
||||||
|
// hier, gibt es eine InvalidOperationException beim Prerendering.
|
||||||
|
//
|
||||||
|
// Der HttpClient auf dem Server zeigt auf sich selbst (localhost),
|
||||||
|
// weil die /api/* Requests über MapForwarder an die echte API gehen.
|
||||||
|
builder.Services.AddScoped(sp =>
|
||||||
|
{
|
||||||
|
var navigationManager = sp.GetService<Microsoft.AspNetCore.Components.NavigationManager>();
|
||||||
|
var baseUri = navigationManager?.BaseUri ?? $"https://localhost:{builder.Configuration["ASPNETCORE_HTTPS_PORT"] ?? "7206"}/";
|
||||||
|
return new HttpClient { BaseAddress = new Uri(baseUri) };
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddAuthorizationCore();
|
||||||
|
builder.Services.AddScoped<ApiAuthStateProvider>();
|
||||||
|
builder.Services.AddScoped<AuthenticationStateProvider>(sp =>
|
||||||
|
sp.GetRequiredService<ApiAuthStateProvider>());
|
||||||
|
|
||||||
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
|
builder.Services.AddScoped<IEnvelopeService, EnvelopeService>();
|
||||||
|
builder.Services.AddScoped<IReceiverAuthService, ReceiverAuthService>();
|
||||||
|
builder.Services.AddScoped<EnvelopeState>();
|
||||||
|
builder.Services.AddScoped<ToastService>();
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseWebAssemblyDebugging();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
||||||
|
app.UseHsts();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseHttpsRedirection();
|
||||||
|
app.UseStaticFiles();
|
||||||
|
app.UseAntiforgery();
|
||||||
|
|
||||||
|
// Alle /api/* Requests an die echte EnvelopeGenerator.API weiterleiten
|
||||||
|
// So muss der Browser nie direkt mit der API sprechen → kein CORS, Cookies funktionieren
|
||||||
|
app.MapForwarder("/api/{**catch-all}", apiBaseUrl);
|
||||||
|
|
||||||
|
app.MapRazorComponents<App>()
|
||||||
|
.AddInteractiveServerRenderMode()
|
||||||
|
.AddInteractiveWebAssemblyRenderMode()
|
||||||
|
.AddAdditionalAssemblies(typeof(EnvelopeGenerator.ReceiverUI.Client._Imports).Assembly);
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||||
|
"iisSettings": {
|
||||||
|
"windowsAuthentication": false,
|
||||||
|
"anonymousAuthentication": true,
|
||||||
|
"iisExpress": {
|
||||||
|
"applicationUrl": "http://localhost:3101",
|
||||||
|
"sslPort": 44303
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||||
|
"applicationUrl": "http://localhost:5109",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"https": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||||
|
"applicationUrl": "https://localhost:7206;http://localhost:5109",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"IIS Express": {
|
||||||
|
"commandName": "IISExpress",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"ApiBaseUrl": "https://localhost:8088",
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/* =============================================
|
||||||
|
signFLOW ReceiverUI — Basis-Stylesheet
|
||||||
|
Ersetzt: site.css, card.css, logo.css aus EnvelopeGenerator.Web
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
/* ----- CSS Custom Properties (Design-Tokens) -----
|
||||||
|
WARUM: Zentrale Stelle für Farben/Abstände.
|
||||||
|
Wenn sich das Branding ändert, änderst du nur diese Werte. */
|
||||||
|
:root {
|
||||||
|
--sf-primary: #0d6efd;
|
||||||
|
--sf-danger: #dc3545;
|
||||||
|
--sf-success: #198754;
|
||||||
|
--sf-bg: #f8f9fa;
|
||||||
|
--sf-text: #212529;
|
||||||
|
--sf-muted: #6c757d;
|
||||||
|
--sf-border: #dee2e6;
|
||||||
|
--sf-header-height: 56px;
|
||||||
|
--sf-footer-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Globale Resets -----
|
||||||
|
WARUM: Konsistentes Rendering über alle Browser. */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
background-color: var(--sf-bg);
|
||||||
|
color: var(--sf-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- App-Container -----
|
||||||
|
WARUM: Flexbox-Layout damit Footer immer unten bleibt,
|
||||||
|
auch wenn der Content wenig Inhalt hat (Sticky Footer Pattern). */
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Header -----
|
||||||
|
WARUM: Fester Header oben wie im Web-Projekt.
|
||||||
|
Höhe ist als CSS-Variable definiert, damit main darunter beginnt. */
|
||||||
|
.app-header {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid var(--sf-border);
|
||||||
|
height: var(--sf-header-height);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 1rem;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--sf-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Main Content -----
|
||||||
|
WARUM: flex: 1 sorgt dafür, dass der Content-Bereich den gesamten
|
||||||
|
verfügbaren Platz einnimmt. Der Footer wird nach unten gedrückt. */
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Footer -----
|
||||||
|
WARUM: Immer am unteren Rand. Enthält Copyright + Sprachauswahl + Privacy-Link
|
||||||
|
(wie im Web-Projekt). */
|
||||||
|
.app-footer {
|
||||||
|
background-color: #fff;
|
||||||
|
border-top: 1px solid var(--sf-border);
|
||||||
|
height: var(--sf-footer-height);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Page Container -----
|
||||||
|
WARUM: Zentrierter Container für Seiteninhalte.
|
||||||
|
Entspricht dem <div class="page container"> im Web-Projekt. */
|
||||||
|
.page {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- AccessCode-Formular -----
|
||||||
|
WARUM: Zentriertes Eingabefeld wie EnvelopeLocked.cshtml.
|
||||||
|
Die Klasse .code-input macht das Eingabefeld größer und zentriert den Text. */
|
||||||
|
.access-code-container {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: 0.5rem;
|
||||||
|
max-width: 300px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Status-Icons -----
|
||||||
|
WARUM: Die SVG-Icons für Signed/Rejected/Locked aus dem Web-Projekt
|
||||||
|
bekommen hier einheitliche Größen und Farben. */
|
||||||
|
.status-icon {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon svg,
|
||||||
|
.status-icon .bi {
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon.signed {
|
||||||
|
color: var(--sf-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon.rejected {
|
||||||
|
color: var(--sf-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon.locked {
|
||||||
|
color: var(--sf-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- PDF-Container -----
|
||||||
|
WARUM: PSPDFKit braucht einen Container mit fester Höhe.
|
||||||
|
Wird in Phase 6 relevant, aber die Klasse wird schon jetzt definiert. */
|
||||||
|
.pdf-container {
|
||||||
|
border: 1px solid var(--sf-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Error Container -----
|
||||||
|
WARUM: Styling für die ErrorBoundary im MainLayout. */
|
||||||
|
.error-container {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- Responsive -----
|
||||||
|
WARUM: Auf Mobilgeräten braucht der Content weniger Padding. */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.app-main {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-input {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
letter-spacing: 0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</dependentAssembly>
|
</dependentAssembly>
|
||||||
<dependentAssembly>
|
<dependentAssembly>
|
||||||
<assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
<assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||||
<bindingRedirect oldVersion="0.0.0.0-4.0.5.0" newVersion="4.0.2.0" />
|
<bindingRedirect oldVersion="0.0.0.0-4.0.2.0" newVersion="4.0.2.0" />
|
||||||
</dependentAssembly>
|
</dependentAssembly>
|
||||||
<dependentAssembly>
|
<dependentAssembly>
|
||||||
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
</dependentAssembly>
|
</dependentAssembly>
|
||||||
<dependentAssembly>
|
<dependentAssembly>
|
||||||
<assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
<assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||||
<bindingRedirect oldVersion="0.0.0.0-4.0.5.0" newVersion="4.0.4.0" />
|
<bindingRedirect oldVersion="0.0.0.0-4.0.4.0" newVersion="4.0.4.0" />
|
||||||
</dependentAssembly>
|
</dependentAssembly>
|
||||||
<dependentAssembly>
|
<dependentAssembly>
|
||||||
<assemblyIdentity name="System.Collections.Immutable" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
<assemblyIdentity name="System.Collections.Immutable" publicKeyToken="b03f5f7f11d50a3a" culture="neutral" />
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
</dependentAssembly>
|
</dependentAssembly>
|
||||||
<dependentAssembly>
|
<dependentAssembly>
|
||||||
<assemblyIdentity name="Microsoft.Extensions.Logging.Abstractions" publicKeyToken="adb9793829ddae60" culture="neutral" />
|
<assemblyIdentity name="Microsoft.Extensions.Logging.Abstractions" publicKeyToken="adb9793829ddae60" culture="neutral" />
|
||||||
<bindingRedirect oldVersion="0.0.0.0-8.0.0.3" newVersion="8.0.0.3" />
|
<bindingRedirect oldVersion="0.0.0.0-7.0.0.0" newVersion="7.0.0.0" />
|
||||||
</dependentAssembly>
|
</dependentAssembly>
|
||||||
<dependentAssembly>
|
<dependentAssembly>
|
||||||
<assemblyIdentity name="System.Text.Encodings.Web" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
<assemblyIdentity name="System.Text.Encodings.Web" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
</dependentAssembly>
|
</dependentAssembly>
|
||||||
<dependentAssembly>
|
<dependentAssembly>
|
||||||
<assemblyIdentity name="Microsoft.Extensions.DependencyInjection.Abstractions" publicKeyToken="adb9793829ddae60" culture="neutral" />
|
<assemblyIdentity name="Microsoft.Extensions.DependencyInjection.Abstractions" publicKeyToken="adb9793829ddae60" culture="neutral" />
|
||||||
<bindingRedirect oldVersion="0.0.0.0-8.0.0.2" newVersion="8.0.0.2" />
|
<bindingRedirect oldVersion="0.0.0.0-7.0.0.0" newVersion="7.0.0.0" />
|
||||||
</dependentAssembly>
|
</dependentAssembly>
|
||||||
<dependentAssembly>
|
<dependentAssembly>
|
||||||
<assemblyIdentity name="Microsoft.Extensions.Caching.Abstractions" publicKeyToken="adb9793829ddae60" culture="neutral" />
|
<assemblyIdentity name="Microsoft.Extensions.Caching.Abstractions" publicKeyToken="adb9793829ddae60" culture="neutral" />
|
||||||
|
|||||||
@@ -181,11 +181,11 @@
|
|||||||
<Reference Include="Microsoft.Extensions.DependencyInjection, Version=7.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60, processorArchitecture=MSIL">
|
<Reference Include="Microsoft.Extensions.DependencyInjection, Version=7.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60, processorArchitecture=MSIL">
|
||||||
<HintPath>..\packages\Microsoft.Extensions.DependencyInjection.7.0.0\lib\net462\Microsoft.Extensions.DependencyInjection.dll</HintPath>
|
<HintPath>..\packages\Microsoft.Extensions.DependencyInjection.7.0.0\lib\net462\Microsoft.Extensions.DependencyInjection.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Microsoft.Extensions.DependencyInjection.Abstractions, Version=8.0.0.2, Culture=neutral, PublicKeyToken=adb9793829ddae60, processorArchitecture=MSIL">
|
<Reference Include="Microsoft.Extensions.DependencyInjection.Abstractions, Version=7.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60, processorArchitecture=MSIL">
|
||||||
<HintPath>..\packages\Microsoft.Extensions.DependencyInjection.Abstractions.8.0.2\lib\net462\Microsoft.Extensions.DependencyInjection.Abstractions.dll</HintPath>
|
<HintPath>..\packages\Microsoft.Extensions.DependencyInjection.Abstractions.7.0.0\lib\net462\Microsoft.Extensions.DependencyInjection.Abstractions.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Microsoft.Extensions.Logging.Abstractions, Version=8.0.0.3, Culture=neutral, PublicKeyToken=adb9793829ddae60, processorArchitecture=MSIL">
|
<Reference Include="Microsoft.Extensions.Logging.Abstractions, Version=7.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60, processorArchitecture=MSIL">
|
||||||
<HintPath>..\packages\Microsoft.Extensions.Logging.Abstractions.8.0.3\lib\net462\Microsoft.Extensions.Logging.Abstractions.dll</HintPath>
|
<HintPath>..\packages\Microsoft.Extensions.Logging.Abstractions.7.0.0\lib\net462\Microsoft.Extensions.Logging.Abstractions.dll</HintPath>
|
||||||
</Reference>
|
</Reference>
|
||||||
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
|
<Reference Include="Newtonsoft.Json, Version=13.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
|
||||||
<HintPath>..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
|
<HintPath>..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll</HintPath>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
<package id="Microsoft.Bcl.AsyncInterfaces" version="8.0.0" targetFramework="net48" />
|
<package id="Microsoft.Bcl.AsyncInterfaces" version="8.0.0" targetFramework="net48" />
|
||||||
<package id="Microsoft.CSharp" version="4.7.0" targetFramework="net48" />
|
<package id="Microsoft.CSharp" version="4.7.0" targetFramework="net48" />
|
||||||
<package id="Microsoft.Extensions.DependencyInjection" version="7.0.0" targetFramework="net462" />
|
<package id="Microsoft.Extensions.DependencyInjection" version="7.0.0" targetFramework="net462" />
|
||||||
<package id="Microsoft.Extensions.DependencyInjection.Abstractions" version="8.0.2" targetFramework="net462" />
|
<package id="Microsoft.Extensions.DependencyInjection.Abstractions" version="7.0.0" targetFramework="net462" />
|
||||||
<package id="Microsoft.Extensions.Logging.Abstractions" version="8.0.3" targetFramework="net462" />
|
<package id="Microsoft.Extensions.Logging.Abstractions" version="7.0.0" targetFramework="net462" />
|
||||||
<package id="Microsoft.VisualBasic" version="10.3.0" targetFramework="net48" />
|
<package id="Microsoft.VisualBasic" version="10.3.0" targetFramework="net48" />
|
||||||
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
|
<package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
|
||||||
<package id="Newtonsoft.Json.Bson" version="1.0.2" targetFramework="net48" />
|
<package id="Newtonsoft.Json.Bson" version="1.0.2" targetFramework="net48" />
|
||||||
|
|||||||
@@ -1,32 +1,56 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
using EnvelopeGenerator.Application.Common.Extensions;
|
using EnvelopeGenerator.Application.Common.Extensions;
|
||||||
using MediatR;
|
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||||
using EnvelopeGenerator.Application.Envelopes.Queries;
|
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Web.Controllers.Test;
|
namespace EnvelopeGenerator.Web.Controllers.Test;
|
||||||
|
|
||||||
[Obsolete("Use MediatR")]
|
[Obsolete("Use MediatR")]
|
||||||
public class TestEnvelopeController : ControllerBase
|
public class TestEnvelopeController : TestControllerBase<IEnvelopeService, EnvelopeDto, Envelope, int>
|
||||||
{
|
{
|
||||||
private readonly IMediator _mediator;
|
public TestEnvelopeController(ILogger<TestEnvelopeController> logger, IEnvelopeService service) : base(logger, service)
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
///
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="mediator"></param>
|
|
||||||
public TestEnvelopeController(IMediator mediator)
|
|
||||||
{
|
{
|
||||||
_mediator = mediator;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[NonAction]
|
||||||
|
public override Task<IActionResult> GetAll() => base.GetAll();
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] ReadEnvelopeQuery query, CancellationToken cancel) => Ok(await _mediator.Send(query, cancel));
|
public async Task<IActionResult> GetAll([FromQuery] string? envelopeKey = default, [FromQuery] bool withDocuments = false, [FromQuery] bool withHistory = false, [FromQuery] bool withDocumentReceiverElement = false, [FromQuery] bool withUser = false, [FromQuery] bool withAll = true)
|
||||||
|
{
|
||||||
|
if (envelopeKey is not null)
|
||||||
|
{
|
||||||
|
(var uuid, var signature) = envelopeKey.DecodeEnvelopeReceiverId();
|
||||||
|
if (uuid is null)
|
||||||
|
return BadRequest("UUID is null");
|
||||||
|
var envlopeServiceResult = await _service.ReadByUuidAsync(
|
||||||
|
uuid: uuid,
|
||||||
|
withDocuments: withDocuments, withHistory: withHistory, withDocumentReceiverElement: withDocumentReceiverElement, withUser: withUser, withAll: withAll);
|
||||||
|
|
||||||
|
if (envlopeServiceResult.IsSuccess)
|
||||||
|
{
|
||||||
|
return Ok(envlopeServiceResult.Data);
|
||||||
|
}
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _service.ReadAllWithAsync(documents: withDocuments, history: withHistory);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
return NotFound(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("decode")]
|
[HttpGet("decode")]
|
||||||
public IActionResult DecodeEnvelopeReceiverId(string envelopeReceiverId, int type = 0) => type switch
|
public IActionResult DecodeEnvelopeReceiverId(string envelopeReceiverId, int type = 0)
|
||||||
{
|
{
|
||||||
1 => Ok(envelopeReceiverId.GetEnvelopeUuid()),
|
return type switch
|
||||||
2 => Ok(envelopeReceiverId.GetReceiverSignature()),
|
{
|
||||||
_ => Ok(envelopeReceiverId.DecodeEnvelopeReceiverId()),
|
1 => Ok(envelopeReceiverId.GetEnvelopeUuid()),
|
||||||
};
|
2 => Ok(envelopeReceiverId.GetReceiverSignature()),
|
||||||
|
_ => Ok(envelopeReceiverId.DecodeEnvelopeReceiverId()),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using EnvelopeGenerator.Jobs.FinalizeDocument;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.WorkerService.Configuration;
|
||||||
|
|
||||||
|
public sealed class WorkerSettings
|
||||||
|
{
|
||||||
|
public string ConnectionString { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool Debug { get; set; }
|
||||||
|
|
||||||
|
public int IntervalMinutes { get; set; } = 1;
|
||||||
|
|
||||||
|
public PDFBurnerParams PdfBurner { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>dotnet-EnvelopeGenerator.WorkerService-0636abb8-6085-477d-9f56-1a9787e84dde</UserSecretsId>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="BouncyCastle.Cryptography" Version="2.6.2" />
|
||||||
|
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
|
||||||
|
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.9.0" />
|
||||||
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EnvelopeGenerator.Jobs\EnvelopeGenerator.Jobs.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
67
EnvelopeGenerator.WorkerService/Program.cs
Normal file
67
EnvelopeGenerator.WorkerService/Program.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Jobs.APIBackendJobs;
|
||||||
|
using EnvelopeGenerator.Jobs.FinalizeDocument;
|
||||||
|
using EnvelopeGenerator.WorkerService;
|
||||||
|
using EnvelopeGenerator.WorkerService.Configuration;
|
||||||
|
using EnvelopeGenerator.WorkerService.Services;
|
||||||
|
using Quartz;
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.Configure<WorkerSettings>(builder.Configuration.GetSection("WorkerSettings"));
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<TempFileManager>();
|
||||||
|
builder.Services.AddSingleton(provider =>
|
||||||
|
{
|
||||||
|
var settings = provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<WorkerSettings>>().Value;
|
||||||
|
var logger = provider.GetRequiredService<ILogger<PDFBurner>>();
|
||||||
|
return new PDFBurner(logger, settings.PdfBurner);
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton<PDFMerger>();
|
||||||
|
builder.Services.AddSingleton<ReportCreator>();
|
||||||
|
|
||||||
|
builder.Services.AddQuartz(q =>
|
||||||
|
{
|
||||||
|
q.UseMicrosoftDependencyInjectionJobFactory();
|
||||||
|
q.UseDefaultThreadPool(tp => tp.MaxConcurrency = 5);
|
||||||
|
|
||||||
|
var settings = new WorkerSettings();
|
||||||
|
builder.Configuration.GetSection("WorkerSettings").Bind(settings);
|
||||||
|
var intervalMinutes = Math.Max(1, settings.IntervalMinutes);
|
||||||
|
|
||||||
|
var finalizeJobKey = new JobKey("FinalizeDocumentJob");
|
||||||
|
q.AddJob<FinalizeDocumentJob>(opts => opts
|
||||||
|
.WithIdentity(finalizeJobKey)
|
||||||
|
.UsingJobData(Value.DATABASE, settings.ConnectionString));
|
||||||
|
|
||||||
|
q.AddTrigger(opts => opts
|
||||||
|
.ForJob(finalizeJobKey)
|
||||||
|
.WithIdentity("FinalizeDocumentJob-trigger")
|
||||||
|
.StartNow()
|
||||||
|
.WithSimpleSchedule(x => x
|
||||||
|
.WithIntervalInMinutes(intervalMinutes)
|
||||||
|
.RepeatForever()));
|
||||||
|
|
||||||
|
var apiJobKey = new JobKey("APIEnvelopeJob");
|
||||||
|
q.AddJob<APIEnvelopeJob>(opts => opts
|
||||||
|
.WithIdentity(apiJobKey)
|
||||||
|
.UsingJobData(Value.DATABASE, settings.ConnectionString));
|
||||||
|
|
||||||
|
q.AddTrigger(opts => opts
|
||||||
|
.ForJob(apiJobKey)
|
||||||
|
.WithIdentity("APIEnvelopeJob-trigger")
|
||||||
|
.StartNow()
|
||||||
|
.WithSimpleSchedule(x => x
|
||||||
|
.WithIntervalInMinutes(intervalMinutes)
|
||||||
|
.RepeatForever()));
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddQuartzHostedService(options =>
|
||||||
|
{
|
||||||
|
options.WaitForJobsToComplete = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddHostedService<Worker>();
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
host.Run();
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"EnvelopeGenerator.WorkerService": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
EnvelopeGenerator.WorkerService/Services/TempFileManager.cs
Normal file
74
EnvelopeGenerator.WorkerService/Services/TempFileManager.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
using System.IO;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.WorkerService.Services;
|
||||||
|
|
||||||
|
public sealed class TempFileManager
|
||||||
|
{
|
||||||
|
private readonly ILogger<TempFileManager> _logger;
|
||||||
|
|
||||||
|
public TempFileManager(ILogger<TempFileManager> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
TempPath = Path.Combine(Path.GetTempPath(), "EnvelopeGenerator");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TempPath { get; }
|
||||||
|
|
||||||
|
public Task CreateAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(TempPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(TempPath);
|
||||||
|
_logger.LogDebug("Created temp folder {TempPath}", TempPath);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CleanUpFiles();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to create temp folder {TempPath}", TempPath);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CleanupAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(TempPath))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Deleting temp folder {TempPath}", TempPath);
|
||||||
|
Directory.Delete(TempPath, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to clean up temp folder {TempPath}", TempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanUpFiles()
|
||||||
|
{
|
||||||
|
foreach (var file in Directory.GetFiles(TempPath))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Deleting temp file {File}", file);
|
||||||
|
File.Delete(file);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to delete temp file {File}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
EnvelopeGenerator.WorkerService/Worker.cs
Normal file
71
EnvelopeGenerator.WorkerService/Worker.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using EnvelopeGenerator.WorkerService.Configuration;
|
||||||
|
using EnvelopeGenerator.WorkerService.Services;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.WorkerService;
|
||||||
|
|
||||||
|
public class Worker : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ILogger<Worker> _logger;
|
||||||
|
private readonly WorkerSettings _settings;
|
||||||
|
private readonly TempFileManager _tempFiles;
|
||||||
|
|
||||||
|
public Worker(
|
||||||
|
ILogger<Worker> logger,
|
||||||
|
IOptions<WorkerSettings> settings,
|
||||||
|
TempFileManager tempFiles)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_settings = settings.Value;
|
||||||
|
_tempFiles = tempFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Starting EnvelopeGenerator worker...");
|
||||||
|
_logger.LogInformation("Debug mode: {Debug}", _settings.Debug);
|
||||||
|
|
||||||
|
ValidateConfiguration();
|
||||||
|
await EnsureDatabaseConnectionAsync(cancellationToken);
|
||||||
|
await _tempFiles.CreateAsync(cancellationToken);
|
||||||
|
|
||||||
|
await base.StartAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("EnvelopeGenerator worker is running. Jobs are scheduled every {Interval} minute(s).", Math.Max(1, _settings.IntervalMinutes));
|
||||||
|
await Task.Delay(Timeout.Infinite, stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Stopping EnvelopeGenerator worker...");
|
||||||
|
await _tempFiles.CleanupAsync(cancellationToken);
|
||||||
|
await base.StopAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ValidateConfiguration()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_settings.ConnectionString))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Connection string cannot be empty. Configure 'WorkerSettings:ConnectionString'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureDatabaseConnectionAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var connection = new SqlConnection(_settings.ConnectionString);
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
_logger.LogInformation("Database connection established successfully.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Database connection could not be established.");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
EnvelopeGenerator.WorkerService/appsettings.Development.json
Normal file
30
EnvelopeGenerator.WorkerService/appsettings.Development.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Debug",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"WorkerSettings": {
|
||||||
|
"ConnectionString": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;",
|
||||||
|
"Debug": true,
|
||||||
|
"IntervalMinutes": 1,
|
||||||
|
"PdfBurner": {
|
||||||
|
"IgnoredLabels": [
|
||||||
|
"Date",
|
||||||
|
"Datum",
|
||||||
|
"ZIP",
|
||||||
|
"PLZ",
|
||||||
|
"Place",
|
||||||
|
"Ort",
|
||||||
|
"Position",
|
||||||
|
"Stellung"
|
||||||
|
],
|
||||||
|
"TopMargin": 0.1,
|
||||||
|
"YOffset": -0.3,
|
||||||
|
"FontName": "Arial",
|
||||||
|
"FontSize": 8,
|
||||||
|
"FontStyle": "Italic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
EnvelopeGenerator.WorkerService/appsettings.json
Normal file
21
EnvelopeGenerator.WorkerService/appsettings.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"WorkerSettings": {
|
||||||
|
"ConnectionString": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;",
|
||||||
|
"Debug": false,
|
||||||
|
"IntervalMinutes": 1,
|
||||||
|
"PdfBurner": {
|
||||||
|
"IgnoredLabels": ["Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung"],
|
||||||
|
"TopMargin": 0.1,
|
||||||
|
"YOffset": -0.3,
|
||||||
|
"FontName": "Arial",
|
||||||
|
"FontSize": 8,
|
||||||
|
"FontStyle": "Italic"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,8 +33,16 @@ 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.Jobs", "EnvelopeGenerator.Jobs\EnvelopeGenerator.Jobs.csproj", "{3D0514EA-2681-4B13-AD71-35CC6363DBD7}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.WorkerService", "EnvelopeGenerator.WorkerService\EnvelopeGenerator.WorkerService.csproj", "{E3676510-7030-4E85-86E1-51E483E2A3B6}"
|
||||||
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.API", "EnvelopeGenerator.API\EnvelopeGenerator.API.csproj", "{EC768913-6270-14F4-1DD3-69C87A659462}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.API", "EnvelopeGenerator.API\EnvelopeGenerator.API.csproj", "{EC768913-6270-14F4-1DD3-69C87A659462}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.ReceiverUI", "EnvelopeGenerator.ReceiverUI\EnvelopeGenerator.ReceiverUI\EnvelopeGenerator.ReceiverUI.csproj", "{620A0476-2F37-490D-ABE9-50DEEB217DA7}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.ReceiverUI.Client", "EnvelopeGenerator.ReceiverUI\EnvelopeGenerator.ReceiverUI.Client\EnvelopeGenerator.ReceiverUI.Client.csproj", "{62DD9063-C026-4805-BD6D-4DB899ABC504}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -53,8 +61,8 @@ Global
|
|||||||
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Release|Any CPU.ActiveCfg = Debug|Any CPU
|
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Release|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Release|Any CPU.Build.0 = Debug|Any CPU
|
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Release|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.ActiveCfg = Release|Any CPU
|
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.Build.0 = Release|Any CPU
|
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.ActiveCfg = Debug|Any CPU
|
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.Build.0 = Debug|Any CPU
|
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
@@ -81,10 +89,26 @@ 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
|
||||||
|
{3D0514EA-2681-4B13-AD71-35CC6363DBD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{3D0514EA-2681-4B13-AD71-35CC6363DBD7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{3D0514EA-2681-4B13-AD71-35CC6363DBD7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{3D0514EA-2681-4B13-AD71-35CC6363DBD7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{E3676510-7030-4E85-86E1-51E483E2A3B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{E3676510-7030-4E85-86E1-51E483E2A3B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{E3676510-7030-4E85-86E1-51E483E2A3B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{E3676510-7030-4E85-86E1-51E483E2A3B6}.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.ActiveCfg = Debug|Any CPU
|
||||||
{EC768913-6270-14F4-1DD3-69C87A659462}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
|
||||||
{EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.Build.0 = Release|Any CPU
|
{EC768913-6270-14F4-1DD3-69C87A659462}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{620A0476-2F37-490D-ABE9-50DEEB217DA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{620A0476-2F37-490D-ABE9-50DEEB217DA7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{620A0476-2F37-490D-ABE9-50DEEB217DA7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{620A0476-2F37-490D-ABE9-50DEEB217DA7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{62DD9063-C026-4805-BD6D-4DB899ABC504}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{62DD9063-C026-4805-BD6D-4DB899ABC504}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{62DD9063-C026-4805-BD6D-4DB899ABC504}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{62DD9063-C026-4805-BD6D-4DB899ABC504}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -103,7 +127,11 @@ 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}
|
||||||
|
{3D0514EA-2681-4B13-AD71-35CC6363DBD7} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
||||||
|
{E3676510-7030-4E85-86E1-51E483E2A3B6} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
|
||||||
{EC768913-6270-14F4-1DD3-69C87A659462} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
{EC768913-6270-14F4-1DD3-69C87A659462} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||||
|
{620A0476-2F37-490D-ABE9-50DEEB217DA7} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||||
|
{62DD9063-C026-4805-BD6D-4DB899ABC504} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}
|
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}
|
||||||
|
|||||||
Reference in New Issue
Block a user