diff --git a/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeExpired.razor b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeExpired.razor new file mode 100644 index 00000000..f6544983 --- /dev/null +++ b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeExpired.razor @@ -0,0 +1,24 @@ +@page "/envelope-expired" +@layout EnvelopeGenerator.ReceiverUI.Web.Client.Layout.ReceiverLayout +@rendermode InteractiveAuto +@inject LocalizationService Loc + +@* + Counterpart of Views/Envelope/EnvelopeExpired.cshtml. +*@ + +@Loc["Expired"] + +
+
+
+

@Loc["Expired"]

+
+
+

@Loc["DocumentSharingPeriodExpired"]

+
+
+ +@code { + protected override async Task OnInitializedAsync() => await Loc.EnsureLoadedAsync(); +} diff --git a/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeKeyRedirect.razor b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeKeyRedirect.razor new file mode 100644 index 00000000..5251d291 --- /dev/null +++ b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeKeyRedirect.razor @@ -0,0 +1,20 @@ +@page "/envelopekey/{*Path}" +@layout EnvelopeGenerator.ReceiverUI.Web.Client.Layout.ReceiverLayout +@rendermode InteractiveAuto +@inject NavigationManager Nav + +@* + Counterpart of EnvelopeKeyRedirController: + /EnvelopeKey/{*path} ? /envelope/{path} + Preserves backwards compatibility with links generated by older e-mails. +*@ + +@code { + [Parameter] public string? Path { get; set; } + + protected override void OnInitialized() + { + var target = "/envelope/" + (Path ?? string.Empty).TrimStart('/'); + Nav.NavigateTo(target, replace: true); + } +} diff --git a/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeLockedView.razor b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeLockedView.razor new file mode 100644 index 00000000..fb3a1bd0 --- /dev/null +++ b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeLockedView.razor @@ -0,0 +1,229 @@ +@implements IDisposable +@inject ReceiverApiClient Api +@inject ReceiverAuthState State +@inject LocalizationService Loc + +@* + Counterpart of EnvelopeGenerator.Web/Views/Envelope/EnvelopeLocked.cshtml. + + Renders one of three input modes based on the current auth state: + + • Status == requires_access_code + ? AccessCode input (+ optional "2FA per SMS" toggle) + + • Status == requires_tfa, TfaType == "sms" + ? SMS code input + countdown until TfaExpiration + + • Status == requires_tfa, TfaType == "authenticator" + ? Authenticator code input + "set up authenticator" link + + On submit, the matching ReceiverApiClient method is invoked. The fresh + response replaces ReceiverAuthState.Current; the parent EnvelopePage + re-renders and either shows the document or navigates to a terminal page. +*@ + +
+ + @* — Welcome banner (custom company image is added in Phase 6) — *@ +
+ + +
+ + + + +
+

@Loc[$"LockedTitle{CodeKey}"]

+
+ + @* — "Set up authenticator" hint, shown only on the authenticator step — *@ + @if (IsAuthenticator) + { +
+

+ @Loc["AuthenticatorSetup_Prefix"] + + @Loc["AuthenticatorSetup_Link"] + + @Loc["AuthenticatorSetup_Suffix"] +

+
+ } + +
+

@Loc[$"LockedBody{CodeKey}"]

+
+ +
+
+ +
+ + + + + + login + + + @if (ShowSmsToggle) + { +
+ + +
+ } + + @if (IsSms && _smsRemaining is not null) + { + + } +
+
+
+
+ + @if (!string.IsNullOrEmpty(State.Current?.ErrorMessage)) + { + + } + +
+
+ @Loc[$"LockedFooterTitle{CodeKey}"] +

+ @Loc.Format($"LockedFooterBody{CodeKey}", + State.Current?.SenderEmail ?? string.Empty, + $"Envelope - {State.Current?.Title}", + string.Empty) +

