diff --git a/AGENTS.md b/AGENTS.md index 6c287f5d..49d4d4db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -236,6 +236,12 @@ Manual testing workflow: 3. Test sender login at `/sender/login` 4. Test receiver flow at `/envelope/login/{envelopeKey}` +## Technical Debt TODOs + +- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor` to reduce complexity. +- Move sender dashboard business/status logic into dedicated services and extension methods (for example effective status resolution, tab classification, receiver signed/rejected detection, and grid-layout filter sanitation). +- Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component. + ## Database **SQL Server** (DD_ECM) diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/AnnotationService.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/AnnotationService.cs index fd9e7556..2f28584a 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/AnnotationService.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/AnnotationService.cs @@ -26,4 +26,24 @@ public class AnnotationService(IHttpClientFactory httpClientFactory) var result = await response.Content.ReadFromJsonAsync>(_jsonOptions, cancel); return result ?? []; } + + public async Task RejectAsync(string envelopeKey, string? reason, CancellationToken cancel = default) + { + using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server"); + + var response = await http.PostAsJsonAsync( + $"/api/Annotation/reject/{Uri.EscapeDataString(envelopeKey)}", + reason, + _jsonOptions, + cancel); + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(cancel); + throw new HttpRequestException( + $"Failed to reject envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}", + null, + response.StatusCode); + } + } } diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor index 20c248ee..e9222313 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor @@ -262,7 +262,7 @@ var envelope = cellContext.DataItem as EnvelopeDto; if (envelope != null) { - var statusInfo = GetStatusInfo(envelope.Status); + var statusInfo = GetStatusInfo(GetEffectiveEnvelopeStatus(envelope));
@statusInfo.Label @@ -314,12 +314,20 @@ {
@{ + var isRejected = IsReceiverRejected(envelope, receiver); var isSigned = IsReceiverSigned(envelope, receiver); var accessCode = receiver.AccessCode; var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode); } - - @if (isSigned) + + @if (isRejected) + { + + + + @GetStatusLabel(EnvelopeStatus.DocumentRejected) + } + else if (isSigned) { @@ -590,10 +598,11 @@ { _allEnvelopes = await EnvelopeService.GetAsync() ?? []; - // Split into active and completed based on status + // Split into active and completed based on effective status + // (history-aware: rejected envelopes should not stay in active tab). var envelopes = _allEnvelopes.ToList(); - _activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList(); - _completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList(); + _activeEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsActive()).ToList(); + _completedEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsCompleted()).ToList(); } catch (Exception ex) { @@ -745,13 +754,13 @@ bool IsEnvelopeSent(EnvelopeDto envelope) { - var status = (EnvelopeStatus)envelope.Status; + var status = GetEffectiveEnvelopeStatus(envelope); return status >= EnvelopeStatus.EnvelopeQueued; } bool IsEnvelopeEditable(EnvelopeDto envelope) { - var status = (EnvelopeStatus)envelope.Status; + var status = GetEffectiveEnvelopeStatus(envelope); return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved; } @@ -800,7 +809,7 @@ IReadOnlyList GetStatusFilterItems(IEnumerable? source) { return (source ?? Enumerable.Empty()) - .Select(x => x.Status) + .Select(GetEffectiveEnvelopeStatus) .Distinct() .OrderBy(x => x) .Select(x => new StatusFilterItem(x, GetStatusLabel(x))) @@ -818,7 +827,7 @@ bool IsReceiverSigned(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver) { - var status = (EnvelopeStatus)envelope.Status; + var status = GetEffectiveEnvelopeStatus(envelope); if (status is EnvelopeStatus.EnvelopeCompletelySigned or EnvelopeStatus.MessageCompletionSent) return true; @@ -841,6 +850,43 @@ return false; } + bool IsReceiverRejected(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver) + { + var status = GetEffectiveEnvelopeStatus(envelope); + var histories = envelope.Histories?.Where(h => + h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected).ToList() ?? []; + + if (histories.Count == 0) + return false; + + if (histories.Any(h => h.Receiver?.Id == receiver.ReceiverId)) + return true; + + var receiverEmail = receiver.Receiver?.EmailAddress; + if (!string.IsNullOrWhiteSpace(receiverEmail) && + histories.Any(h => string.Equals(h.UserReference, receiverEmail, StringComparison.OrdinalIgnoreCase))) + return true; + + if (!string.IsNullOrWhiteSpace(receiver.Name) && + histories.Any(h => string.Equals(h.UserReference, receiver.Name, StringComparison.OrdinalIgnoreCase))) + return true; + + return status == EnvelopeStatus.EnvelopeRejected && (envelope.EnvelopeReceivers?.Count() ?? 0) == 1; + } + + EnvelopeStatus GetEffectiveEnvelopeStatus(EnvelopeDto envelope) + { + var rawStatus = envelope.Status; + + if (rawStatus is EnvelopeStatus.EnvelopeRejected or EnvelopeStatus.EnvelopeWithdrawn) + return rawStatus; + + var hasRejectedHistory = (envelope.Histories ?? Enumerable.Empty()) + .Any(h => h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected); + + return hasRejectedHistory ? EnvelopeStatus.EnvelopeRejected : rawStatus; + } + DateTime GetHistoryDate(HistoryDto history) { return history.ActionDate ?? history.AddedWhen; @@ -983,13 +1029,18 @@ // If cache is empty (LoadFromServerAsync failed), skip layout restore silently. var prefs = UserPreferencesService.GetCached(); if (prefs is not null && prefs.GridLayouts.TryGetValue(CurrentGridKey, out var json)) - e.Layout = JsonSerializer.Deserialize(json); + { + var layout = JsonSerializer.Deserialize(json); + SanitizeLayoutFilters(layout); + e.Layout = layout; + } } async Task OnGridLayoutAutoSaving(GridPersistentLayoutEventArgs e) { try { + SanitizeLayoutFilters(e.Layout); var json = JsonSerializer.Serialize(e.Layout); await UserPreferencesService.SaveGridLayoutAsync(CurrentGridKey, json); } @@ -999,5 +1050,55 @@ } } + // Keep column/order personalization, but never persist/restore data filters. + // Tab counters are based on tab datasets; persisted grid filters can hide rows and + // make the grid appear empty or incomplete. + static void SanitizeLayoutFilters(GridPersistentLayout? layout) + { + if (layout is null) + return; + + ClearWritableProperty(layout, "FilterCriteria"); + ClearWritableProperty(layout, "SearchText"); + + if (layout.Columns is not System.Collections.IEnumerable columns) + return; + + foreach (var column in columns) + { + if (column is null) + continue; + + ClearWritableProperty(column, "FilterCriteria"); + ClearWritableProperty(column, "FilterValues"); + ClearWritableProperty(column, "FilterValue"); + ClearWritableProperty(column, "SearchText"); + } + } + + static void ClearWritableProperty(object target, string propertyName) + { + var prop = target.GetType() + .GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public) + .FirstOrDefault(p => p.Name == propertyName && p.GetIndexParameters().Length == 0); + + if (prop is null || !prop.CanWrite) + return; + + var propertyType = prop.PropertyType; + object? value; + + if (!propertyType.IsValueType || Nullable.GetUnderlyingType(propertyType) is not null) + { + value = null; + } + else + { + value = Activator.CreateInstance(propertyType); + } + + prop.SetValue(target, value); + } + private sealed record StatusFilterItem(EnvelopeStatus Value, string Text); } diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverPage.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverPage.razor index 376e9803..04cae322 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverPage.razor +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverPage.razor @@ -22,6 +22,7 @@ @inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService @inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService @inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService +@inject AnnotationService AnnotationService @inject AppVersionService AppVersion @inject IMemoryCache MemoryCache @inject ILogger Logger @@ -132,32 +133,6 @@ Unterschreiben } - - @if (!(_envelopeReceiver?.Envelope?.ReadOnly ?? false)) - { - - } - -
@@ -375,57 +350,6 @@ - - -
- - -
- -
- - -
- - @if (!string.IsNullOrWhiteSpace(_shareMessage)) - { - - } -
- -
- - -
-
-
- @code { // ----- Constants ----- const string SignatureTabDraw = "draw"; @@ -545,40 +469,6 @@ return; } - _isSharing = true; - _shareMessage = null; - _shareMessageIsError = false; - await InvokeAsync(StateHasChanged); - - try - { - var shared = await AuthService.ShareEnvelopeReadOnlyAsync(EnvelopeKey!, email, _shareDateValid.Date); - if (shared) - { - _shareMessage = "Dokument wurde erfolgreich als Nur-Lesen freigegeben."; - _shareMessageIsError = false; - _shareReceiverMail = string.Empty; - _shareDateValid = DateTime.Today.AddDays(7); - } - else - { - _shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut."; - _shareMessageIsError = true; - } - } - catch (Exception ex) - { - Logger.LogWarning(ex, "Failed to share envelope in read-only mode for {EnvelopeKey}", EnvelopeKey); - _shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut."; - _shareMessageIsError = true; - } - finally - { - _isSharing = false; - await InvokeAsync(StateHasChanged); - } - } - // ----- Lifecycle ----- protected override async Task OnInitializedAsync() { @@ -627,6 +517,11 @@ _envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey); if (_envelopeReceiver is null) Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey); + else if (IsRejected(_envelopeReceiver)) + { + Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/rejected", forceLoad: true); + return; + } // Build initial report (no signature image yet) _report = BuildReport(_pdfBytes, _signatures, capturedSignature: null); @@ -672,6 +567,16 @@ } // ----- Report builder ----- + static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver) + { + var status = envelopeReceiver.Envelope?.Status; + if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) + return true; + + return envelopeReceiver.Envelope?.Histories?.Any(h => + h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true; + } + /// /// Builds an XtraReport wrapping the PDF bytes. /// If a signature is captured and there are signature fields, the signature image is @@ -905,6 +810,56 @@ forceLoad: true); } + void OpenRejectPopup() + { + _rejectValidationMessage = null; + _rejectPopupVisible = true; + } + + void CloseRejectPopup() + { + if (_isRejectSubmitting) + return; + + _rejectPopupVisible = false; + } + + async Task SubmitRejectAsync() + { + if (_isRejectSubmitting) + return; + + _isRejectSubmitting = true; + _rejectValidationMessage = null; + await InvokeAsync(StateHasChanged); + + try + { + var reason = string.IsNullOrWhiteSpace(_rejectReason) + ? null + : _rejectReason.Trim(); + + if (string.IsNullOrWhiteSpace(EnvelopeKey)) + { + _rejectValidationMessage = "Envelope-Schluessel fehlt."; + _isRejectSubmitting = false; + await InvokeAsync(StateHasChanged); + return; + } + + await AnnotationService.RejectAsync(EnvelopeKey, reason); + + Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to reject envelope {EnvelopeKey}", EnvelopeKey); + _rejectValidationMessage = "Die Ablehnung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut."; + _isRejectSubmitting = false; + await InvokeAsync(StateHasChanged); + } + } + async Task GetActiveSignatureDataUrlAsync() { if (_activeSignatureTab == SignatureTabDraw) diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverRejectedPage.razor b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverRejectedPage.razor new file mode 100644 index 00000000..6b8df10d --- /dev/null +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/ReceiverRejectedPage.razor @@ -0,0 +1,105 @@ +@page "/envelope/{EnvelopeKey}/rejected" +@rendermode InteractiveServer +@using EnvelopeGenerator.Application.Resources +@using EnvelopeGenerator.Domain.Constants +@using EnvelopeGenerator.Domain.Interfaces +@using Microsoft.Extensions.Localization +@inject NavigationManager Navigation +@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService +@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService +@inject IStringLocalizer UiLocalizer + +
+
+
+
@L("DocRejected", "Dokument abgelehnt")
+
+ +
+
+
+ + + +
+
+ +

+ @(_isReadAndConfirm + ? L("RejectionInfo1Confirmation", "Dieses Dokument wurde als nicht bestaetigt markiert.") + : L("RejectionInfo1", "Dieses Dokument wurde abgelehnt.")) +

+ +

+ @((MarkupString)_messageLine2) +

+ + +
+
+
+ +@code { + [Parameter] public string? EnvelopeKey { get; set; } + + bool _isReadAndConfirm; + string _messageLine2 = string.Empty; + + protected override async Task OnInitializedAsync() + { + if (string.IsNullOrWhiteSpace(EnvelopeKey)) + { + Navigation.NavigateTo("/"); + return; + } + + var user = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey); + if (user is null) + { + Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}"); + return; + } + + var envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey); + if (envelopeReceiver is null) + { + Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true); + return; + } + + if (!IsRejected(envelopeReceiver)) + { + Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true); + return; + } + + _isReadAndConfirm = envelopeReceiver.Envelope?.IsReadAndConfirm() ?? false; + + var senderName = envelopeReceiver.Envelope?.User is null + ? string.Empty + : $"{envelopeReceiver.Envelope.User.Prename} {envelopeReceiver.Envelope.User.Name}".Trim(); + var senderEmail = envelopeReceiver.Envelope?.User?.Email ?? string.Empty; + var envelopeTitle = envelopeReceiver.Envelope?.Title ?? string.Empty; + + var template = L("RejectionInfo2", "Das Dokument \"{2}\" von {0} ({1}) wurde abgelehnt."); + _messageLine2 = string.Format(template, senderName, senderEmail, envelopeTitle); + } + + static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver) + { + var status = envelopeReceiver.Envelope?.Status; + if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) + return true; + + return envelopeReceiver.Envelope?.Histories?.Any(h => + h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true; + } + + string L(string key, string fallback) + { + var value = UiLocalizer[key]; + return value.ResourceNotFound ? fallback : value.Value; + } +} diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/AnnotationController.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/AnnotationController.cs index 4847945b..0bf20d17 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/AnnotationController.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/AnnotationController.cs @@ -100,11 +100,12 @@ public class AnnotationController : ControllerBase /// /// Rejects the document for the current receiver. /// + /// Envelope key route parameter. /// Optional rejection reason. [Authorize(Policy = AuthPolicy.Receiver)] - [HttpPost("reject")] + [HttpPost("reject/{envelopeKey}")] [Obsolete("Use MediatR")] - public async Task Reject([FromBody] string? reason = null) + public async Task Reject([FromRoute] string envelopeKey, [FromBody] string? reason = null) { var signature = User.ReceiverSignature(); var uuid = User.EnvelopeUuid(); diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Extensions/ReceiverClaimExtensions.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Extensions/ReceiverClaimExtensions.cs index 13720e38..8882325d 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Extensions/ReceiverClaimExtensions.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Extensions/ReceiverClaimExtensions.cs @@ -67,7 +67,7 @@ public static class ReceiverClaimExtensions /// Gets the authenticated receiver email address from the claims. ///
public static string ReceiverMail(this ClaimsPrincipal user) - => user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email); + => user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email, ClaimTypes.Email); /// /// Gets the authenticated envelope identifier from the claims. @@ -120,4 +120,4 @@ public static class ReceiverClaimExtensions { return Role.Receiver.Authorized.Any(user.IsInRole); } -} \ No newline at end of file +} diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css index e5ec2274..3b88d560 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css @@ -954,6 +954,29 @@ body.resizing { cursor: not-allowed; } +.pdf-toolbar__btn--reject { + display: flex; + align-items: center; + gap: 0.375rem; + min-width: auto; + padding: 0.5rem 0.75rem; + background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.08) 100%); + border: 1px solid rgba(239, 68, 68, 0.3); + color: #b91c1c; +} + +.pdf-toolbar__btn--reject:hover:not(:disabled) { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + border-color: transparent; + color: #ffffff; + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); +} + +.pdf-toolbar__btn--reject:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .pdf-toolbar__btn-text { font-size: 0.813rem; font-weight: 600; @@ -1591,6 +1614,18 @@ body.resizing { color: #a5b4fc; } +[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(248, 113, 113, 0.4); + color: #fca5a5; +} + +[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--reject:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.35); + border-color: rgba(248, 113, 113, 0.6); + color: #ffffff; +} + /* Error container */ [data-sf-theme="blazing-dark"] .error-container .alert-danger { background: rgba(239, 68, 68, 0.12);