Add main receiver-side Blazor pages for e-sign portal
Port legacy MVC views to Blazor components for the receiver UI, including Home, envelope routing, locked/auth flows, document signing, signature pad dialog, and all terminal/confirmation pages (signed, rejected, expired, not found, 404). Implements DevExpress Blazor controls for UI consistency and accessibility. Signature flow uses a side-panel UX for capturing signatures. Includes localization, robust error handling, state management, and JS interop for signature capture and 404 animation. Legacy routes are redirected for backward compatibility.
This commit is contained in:
@@ -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.
|
||||
*@
|
||||
|
||||
<PageTitle>@Loc["Expired"]</PageTitle>
|
||||
|
||||
<div class="page container p-5">
|
||||
<header class="text-center">
|
||||
<div class="icon expired"></div>
|
||||
<h1>@Loc["Expired"]</h1>
|
||||
</header>
|
||||
<section class="text-center">
|
||||
<p>@Loc["DocumentSharingPeriodExpired"]</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync() => await Loc.EnsureLoadedAsync();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*@
|
||||
|
||||
<div class="page container py-4 px-4">
|
||||
|
||||
@* — Welcome banner (custom company image is added in Phase 6) — *@
|
||||
<header class="text-center">
|
||||
<div class="header-1 alert alert-secondary" role="alert">
|
||||
<h3 class="text">@Loc["WelcomeToTheESignPortal"]</h3>
|
||||
</div>
|
||||
|
||||
<div class="icon locked @(IsTfa ? "tfa" : "") mt-4 mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" fill="currentColor"
|
||||
class="bi bi-shield-lock" viewBox="0 0 16 16">
|
||||
<path d="M5.338 1.59a61 61 0 0 0-2.837.856.48.48 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.7 10.7 0 0 0 2.287 2.233c.346.244.652.42.893.533q.18.085.293.118a1 1 0 0 0 .101.025 1 1 0 0 0 .1-.025q.114-.034.294-.118c.24-.113.547-.29.893-.533a10.7 10.7 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.8 11.8 0 0 1-2.517 2.453 7 7 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7 7 0 0 1-1.048-.625 11.8 11.8 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 63 63 0 0 1 5.072.56" />
|
||||
<path d="M9.5 6.5a1.5 1.5 0 0 1-1 1.415l.385 1.99a.5.5 0 0 1-.491.595h-.788a.5.5 0 0 1-.49-.595l.384-1.99a1.5 1.5 0 1 1 2-1.415" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1>@Loc[$"LockedTitle{CodeKey}"]</h1>
|
||||
</header>
|
||||
|
||||
@* — "Set up authenticator" hint, shown only on the authenticator step — *@
|
||||
@if (IsAuthenticator)
|
||||
{
|
||||
<section class="text-center">
|
||||
<p class="m-0 p-0">
|
||||
@Loc["AuthenticatorSetup_Prefix"]
|
||||
<a class="icon-link m-0 p-0" href="@($"/tfa/{EnvelopeKey}")" target="_blank" style="text-decoration:none">
|
||||
@Loc["AuthenticatorSetup_Link"] <i class="bi bi-box-arrow-up-right"></i>
|
||||
</a>
|
||||
@Loc["AuthenticatorSetup_Suffix"]
|
||||
</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="text-center">
|
||||
<p>@Loc[$"LockedBody{CodeKey}"]</p>
|
||||
</section>
|
||||
|
||||
<div class="row m-0 p-0">
|
||||
<div class="access-code-panel justify-content-center align-items-center p-0 m-0">
|
||||
<EditForm Model="this" OnValidSubmit="HandleSubmit" Context="editContext"
|
||||
id="form-access-code" class="form form-floating mb-0">
|
||||
<div class="form-floating access-code-form-floating">
|
||||
|
||||
<input id="access_code" type="password" class="form-control"
|
||||
placeholder="@Loc[$"LockedCodeLabel{CodeKey}"]"
|
||||
@bind="Code" @bind:event="oninput"
|
||||
disabled="@_submitting" required />
|
||||
<label for="access_code">@Loc[$"LockedCodeLabel{CodeKey}"]</label>
|
||||
|
||||
<DxButton SubmitFormOnClick="true" Enabled="@(!_submitting)"
|
||||
RenderStyle="ButtonRenderStyle.Primary"
|
||||
CssClass="btn btn-primary">
|
||||
<span class="material-symbols-outlined">login</span>
|
||||
</DxButton>
|
||||
|
||||
@if (ShowSmsToggle)
|
||||
{
|
||||
<div class="form-check form-switch tfa-sms">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="flexSwitchCheckChecked"
|
||||
@bind="PreferSms"
|
||||
disabled="@(!State.Current!.HasPhoneNumber)" />
|
||||
<label class="form-check-label" for="flexSwitchCheckChecked">2FA per SMS</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (IsSms && _smsRemaining is not null)
|
||||
{
|
||||
<div id="sms-timer" class="alert alert-primary" role="alert">@_smsRemaining</div>
|
||||
}
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(State.Current?.ErrorMessage))
|
||||
{
|
||||
<div id="access-code-error-message" class="alert alert-danger row" role="alert">
|
||||
@State.Current.ErrorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="no-receiver-explanation text-center">
|
||||
<details>
|
||||
<summary>@Loc[$"LockedFooterTitle{CodeKey}"]</summary>
|
||||
<p>
|
||||
@Loc.Format($"LockedFooterBody{CodeKey}",
|
||||
State.Current?.SenderEmail ?? string.Empty,
|
||||
$"Envelope - {State.Current?.Title}",
|
||||
string.Empty)
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@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";
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the legacy view's "codeKeyName" suffix used to pick the right
|
||||
/// resource string ("LockedTitleAccess", "LockedTitleSms", ...).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()).
|
||||
*@
|
||||
|
||||
<PageTitle>@Loc["EnvelopeNotFoundTitle"]</PageTitle>
|
||||
|
||||
<div class="page container p-5">
|
||||
<header class="text-center">
|
||||
<h1>@Loc["EnvelopeNotFoundTitle"]</h1>
|
||||
</header>
|
||||
<section class="text-center">
|
||||
<p>@Loc["EnvelopeNotFoundBody"]</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync() => await Loc.EnsureLoadedAsync();
|
||||
}
|
||||
@@ -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.
|
||||
*@
|
||||
|
||||
<PageTitle>@(Auth?.Title ?? Loc["SignDoc"])</PageTitle>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<div class="page container p-5 text-center">
|
||||
<DxLoadingPanel Visible="true" IsContentBlocked="false" ApplyBackgroundShading="false" />
|
||||
</div>
|
||||
}
|
||||
else if (Auth is null)
|
||||
{
|
||||
<div class="page container p-5 text-center">
|
||||
<p class="alert alert-danger">@Loc["UnexpectedErrorTitle"]</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (Auth.Status)
|
||||
{
|
||||
case ReceiverAuthStatus.RequiresAccessCode:
|
||||
case ReceiverAuthStatus.RequiresTfa:
|
||||
<EnvelopeLockedView EnvelopeKey="@Key" />
|
||||
break;
|
||||
|
||||
case ReceiverAuthStatus.ShowDocument:
|
||||
<ShowEnvelopeView EnvelopeKey="@Key" />
|
||||
break;
|
||||
|
||||
default:
|
||||
<div class="page container p-5 text-center">
|
||||
<p class="alert alert-warning">@(Auth.ErrorMessage ?? Loc["UnexpectedErrorTitle"])</p>
|
||||
</div>
|
||||
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;
|
||||
}
|
||||
@@ -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.
|
||||
*@
|
||||
|
||||
<PageTitle>@Loc["DocRejected"]</PageTitle>
|
||||
|
||||
<div class="page container p-5">
|
||||
<header class="text-center">
|
||||
<div class="icon rejected"></div>
|
||||
<h1>@Loc["RejectionInfo1"]</h1>
|
||||
</header>
|
||||
<section class="text-center">
|
||||
<small class="text-body-secondary">
|
||||
@(Loc["RejectionInfo2"])
|
||||
</small>
|
||||
@if (State.Current is not null)
|
||||
{
|
||||
<p class="mt-3">
|
||||
<strong>@State.Current.Title</strong>
|
||||
@if (!string.IsNullOrEmpty(State.Current.SenderEmail))
|
||||
{
|
||||
<span> — <a href="mailto:@State.Current.SenderEmail">@State.Current.SenderEmail</a></span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync() => await Loc.EnsureLoadedAsync();
|
||||
}
|
||||
@@ -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.
|
||||
*@
|
||||
|
||||
<PageTitle>@Loc["DocumentSuccessfullySigned"]</PageTitle>
|
||||
|
||||
<div class="page container p-5">
|
||||
<header class="text-center">
|
||||
<div class="icon signed">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" fill="currentColor"
|
||||
class="bi bi-check2-circle" viewBox="0 0 16 16">
|
||||
<path d="M2.5 8a5.5 5.5 0 0 1 8.25-4.764.5.5 0 0 0 .5-.866A6.5 6.5 0 1 0 14.5 8a.5.5 0 0 0-1 0 5.5 5.5 0 1 1-11 0z" />
|
||||
<path d="M15.354 3.354a.5.5 0 0 0-.708-.708L8 9.293 5.354 6.646a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1>@Loc["DocumentSuccessfullySigned"]</h1>
|
||||
</header>
|
||||
<section class="text-center">
|
||||
<p>@Loc["DocumentSignedConfirmationMessage"]</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync() => await Loc.EnsureLoadedAsync();
|
||||
}
|
||||
@@ -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 <HeadContent>. JS animation (visor + cord) is initialized
|
||||
once after the canvas elements are in the DOM.
|
||||
*@
|
||||
|
||||
<HeadContent>
|
||||
<link rel="stylesheet" href="/css/error-space.css" />
|
||||
</HeadContent>
|
||||
|
||||
<PageTitle>404</PageTitle>
|
||||
|
||||
<div class="error-space-stage" style="position:relative; height:80vh; overflow:hidden">
|
||||
<div class="moon"></div>
|
||||
<div class="moon__crater moon__crater1"></div>
|
||||
<div class="moon__crater moon__crater2"></div>
|
||||
<div class="moon__crater moon__crater3"></div>
|
||||
|
||||
<div class="star star1"></div>
|
||||
<div class="star star2"></div>
|
||||
<div class="star star3"></div>
|
||||
<div class="star star4"></div>
|
||||
<div class="star star5"></div>
|
||||
|
||||
<div class="error">
|
||||
<div class="error__title">404</div>
|
||||
<div class="error__subtitle">@Loc["PageNotFound"]</div>
|
||||
<div class="error__description">@Loc["PageNotFoundDescription"]</div>
|
||||
<a href="/" class="error__button error__button--active">@Loc["Home"]</a>
|
||||
</div>
|
||||
|
||||
<div class="astronaut">
|
||||
<div class="astronaut__backpack"></div>
|
||||
<div class="astronaut__body"></div>
|
||||
<div class="astronaut__body__chest"></div>
|
||||
<div class="astronaut__arm-left1"></div>
|
||||
<div class="astronaut__arm-left2"></div>
|
||||
<div class="astronaut__arm-right1"></div>
|
||||
<div class="astronaut__arm-right2"></div>
|
||||
<div class="astronaut__arm-thumb-left"></div>
|
||||
<div class="astronaut__arm-thumb-right"></div>
|
||||
<div class="astronaut__leg-left"></div>
|
||||
<div class="astronaut__leg-right"></div>
|
||||
<div class="astronaut__foot-left"></div>
|
||||
<div class="astronaut__foot-right"></div>
|
||||
<div class="astronaut__wrist-left"></div>
|
||||
<div class="astronaut__wrist-right"></div>
|
||||
|
||||
<div class="astronaut__cord">
|
||||
<canvas id="cord" height="500" width="500"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="astronaut__head">
|
||||
<canvas id="visor" width="60" height="60"></canvas>
|
||||
<div class="astronaut__head-visor-flare1"></div>
|
||||
<div class="astronaut__head-visor-flare2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@page "/"
|
||||
@layout EnvelopeGenerator.ReceiverUI.Web.Client.Layout.ReceiverLayout
|
||||
@rendermode InteractiveAuto
|
||||
@inject LocalizationService Loc
|
||||
|
||||
<PageTitle>@Loc["Home"]</PageTitle>
|
||||
|
||||
@*
|
||||
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.
|
||||
*@
|
||||
|
||||
<div class="page container py-4 px-4">
|
||||
<header class="text-center">
|
||||
<div class="header-1 alert alert-secondary" role="alert">
|
||||
<h3 class="text">@Loc["WelcomeToTheESignPortal"]</h3>
|
||||
<img class="dd-locked-logo" src="/img/company.svg"
|
||||
onerror="this.style.display='none'" alt="" />
|
||||
</div>
|
||||
<div class="icon mt-4 mb-1">
|
||||
<img class="signFlow-logo" src="/img/sign_flow_horizontal.svg"
|
||||
onerror="this.style.display='none'" alt="signFLOW" />
|
||||
</div>
|
||||
</header>
|
||||
<section class="text-center">
|
||||
<div class="alert alert-light" role="alert">
|
||||
<p class="home-description">@Loc["HomePageDescription"]</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Loc.EnsureLoadedAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
@implements IDisposable
|
||||
@inject ReceiverApiClient Api
|
||||
@inject ReceiverAuthState State
|
||||
@inject LocalizationService Loc
|
||||
@inject NavigationManager Nav
|
||||
@inject ILogger<ShowEnvelopeView> 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.
|
||||
*@
|
||||
|
||||
<div class="envelope-view">
|
||||
|
||||
@* — Top toolbar / action buttons (desktop) — *@
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<div id="flex-action-panel" class="btn-group btn_group position-fixed bottom-0 end-0 d-flex align-items-center"
|
||||
role="group">
|
||||
<DxButton CssClass="btn_complete btn btn-success btn-desktop"
|
||||
Text="@Loc["Complete"]"
|
||||
Click="OnCompleteClick"
|
||||
Enabled="@(!_busy)" />
|
||||
<DxButton CssClass="btn_reject btn btn-danger btn-desktop"
|
||||
Text="@Loc["Reject"]"
|
||||
Click="OnRejectClick"
|
||||
Enabled="@(!_busy)" />
|
||||
<DxButton CssClass="btn_refresh btn btn-secondary btn-desktop"
|
||||
IconCssClass="bi bi-arrow-counterclockwise"
|
||||
Text="@Loc["Reset"]"
|
||||
Click="OnResetClick"
|
||||
Enabled="@(!_busy && _captured.Count > 0)" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@* — Envelope info card — *@
|
||||
<div class="dd-cards-container">
|
||||
<div class="dd-card">
|
||||
<div class="dd-card-preview">
|
||||
<img src="/img/sign_flow_horizontal.svg" class="app-logo"
|
||||
onerror="this.style.display='none'" alt="signFLOW" />
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<div class="progress-container">
|
||||
<div id="signed-count-bar" class="progress"></div>
|
||||
<span class="progress-text">
|
||||
<span id="signed-count">@SignedCount</span>/<span id="signature-count">@_elements.Count</span>
|
||||
@Loc["Signatures"]
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="dd-card-info">
|
||||
<h2>@(State.Current?.Title)</h2>
|
||||
@if (!string.IsNullOrEmpty(State.Current?.Message))
|
||||
{
|
||||
<div class="markdown">@State.Current.Message</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(State.Current?.SenderEmail))
|
||||
{
|
||||
<p>
|
||||
<small class="text-body-secondary">
|
||||
<a class="mail-link" href="mailto:@State.Current.SenderEmail">
|
||||
@State.Current.SenderEmail
|
||||
</a>
|
||||
</small>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 m-0 envelope-content">
|
||||
|
||||
@* — PDF viewer — *@
|
||||
<div class="col-12 col-lg-8 p-0">
|
||||
<div id="pdfviewer-host" style="min-height:60vh; height:70vh;">
|
||||
@if (_loadingDoc)
|
||||
{
|
||||
<div class="d-flex align-items-center justify-content-center h-100">
|
||||
<DxLoadingPanel Visible="true" IsContentBlocked="false" ApplyBackgroundShading="false" />
|
||||
</div>
|
||||
}
|
||||
else if (_documentBytes is { Length: > 0 })
|
||||
{
|
||||
<DxPdfViewer DocumentContent="_documentBytes"
|
||||
CssClass="h-100 w-100"
|
||||
ZoomLevel="1" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning m-3" role="alert">
|
||||
@Loc["DocumentNotFound"]
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* — Side panel: signature placeholders to sign — *@
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<aside class="col-12 col-lg-4 p-3">
|
||||
<h5>@Loc["SignaturePlaceholders"]</h5>
|
||||
@if (_loadingElements)
|
||||
{
|
||||
<DxLoadingPanel Visible="true" IsContentBlocked="false" ApplyBackgroundShading="false" />
|
||||
}
|
||||
else if (_elements.Count == 0)
|
||||
{
|
||||
<p class="text-body-secondary">@Loc["NoSignatureRequired"]</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ol class="signature-list list-unstyled">
|
||||
@foreach (var (el, idx) in _elements.Select((e, i) => (e, i + 1)))
|
||||
{
|
||||
var signed = _captured.ContainsKey(el.Id);
|
||||
<li class="signature-item d-flex align-items-center justify-content-between gap-2 p-2 border rounded mb-2 @(signed ? "bg-success-subtle" : "")">
|
||||
<div>
|
||||
<strong>#@idx</strong>
|
||||
<span class="badge bg-secondary ms-1">@Loc["Page"] @el.Page</span>
|
||||
@if (!string.IsNullOrEmpty(el.Tooltip))
|
||||
{
|
||||
<div class="small text-body-secondary">@el.Tooltip</div>
|
||||
}
|
||||
@if (signed)
|
||||
{
|
||||
<div class="small text-success">
|
||||
<i class="bi bi-check2-circle"></i> @Loc["Signed"]
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<DxButton Text="@(signed ? Loc["Change"] : Loc["Sign"])"
|
||||
RenderStyle="@(signed ? ButtonRenderStyle.Secondary : ButtonRenderStyle.Primary)"
|
||||
IconCssClass="bi bi-pen"
|
||||
Click="@(() => OpenPadAsync(el))" />
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
</aside>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* — Signature pad dialog — *@
|
||||
<SignaturePadDialog @ref="_padDialog" Confirmed="OnSignatureConfirmed" />
|
||||
|
||||
@* — Confirm-complete popup — *@
|
||||
<DxPopup @bind-Visible="_confirmCompleteVisible"
|
||||
HeaderText="@Loc["ConfirmSigning"]"
|
||||
ShowCloseButton="true"
|
||||
Width="32rem">
|
||||
<BodyContentTemplate Context="confirmCtx">
|
||||
<p>@Loc["ConfirmSigningQ"]</p>
|
||||
</BodyContentTemplate>
|
||||
<FooterContentTemplate Context="confirmFootCtx">
|
||||
<DxButton Text="@Loc["Confirm"]"
|
||||
RenderStyle="ButtonRenderStyle.Primary"
|
||||
Click="OnCompleteSubmit" Enabled="@(!_busy)" />
|
||||
<DxButton Text="@Loc["Cancel"]"
|
||||
RenderStyle="ButtonRenderStyle.Secondary"
|
||||
Click="@(() => _confirmCompleteVisible = false)" />
|
||||
</FooterContentTemplate>
|
||||
</DxPopup>
|
||||
|
||||
@* — Read-only share popup — *@
|
||||
@if (!IsReadOnly)
|
||||
{
|
||||
<DxPopup @bind-Visible="_shareVisible"
|
||||
HeaderText="@Loc["EnterRecipientToShareDocument"]"
|
||||
ShowCloseButton="true"
|
||||
Width="32rem">
|
||||
<BodyContentTemplate Context="shareCtx">
|
||||
<DxFormLayout>
|
||||
<DxFormLayoutItem Caption="@Loc["Email"]">
|
||||
<DxTextBox @bind-Text="_shareEmail" NullText="user@mail.com" />
|
||||
</DxFormLayoutItem>
|
||||
<DxFormLayoutItem Caption="@Loc["ValidUntil"]">
|
||||
<DxDateEdit @bind-Date="_shareValidUntil"
|
||||
MinDate="DateTime.Today.AddDays(1)"
|
||||
MaxDate="DateTime.Today.AddDays(90)" />
|
||||
</DxFormLayoutItem>
|
||||
</DxFormLayout>
|
||||
@if (!string.IsNullOrEmpty(_shareError))
|
||||
{
|
||||
<div class="alert alert-danger mt-2">@_shareError</div>
|
||||
}
|
||||
</BodyContentTemplate>
|
||||
<FooterContentTemplate Context="shareFootCtx">
|
||||
<DxButton Text="@Loc["Send"]" RenderStyle="ButtonRenderStyle.Primary"
|
||||
IconCssClass="bi bi-send" Click="OnShareSubmit"
|
||||
Enabled="@(!_busy)" />
|
||||
</FooterContentTemplate>
|
||||
</DxPopup>
|
||||
|
||||
@* — Reject popup — *@
|
||||
<DxPopup @bind-Visible="_rejectVisible"
|
||||
HeaderText="@Loc["Rejection"]"
|
||||
ShowCloseButton="true"
|
||||
Width="32rem">
|
||||
<BodyContentTemplate Context="rejectCtx">
|
||||
<p>@Loc["RejectionReasonQ"]</p>
|
||||
<DxMemo @bind-Text="_rejectReason" Rows="4" />
|
||||
</BodyContentTemplate>
|
||||
<FooterContentTemplate Context="rejectFootCtx">
|
||||
<DxButton Text="@Loc["Complete"]"
|
||||
RenderStyle="ButtonRenderStyle.Primary"
|
||||
Click="OnRejectSubmit" Enabled="@(!_busy)" />
|
||||
<DxButton Text="@Loc["Back"]"
|
||||
RenderStyle="ButtonRenderStyle.Secondary"
|
||||
Click="@(() => _rejectVisible = false)" />
|
||||
</FooterContentTemplate>
|
||||
</DxPopup>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(_globalError))
|
||||
{
|
||||
<div class="alert alert-danger m-3" role="alert">@_globalError</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@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<SignatureElementDto> _elements = new();
|
||||
private readonly Dictionary<int, BlazorSignatureEntry> _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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
*@
|
||||
|
||||
<DxPopup @bind-Visible="_visible"
|
||||
HeaderText="@Loc["YourSignature"]"
|
||||
ShowCloseButton="true"
|
||||
CloseOnEscape="true"
|
||||
Width="36rem"
|
||||
Closed="OnClosed">
|
||||
<BodyContentTemplate Context="padCtx">
|
||||
<div class="signature-pad-container">
|
||||
<canvas id="@_canvasId"
|
||||
class="signature-pad-canvas"
|
||||
style="width:100%; height:200px; border:1px solid #cfd6dd; border-radius:.25rem; background:#fff; touch-action:none"></canvas>
|
||||
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<DxButton Text="@Loc["Clear"]"
|
||||
RenderStyle="ButtonRenderStyle.Secondary"
|
||||
IconCssClass="bi bi-eraser"
|
||||
Click="ClearAsync" />
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<DxFormLayout>
|
||||
<DxFormLayoutItem Caption="@Loc["Position"]">
|
||||
<DxTextBox @bind-Text="_position" NullText="@Loc["PositionPlaceholder"]" />
|
||||
</DxFormLayoutItem>
|
||||
<DxFormLayoutItem Caption="@Loc["City"]">
|
||||
<DxTextBox @bind-Text="_city" NullText="@Loc["CityPlaceholder"]" />
|
||||
</DxFormLayoutItem>
|
||||
</DxFormLayout>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(_error))
|
||||
{
|
||||
<div class="alert alert-danger mt-2 mb-0">@_error</div>
|
||||
}
|
||||
</div>
|
||||
</BodyContentTemplate>
|
||||
<FooterContentTemplate Context="footCtx">
|
||||
<DxButton Text="@Loc["Confirm"]"
|
||||
RenderStyle="ButtonRenderStyle.Primary"
|
||||
IconCssClass="bi bi-check2"
|
||||
Click="ConfirmAsync" />
|
||||
<DxButton Text="@Loc["Cancel"]"
|
||||
RenderStyle="ButtonRenderStyle.Secondary"
|
||||
Click="@(() => Hide())" />
|
||||
</FooterContentTemplate>
|
||||
</DxPopup>
|
||||
|
||||
@code {
|
||||
/// <summary>Fired when the user confirms a valid signature.</summary>
|
||||
[Parameter] public EventCallback<BlazorSignatureEntry> 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;
|
||||
|
||||
/// <summary>Opens the dialog and binds JS interop on the canvas.</summary>
|
||||
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<bool>("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<string?>("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();
|
||||
}
|
||||
@@ -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 <c>GET /api/tfa/{key}</c> on first render.
|
||||
*@
|
||||
|
||||
<PageTitle>@Loc["TfaRegistration"]</PageTitle>
|
||||
|
||||
<div class="page container p-5">
|
||||
<header class="text-center">
|
||||
<div class="icon locked mt-4 mb-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" fill="currentColor"
|
||||
class="bi bi-shield-lock" viewBox="0 0 16 16">
|
||||
<path d="M5.338 1.59a61 61 0 0 0-2.837.856.48.48 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.7 10.7 0 0 0 2.287 2.233c.346.244.652.42.893.533q.18.085.293.118a1 1 0 0 0 .101.025 1 1 0 0 0 .1-.025q.114-.034.294-.118c.24-.113.547-.29.893-.533a10.7 10.7 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.8 11.8 0 0 1-2.517 2.453 7 7 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7 7 0 0 1-1.048-.625 11.8 11.8 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 63 63 0 0 1 5.072.56" />
|
||||
<path d="M9.5 6.5a1.5 1.5 0 0 1-1 1.415l.385 1.99a.5.5 0 0 1-.491.595h-.788a.5.5 0 0 1-.49-.595l.384-1.99a1.5 1.5 0 1 1 2-1.415" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mb-0">2-Factor Authentication (2FA)</h2>
|
||||
<h2>@Loc["Registration"]</h2>
|
||||
</header>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<div class="text-center mt-4">
|
||||
<DxLoadingPanel Visible="true" IsContentBlocked="false" ApplyBackgroundShading="false" />
|
||||
</div>
|
||||
}
|
||||
else if (_error is not null)
|
||||
{
|
||||
<div class="alert alert-danger mt-4" role="alert">@_error</div>
|
||||
<div class="text-center mt-3">
|
||||
<DxButton Text="@Loc["Back"]"
|
||||
RenderStyle="ButtonRenderStyle.Secondary"
|
||||
Click="@(() => Nav.NavigateTo($"/envelope/{Key}"))" />
|
||||
</div>
|
||||
}
|
||||
else if (_data is not null)
|
||||
{
|
||||
<section class="text-center">
|
||||
<p class="p-0 m-0">
|
||||
@if (_data.TfaRegDeadline is DateTime dl)
|
||||
{
|
||||
@Loc.Format("PageVisibleUntil", dl.ToString("d. MMM, HH:mm", new System.Globalization.CultureInfo("de-DE")))
|
||||
}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="text-start mt-4">
|
||||
<DxAccordion>
|
||||
<Items>
|
||||
<DxAccordionItem Text="@Loc["Step1Download2faApplication"]" Expanded="true">
|
||||
<ContentTemplate>
|
||||
<p class="text-wrap fw-medium">@Loc["Download2faAppInstruction"]</p>
|
||||
<p class="text-wrap fw-light">@Loc["Recommended2faApplications"]</p>
|
||||
<ul class="list-group text-start">
|
||||
<li class="list-group-item">
|
||||
<a href="https://support.google.com/accounts/answer/1066447?hl=de&co=GENIE.Platform%3DAndroid"
|
||||
target="_blank" rel="noopener" style="text-decoration:none">
|
||||
<samp>Google Authenticator</samp>
|
||||
</a>
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<a href="https://support.microsoft.com/de-de/account-billing/microsoft-authenticator-herunterladen-351498fc-850a-45da-b7b6-27e523b8702a"
|
||||
target="_blank" rel="noopener" style="text-decoration:none">
|
||||
<samp>Microsoft Authenticator</samp>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</ContentTemplate>
|
||||
</DxAccordionItem>
|
||||
|
||||
<DxAccordionItem Text="@Loc["Step2ScanQrCode"]">
|
||||
<ContentTemplate>
|
||||
<div class="text-center m-0 p-0">
|
||||
@if (!string.IsNullOrEmpty(_data.TotpQR64))
|
||||
{
|
||||
<img class="tfaQrCode"
|
||||
src="@($"data:image/png;base64,{_data.TotpQR64}")"
|
||||
alt="TOTP QR" />
|
||||
}
|
||||
</div>
|
||||
<p class="text-wrap fw-medium">@Loc["ScanQrCodeInstruction"]</p>
|
||||
</ContentTemplate>
|
||||
</DxAccordionItem>
|
||||
|
||||
<DxAccordionItem Text="@Loc["Step3VerifyTheCode"]">
|
||||
<ContentTemplate>
|
||||
<p class="text-wrap fw-medium">
|
||||
@Loc["VerifyCodeInstructionMain"]
|
||||
<samp>@Loc["VerifyCodeInstructionSubmit"]</samp>.
|
||||
</p>
|
||||
<div class="text-center mt-3">
|
||||
<DxButton Text="@Loc["BackToEnvelope"]"
|
||||
RenderStyle="ButtonRenderStyle.Primary"
|
||||
Click="@(() => Nav.NavigateTo($"/envelope/{Key}"))" />
|
||||
</div>
|
||||
</ContentTemplate>
|
||||
</DxAccordionItem>
|
||||
</Items>
|
||||
</DxAccordion>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user