+
+
+
+ +@code { + [Parameter] public string EnvelopeKey { get; set; } = string.Empty; + + private string Code { get; set; } = string.Empty; + private bool PreferSms { get; set; } + private bool _submitting; + private System.Threading.Timer? _smsTimer; + private string? _smsRemaining; + + // — Mode helpers ???????????????????????????????????????????????? + private bool IsAccessCodeStep => State.Current?.Status == ReceiverAuthStatus.RequiresAccessCode; + private bool IsTfa => State.Current?.Status == ReceiverAuthStatus.RequiresTfa; + private bool IsSms => IsTfa && State.Current?.TfaType == "sms"; + private bool IsAuthenticator => IsTfa && State.Current?.TfaType == "authenticator"; + + /// + /// Mirrors the legacy view's "codeKeyName" suffix used to pick the right + /// resource string ("LockedTitleAccess", "LockedTitleSms", ...). + /// + private string CodeKey => IsSms ? "Sms" : IsAuthenticator ? "Authenticator" : "Access"; + + private bool ShowSmsToggle => + IsAccessCodeStep + && (State.Current?.TfaEnabled ?? false); + + protected override async Task OnInitializedAsync() + { + await Loc.EnsureLoadedAsync(); + State.Changed += OnStateChanged; + ResetSmsTimer(); + } + + private void OnStateChanged() + { + ResetSmsTimer(); + InvokeAsync(StateHasChanged); + } + + private async Task HandleSubmit() + { + if (_submitting || string.IsNullOrWhiteSpace(Code)) + return; + + _submitting = true; + try + { + ReceiverAuthResponse? res; + if (IsAccessCodeStep) + { + res = await Api.SubmitAccessCodeAsync(EnvelopeKey, new AccessCodeRequest + { + AccessCode = Code, + PreferSms = PreferSms + }); + } + else // TFA step + { + res = await Api.SubmitTfaCodeAsync(EnvelopeKey, new TfaCodeRequest + { + Code = Code, + Type = State.Current?.TfaType ?? "authenticator" + }); + } + + Code = string.Empty; + State.Set(EnvelopeKey, res); + } + finally + { + _submitting = false; + } + } + + // — SMS countdown ??????????????????????????????????????????????? + private void ResetSmsTimer() + { + _smsTimer?.Dispose(); + _smsTimer = null; + _smsRemaining = null; + + if (!IsSms || State.Current?.TfaExpiration is not DateTime exp) + return; + + UpdateRemaining(exp); + _smsTimer = new System.Threading.Timer(_ => + { + UpdateRemaining(exp); + InvokeAsync(StateHasChanged); + }, null, 1000, 1000); + } + + private void UpdateRemaining(DateTime expiration) + { + var diff = expiration - DateTime.Now; + if (diff <= TimeSpan.Zero) + { + _smsRemaining = "00:00"; + _smsTimer?.Dispose(); + _smsTimer = null; + return; + } + _smsRemaining = $"{(int)diff.TotalMinutes:00}:{diff.Seconds:00}"; + } + + public void Dispose() + { + State.Changed -= OnStateChanged; + _smsTimer?.Dispose(); + } +} + diff --git a/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeNotFound.razor b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeNotFound.razor new file mode 100644 index 00000000..7df18dde --- /dev/null +++ b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeNotFound.razor @@ -0,0 +1,24 @@ +@page "/envelope-not-found" +@layout EnvelopeGenerator.ReceiverUI.Web.Client.Layout.ReceiverLayout +@rendermode InteractiveAuto +@inject LocalizationService Loc + +@* + Counterpart of the "EnvelopeNotFound" view (rendered by + EnvelopeGenerator.Web.Extensions.ViewExtensions.ViewEnvelopeNotFound()). +*@ + +@Loc["EnvelopeNotFoundTitle"] + +
+
+

@Loc["EnvelopeNotFoundTitle"]

+
+
+

@Loc["EnvelopeNotFoundBody"]

+
+
+ +@code { + protected override async Task OnInitializedAsync() => await Loc.EnsureLoadedAsync(); +} diff --git a/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopePage.razor b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopePage.razor new file mode 100644 index 00000000..e2eccd7f --- /dev/null +++ b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopePage.razor @@ -0,0 +1,110 @@ +@page "/envelope/{Key}" +@layout EnvelopeGenerator.ReceiverUI.Web.Client.Layout.ReceiverLayout +@rendermode InteractiveAuto +@inject ReceiverApiClient Api +@inject ReceiverAuthState State +@inject LocalizationService Loc +@inject NavigationManager Nav + +@* + Counterpart of EnvelopeGenerator.Web/Controllers/EnvelopeController.Main. + + Behavior: + 1. Calls GET /api/receiverauth/{key}/status. + 2. Routes to a sub-view based on the response Status: + - requires_access_code / requires_tfa ? EnvelopeLockedView (Phase 3) + - show_document ? ShowEnvelopeView (Phase 4) + - already_signed ? /envelope-signed + - rejected ? /envelope-rejected + - not_found ? /envelope-not-found + - expired ? /envelope-expired + - error ? inline error banner + + Sub-views are simple placeholders here; they are filled with real UI + in later phases. The routing skeleton just needs to compile and + transition correctly. +*@ + +@(Auth?.Title ?? Loc["SignDoc"]) + +@if (_loading) +{ +
+ +
+} +else if (Auth is null) +{ +
+

@Loc["UnexpectedErrorTitle"]

+
+} +else +{ + switch (Auth.Status) + { + case ReceiverAuthStatus.RequiresAccessCode: + case ReceiverAuthStatus.RequiresTfa: + + break; + + case ReceiverAuthStatus.ShowDocument: + + break; + + default: +
+

@(Auth.ErrorMessage ?? Loc["UnexpectedErrorTitle"])

+
+ break; + } +} + +@code { + [Parameter] public string Key { get; set; } = string.Empty; + + private bool _loading = true; + private ReceiverAuthResponse? Auth => State.Current; + + protected override async Task OnInitializedAsync() + { + await Loc.EnsureLoadedAsync(); + State.Changed += OnStateChanged; + } + + protected override async Task OnParametersSetAsync() + { + // Re-fetch status if the route key changed or no response loaded yet. + if (State.EnvelopeKey != Key || State.Current is null) + { + _loading = true; + var res = await Api.GetStatusAsync(Key); + State.Set(Key, res); + RedirectIfTerminal(res); + _loading = false; + } + else + { + _loading = false; + } + } + + private void RedirectIfTerminal(ReceiverAuthResponse? res) + { + if (res is null) return; + var target = res.Status switch + { + ReceiverAuthStatus.AlreadySigned => "/envelope-signed", + ReceiverAuthStatus.Rejected => "/envelope-rejected", + ReceiverAuthStatus.NotFound => "/envelope-not-found", + ReceiverAuthStatus.Expired => "/envelope-expired", + _ => null + }; + if (target is not null) + Nav.NavigateTo(target, replace: true); + } + + private void OnStateChanged() => InvokeAsync(StateHasChanged); + + public void Dispose() => State.Changed -= OnStateChanged; +} diff --git a/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeRejected.razor b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeRejected.razor new file mode 100644 index 00000000..256498b3 --- /dev/null +++ b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeRejected.razor @@ -0,0 +1,39 @@ +@page "/envelope-rejected" +@layout EnvelopeGenerator.ReceiverUI.Web.Client.Layout.ReceiverLayout +@rendermode InteractiveAuto +@inject ReceiverAuthState State +@inject LocalizationService Loc + +@* + Counterpart of Views/Envelope/EnvelopeRejected.cshtml. + Reads envelope title / sender info from the cached auth response, + which is populated by EnvelopePage before navigation occurs. +*@ + +@Loc["DocRejected"] + +
+
+
+

@Loc["RejectionInfo1"]

+
+
+ + @(Loc["RejectionInfo2"]) + + @if (State.Current is not null) + { +

+ @State.Current.Title + @if (!string.IsNullOrEmpty(State.Current.SenderEmail)) + { + @State.Current.SenderEmail + } +

+ } +
+
+ +@code { + protected override async Task OnInitializedAsync() => await Loc.EnsureLoadedAsync(); +} diff --git a/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeSigned.razor b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeSigned.razor new file mode 100644 index 00000000..676fb83f --- /dev/null +++ b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/EnvelopeSigned.razor @@ -0,0 +1,31 @@ +@page "/envelope-signed" +@layout EnvelopeGenerator.ReceiverUI.Web.Client.Layout.ReceiverLayout +@rendermode InteractiveAuto +@inject LocalizationService Loc + +@* + Counterpart of Views/Envelope/EnvelopeSigned.cshtml. + Full styling (icon + section card) is migrated in Phase 6. +*@ + +@Loc["DocumentSuccessfullySigned"] + +
+
+
+ + + + +
+

@Loc["DocumentSuccessfullySigned"]

+
+
+

@Loc["DocumentSignedConfirmationMessage"]

+
+
+ +@code { + protected override async Task OnInitializedAsync() => await Loc.EnsureLoadedAsync(); +} diff --git a/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/Error404.razor b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/Error404.razor new file mode 100644 index 00000000..745a5473 --- /dev/null +++ b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/Error404.razor @@ -0,0 +1,88 @@ +@page "/error404" +@layout EnvelopeGenerator.ReceiverUI.Web.Client.Layout.ReceiverLayout +@rendermode InteractiveAuto +@inject LocalizationService Loc +@inject NavigationManager Nav +@inject IJSRuntime JS + +@* + Counterpart of HomeController.Error404 ? Views/Shared/_Error.cshtml. + + The legacy view fully replaces the document with a black-space themed + layout. In Blazor we keep the receiver layout intact (so the user can + still reach the language switcher) and only scope error-space.css to + this page via . JS animation (visor + cord) is initialized + once after the canvas elements are in the DOM. +*@ + + + + + +404 + +
+
+
+
+
+ +
+
+
+
+
+ +
+
404
+
@Loc["PageNotFound"]
+
@Loc["PageNotFoundDescription"]
+ @Loc["Home"] +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+ +
+ +
+
+
+
+
+ +@code { + protected override async Task OnInitializedAsync() => await Loc.EnsureLoadedAsync(); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) return; + try + { + const string js = "if (!window.__errSpaceLoaded) { window.__errSpaceLoaded = true; var s = document.createElement('script'); s.src = '/js/error-space.js'; document.body.appendChild(s); }"; + await JS.InvokeVoidAsync("eval", js); + } + catch + { + // Animation is purely decorative — failing to load it is fine. + } + } +} diff --git a/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/Home.razor b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/Home.razor new file mode 100644 index 00000000..b54bfc45 --- /dev/null +++ b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/Home.razor @@ -0,0 +1,42 @@ +@page "/" +@layout EnvelopeGenerator.ReceiverUI.Web.Client.Layout.ReceiverLayout +@rendermode InteractiveAuto +@inject LocalizationService Loc + +@Loc["Home"] + +@* + Counterpart of EnvelopeGenerator.Web/Views/Home/Main.cshtml. + + The legacy view animates the description with typed.js. The Blazor + version omits the typewriter effect because it adds another JS + dependency for marginal value; the static description is shown + instead. Custom company / app logos are loaded from /img/ if + available, otherwise gracefully hidden via onerror. +*@ + +
+
+ +
+ +
+
+
+ +
+
+ +@code { + protected override async Task OnInitializedAsync() + { + await Loc.EnsureLoadedAsync(); + } +} diff --git a/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/ShowEnvelopeView.razor b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/ShowEnvelopeView.razor new file mode 100644 index 00000000..d567d3c0 --- /dev/null +++ b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/ShowEnvelopeView.razor @@ -0,0 +1,461 @@ +@implements IDisposable +@inject ReceiverApiClient Api +@inject ReceiverAuthState State +@inject LocalizationService Loc +@inject NavigationManager Nav +@inject ILogger Logger + +@* + Counterpart of EnvelopeGenerator.Web/Views/Envelope/ShowEnvelope.cshtml. + + Sign flow (Phase 5): + • Document is rendered by DxPdfViewer for review. + • A side panel lists every signature placeholder the receiver has + to sign (GET /api/annotation/elements). Each entry opens + SignaturePadDialog to capture the signature image (+ optional + position / city) and stores the result locally. + • Complete validates that every placeholder is signed, then submits + the BlazorSignaturePayload (POST /api/annotation/blazor) and + navigates to /envelope-signed. + • Reset clears every captured signature locally (no server call). + • Reject and read-only share popups behave as in Phase 4. + + Why a side-panel signing UX instead of overlaying widgets on the PDF? + • DevExpress DxPdfViewer does not expose a public surface for + programmatic widget annotations the way PSPDFKit did. + • A side panel is fully keyboard / screen-reader accessible, works + identically on mobile, and avoids fragile coordinate math against + DevExpress' internal DOM. The visual position on the PDF is still + communicated via the "Page P" badge per entry. +*@ + +
+ + @* — Top toolbar / action buttons (desktop) — *@ + @if (!IsReadOnly) + { +
+ + + +
+ } + + @* — Envelope info card — *@ +
+
+
+ + @if (!IsReadOnly) + { +
+
+ + @SignedCount/@_elements.Count + @Loc["Signatures"] + +
+ } +
+
+

@(State.Current?.Title)

+ @if (!string.IsNullOrEmpty(State.Current?.Message)) + { +
@State.Current.Message
+ } + @if (!string.IsNullOrEmpty(State.Current?.SenderEmail)) + { +

+ + + @State.Current.SenderEmail + + +

+ } +
+
+
+ +
+ + @* — PDF viewer — *@ +
+
+ @if (_loadingDoc) + { +
+ +
+ } + else if (_documentBytes is { Length: > 0 }) + { + + } + else + { + + } +
+
+ + @* — Side panel: signature placeholders to sign — *@ + @if (!IsReadOnly) + { + + } +
+ + @* — Signature pad dialog — *@ + + + @* — Confirm-complete popup — *@ + + +

@Loc["ConfirmSigningQ"]

+
+ + + + +
+ + @* — Read-only share popup — *@ + @if (!IsReadOnly) + { + + + + + + + + + + + @if (!string.IsNullOrEmpty(_shareError)) + { +
@_shareError
+ } +
+ + + +
+ + @* — Reject popup — *@ + + +

@Loc["RejectionReasonQ"]

+ +
+ + + + +
+ } + + @if (!string.IsNullOrEmpty(_globalError)) + { + + } +
+ +@code { + [Parameter] public string EnvelopeKey { get; set; } = string.Empty; + + private byte[]? _documentBytes; + private bool _loadingDoc = true; + private bool _loadingElements = true; + private bool _busy; + + private List _elements = new(); + private readonly Dictionary _captured = new(); + + private SignaturePadDialog? _padDialog; + private bool _confirmCompleteVisible; + private string? _globalError; + + private bool _shareVisible; + private string _shareEmail = string.Empty; + private DateTime _shareValidUntil = DateTime.Today.AddDays(7); + private string? _shareError; + + private bool _rejectVisible; + private string _rejectReason = string.Empty; + + private bool IsReadOnly => State.Current?.ReadOnly ?? false; + private int SignedCount => _captured.Count; + + protected override async Task OnInitializedAsync() + { + await Loc.EnsureLoadedAsync(); + State.Changed += OnStateChanged; + } + + protected override async Task OnParametersSetAsync() + { + if (_documentBytes is null && !string.IsNullOrEmpty(EnvelopeKey)) + { + await LoadDocumentAsync(); + if (!IsReadOnly) + await LoadElementsAsync(); + } + } + + private async Task LoadDocumentAsync() + { + _loadingDoc = true; + try + { + _documentBytes = await Api.GetDocumentAsync(EnvelopeKey); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load document for key {Key}", EnvelopeKey); + } + finally + { + _loadingDoc = false; + } + } + + private async Task LoadElementsAsync() + { + _loadingElements = true; + try + { + _elements = await Api.GetSignatureElementsAsync(); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load signature elements."); + _elements = new(); + } + finally + { + _loadingElements = false; + } + } + + private void OnStateChanged() => InvokeAsync(StateHasChanged); + + // — Signature pad ????????????????????????????????????????????????? + + private async Task OpenPadAsync(SignatureElementDto el) + { + if (_padDialog is null) return; + _captured.TryGetValue(el.Id, out var existing); + await _padDialog.ShowAsync(el.Id, existing?.Position, existing?.City); + } + + private void OnSignatureConfirmed(BlazorSignatureEntry entry) + { + _captured[entry.ElementId] = entry; + StateHasChanged(); + } + + // — Toolbar actions ???????????????????????????????????????????????? + + private Task OnCompleteClick() + { + _globalError = null; + var missing = _elements.Where(e => !_captured.ContainsKey(e.Id)).ToList(); + if (missing.Count > 0) + { + _globalError = Loc.Format("MissingSignaturesFmt", missing.Count); + return Task.CompletedTask; + } + _confirmCompleteVisible = true; + return Task.CompletedTask; + } + + private async Task OnCompleteSubmit() + { + if (_busy) return; + _busy = true; + try + { + var payload = new BlazorSignaturePayload + { + Signatures = _captured.Values.ToList() + }; + var status = await Api.SignBlazorAsync(payload); + _confirmCompleteVisible = false; + if ((int)status >= 200 && (int)status < 300) + { + Nav.NavigateTo("/envelope-signed", replace: true); + } + else + { + _globalError = $"{Loc["UnexpectedErrorTitle"]} ({(int)status})"; + } + } + catch (Exception ex) + { + Logger.LogError(ex, "Sign submit failed."); + _globalError = Loc["UnexpectedErrorTitle"]; + } + finally + { + _busy = false; + } + } + + private Task OnRejectClick() + { + _rejectReason = string.Empty; + _rejectVisible = true; + return Task.CompletedTask; + } + + private async Task OnRejectSubmit() + { + if (_busy) return; + _busy = true; + try + { + var ok = await Api.RejectAsync(_rejectReason ?? string.Empty); + _rejectVisible = false; + if (ok) + Nav.NavigateTo("/envelope-rejected", replace: true); + } + finally + { + _busy = false; + } + } + + private void OnResetClick() + { + _captured.Clear(); + _globalError = null; + } + + // — Share read-only ????????????????????????????????????????????????? + + private async Task OnShareSubmit() + { + _shareError = null; + if (string.IsNullOrWhiteSpace(_shareEmail) || + !System.Text.RegularExpressions.Regex.IsMatch(_shareEmail, @"^\S+@\S+\.\S+$")) + { + _shareError = Loc["ShrEnvInvalidEmailText"]; + return; + } + if (_shareValidUntil < DateTime.Today.AddDays(1)) + { + _shareError = Loc["ShrEnvInvalidDateText"]; + return; + } + + _busy = true; + try + { + var ok = await Api.ShareReadOnlyAsync(new ReadOnlyShareRequest + { + ReceiverMail = _shareEmail, + DateValid = _shareValidUntil + }); + if (ok) + { + _shareVisible = false; + _shareEmail = string.Empty; + _shareValidUntil = DateTime.Today.AddDays(7); + } + else + { + _shareError = Loc["ShrEnvOperationFailedText"]; + } + } + catch + { + _shareError = Loc["UnexpectedErrorTitle"]; + } + finally + { + _busy = false; + } + } + + public void Dispose() => State.Changed -= OnStateChanged; +} + + diff --git a/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/SignaturePadDialog.razor b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/SignaturePadDialog.razor new file mode 100644 index 00000000..b35ad7a0 --- /dev/null +++ b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/SignaturePadDialog.razor @@ -0,0 +1,150 @@ +@implements IAsyncDisposable +@inject IJSRuntime JS +@inject LocalizationService Loc + +@* + Modal dialog that captures one signature for a single placeholder. + + The user is asked to: + • draw their signature (mouse / touch), + • optionally fill in "position" (job title) and "city", + • confirm — which produces a BlazorSignatureEntry and closes the dialog. + + The drawing surface is a plain HTML5 canvas wired up by signature-pad.js + (loaded once in App.razor). All JS interop is encapsulated here so the + rest of the receiver UI is free of DOM concerns. +*@ + + + +
+ + +
+ +
+ +
+ + + + + + + + +
+ + @if (!string.IsNullOrEmpty(_error)) + { +
@_error
+ } +
+
+ + + + +
+ +@code { + /// Fired when the user confirms a valid signature. + [Parameter] public EventCallback Confirmed { get; set; } + + private readonly string _canvasId = $"sigpad_{Guid.NewGuid():N}"; + private bool _visible; + private bool _attached; + private int _elementId; + private string _position = string.Empty; + private string _city = string.Empty; + private string? _error; + + /// Opens the dialog and binds JS interop on the canvas. + public async Task ShowAsync(int elementId, string? defaultPosition = null, string? defaultCity = null) + { + _elementId = elementId; + _position = defaultPosition ?? string.Empty; + _city = defaultCity ?? string.Empty; + _error = null; + _visible = true; + StateHasChanged(); + + // The canvas only exists after the popup is rendered. Wait one + // render cycle, then attach the pad. + await Task.Yield(); + try + { + _attached = await JS.InvokeAsync("signaturePad.attach", _canvasId); + } + catch (JSException ex) + { + _error = ex.Message; + _attached = false; + } + } + + public void Hide() + { + _visible = false; + } + + private async Task ClearAsync() + { + if (_attached) + await JS.InvokeVoidAsync("signaturePad.clear", _canvasId); + } + + private async Task ConfirmAsync() + { + if (!_attached) + { + _error = Loc["SignaturePadNotReady"]; + return; + } + + var dataUrl = await JS.InvokeAsync("signaturePad.toDataUrl", _canvasId); + if (string.IsNullOrEmpty(dataUrl)) + { + _error = Loc["SignatureRequired"]; + return; + } + + await Confirmed.InvokeAsync(new BlazorSignatureEntry + { + ElementId = _elementId, + SignatureDataUrl = dataUrl, + Position = string.IsNullOrWhiteSpace(_position) ? null : _position.Trim(), + City = string.IsNullOrWhiteSpace(_city) ? null : _city.Trim(), + SignedAt = DateTime.Now, + }); + + await OnClosed(); + _visible = false; + } + + private async Task OnClosed() + { + if (_attached) + { + try { await JS.InvokeVoidAsync("signaturePad.detach", _canvasId); } catch { /* ignore */ } + _attached = false; + } + } + + public async ValueTask DisposeAsync() => await OnClosed(); +} diff --git a/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/TfaRegistration.razor b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/TfaRegistration.razor new file mode 100644 index 00000000..8a6f56b9 --- /dev/null +++ b/receiverUI/EnvelopeGenerator.ReceiverUI.Web/EnvelopeGenerator.ReceiverUI.Web.Client/Pages/Receiver/TfaRegistration.razor @@ -0,0 +1,158 @@ +@page "/tfa/{Key}" +@layout EnvelopeGenerator.ReceiverUI.Web.Client.Layout.ReceiverLayout +@rendermode InteractiveAuto +@inject ReceiverApiClient Api +@inject LocalizationService Loc +@inject NavigationManager Nav + +@* + Counterpart of TFARegController.Reg ? Views/TFAReg/Reg.cshtml. + + The legacy view uses Bootstrap's collapse-based accordion to walk the + receiver through 3 steps: + 1. Install an authenticator app + 2. Scan the QR code + 3. Verify the generated 6-digit code + + The Blazor port keeps the exact same step structure but uses + DxAccordion so the visual / keyboard behavior matches the rest of + the receiver UI. The TOTP QR and registration deadline are fetched + from GET /api/tfa/{key} on first render. +*@ + +@Loc["TfaRegistration"] + +
+
+
+ + + + +
+

2-Factor Authentication (2FA)

+

@Loc["Registration"]

+
+ + @if (_loading) + { +
+ +
+ } + else if (_error is not null) + { + +
+ +
+ } + else if (_data is not null) + { +
+

+ @if (_data.TfaRegDeadline is DateTime dl) + { + @Loc.Format("PageVisibleUntil", dl.ToString("d. MMM, HH:mm", new System.Globalization.CultureInfo("de-DE"))) + } +

+
+ +
+ + + + +

@Loc["Download2faAppInstruction"]

+

@Loc["Recommended2faApplications"]

+ +
+
+ + + +
+ @if (!string.IsNullOrEmpty(_data.TotpQR64)) + { + TOTP QR + } +
+

@Loc["ScanQrCodeInstruction"]

+
+
+ + + +

+ @Loc["VerifyCodeInstructionMain"] + @Loc["VerifyCodeInstructionSubmit"]. +

+
+ +
+
+
+
+
+
+ } +
+ +@code { + [Parameter] public string Key { get; set; } = string.Empty; + + private TfaRegistrationResponse? _data; + private string? _error; + private bool _loading = true; + + protected override async Task OnInitializedAsync() + { + await Loc.EnsureLoadedAsync(); + + _loading = true; + try + { + var (data, status) = await Api.GetTfaRegistrationAsync(Key); + if ((int)status >= 200 && (int)status < 300 && data is not null) + { + _data = data; + } + else if ((int)status == 410) + { + _error = Loc["TfaRegDeadlineExpired"]; + } + else if ((int)status == 401) + { + _error = Loc["UnauthorizedTfaReg"]; + } + else + { + _error = Loc["UnexpectedErrorTitle"]; + } + } + finally + { + _loading = false; + } + } +}