Compare commits

...

22 Commits

Author SHA1 Message Date
b536b31a4b SF-74: CSS — app.css theme/dark-mode additions, envelope-viewer.css, sender-page.css new stylesheets 2026-09-03 15:40:20 +02:00
a80efee075 SF-74: ThemeService + signflow-theme.js — JS interop fixes and dark mode init improvements 2026-09-03 15:40:14 +02:00
86d6daafbd SF-74: EnvelopeSenderPage — ThemeService/UserPreferencesService inject, LoadFromServerAsync, grid layout persistence (LayoutAutoLoading/Saving) 2026-09-03 14:58:31 +02:00
aefa1a8883 SF-74: Client UI — MainLayout HeadContent+footer theme controls, ThemeControls component, app.css sf-footer styles 2026-09-03 14:58:19 +02:00
b3e7f77165 SF-74: Server — add signflow-theme.js (localStorage/system dark mode JS interop) 2026-09-03 14:58:07 +02:00
bac9bfd036 SF-74: Client Services — ThemeService (debounce, SaveError, DisplayName), UserPreferencesService (1h cache) 2026-09-03 14:57:55 +02:00
6effb8a0d6 SF-74: Server — UserPreferencesController, claim extensions, DI registration, App.razor 2026-09-03 14:57:44 +02:00
a9ff0d88f2 SF-74: Infrastructure — UserPreferencesService (IDistributedCache, no try-catch) 2026-09-03 14:57:32 +02:00
0fdfe991ea SF-74: Domain/Application — UserPreferences DTO, interface, Sender role constant 2026-09-03 14:57:21 +02:00
7fc2c14f9c Add dynamic theme and dark mode functionality
Introduced `ThemeService` to manage themes and dark mode, with a new footer UI for theme selection and toggling dark mode. Added dynamic CSS loading and Bootstrap theme support. Registered `UserPreferencesService` and `ThemeService` for SSR compatibility. Removed hardcoded DevExpress theme links.

Fixed German localization issues in `LoginReceiverPage` and `LoginSenderPage`. Updated `app.css` with styles for the new footer controls. Simplified font loading in `MainLayout.razor`.
2026-09-03 09:24:13 +02:00
5098deedd1 Add ThemeService and UserPreferencesService
Introduce ThemeService to manage themes and dark mode state,
including initialization, persistence, and server sync.

Add UserPreferencesService to handle API communication for
retrieving and saving user preferences with in-memory caching
to reduce network overhead.

Register both services in the DI container in Program.cs.

Enhance ThemeService with detailed documentation and introduce
ThemeOption record for selectable themes. Improve server-side
caching in UserPreferencesService for consistency and efficiency.
2026-09-03 01:18:01 +02:00
2e634361c5 Add user preferences management via distributed cache
Introduced `UserPreferencesDto` to represent UI preferences,
including theme, dark mode, and grid layouts. Added the
`IUserPreferencesService` interface and implemented it in
`UserPreferencesService` to manage preferences stored in
the distributed cache (`dbo.TBDD_CACHE`).

Registered `UserPreferencesService` in dependency injection
and added a NuGet reference for `Microsoft.Extensions.Caching.Abstractions`.

Created `UserPreferencesController` with `GET` and `PUT`
endpoints to retrieve and save user preferences. Validated
theme names in the `PUT` endpoint.

Updated `Program.cs` to ensure proper service registration.
2026-09-02 12:35:19 +02:00
60ebebc0d8 Add widget annotation and update stroke color
Updated `createAnnotations` to initialize a `PSPDFKit.Annotations.WidgetAnnotation` object, enabling the creation of widget annotations.

Modified `ctx.strokeStyle` in `createAnnotationFrameBlob` to use 'darkblue' instead of 'darkgray', improving the visual appearance of the annotation frame.
2026-08-27 14:46:32 +02:00
c2d8b6531c Target single framework and add publish profile
Updated `EnvelopeGenerator.Service.csproj` to target a single framework (`net4.6.2`) by replacing `<TargetFrameworks>` with `<TargetFramework>`.

Added `DefaultProfile.pubxml` to define a publish profile with settings for release configuration, platform, and file system-based publishing.
2026-08-27 12:44:11 +02:00
665266457f Migrate legacy VB.NET service to modern C# Worker
Migrated the legacy VB.NET Windows Service to a modern C# Worker Service targeting .NET Framework 4.6.2. Key changes include:

- Added `Microsoft.Extensions.Hosting.WindowsServices`, `NLog`, and `Quartz` for logging and scheduling.
- Introduced `LogConfigFactory` to bridge `ServiceConfig` and `LogConfig` for Quartz jobs.
- Rewrote `Worker` to handle the full service lifecycle, including database connection, temp file management, and scheduler initialization.
- Implemented `SchedulerFinishEnvelope` and `SchedulerEnvelopeTaskApi` for Quartz job scheduling.
- Created `TempFiles` for service-level temp file management.
- Configured `NLog` for file and console logging.
- Updated `appsettings.json` for connection strings and service-specific configuration.
- Replaced legacy `ProjectInstaller` with `AddWindowsService()` for SCM integration.
2026-08-27 11:39:49 +02:00
14d581acac Add EnvelopeGenerator.Service as a .NET Worker Service
Introduced a new project `EnvelopeGenerator.Service` to the solution as a .NET Worker Service targeting the `net4.6.2` framework.

- Configured the project with nullable reference types, implicit usings, and `LangVersion` set to `latest`.
- Added a `Worker` class that logs periodic messages while running.
- Updated `Program.cs` to configure and run the Worker Service.
- Added `launchSettings.json` for development environment configuration.
- Added `appsettings.json` and `appsettings.Development.json` for logging configuration.
- Included the project in `EnvelopeGenerator.sln` with appropriate build configurations and hierarchy updates.
2026-08-27 10:36:55 +02:00
eb533a1dc2 Replace EnvelopeGenerator.Service with Service_legacy
The project `EnvelopeGenerator.Service` has been removed from the solution and replaced with a new project, `EnvelopeGenerator.Service_legacy`.

Updated build configuration mappings to reflect the addition of `Service_legacy` and the removal of `Service`. Adjusted project nesting to place `Service_legacy` under the same parent as the removed `Service`.
2026-08-27 10:36:01 +02:00
36025fad7b Enhance handleReset functionality and fix async issues
Updated the `handleReset` method in `app.js` to await the
`Swal.fire` call, ensuring proper asynchronous handling.
Added logic to reset editable text form fields and restore
widget backgrounds after signing.

Refactored `app.min.js` to align with changes in `app.js`,
ensuring consistency between the minified and non-minified
versions. Improved code readability and maintainability
across both files.
2026-08-27 09:32:51 +02:00
0606efabf9 Update truncation limits for "position" and "city"
Increased the maximum width values for the "position" and "city" keys in the `TextMaxWidths` property of `Jobs.FinalizeDocument` (PDFBurnerParams.vb) from `1.25F` to `1.41F`. This change allows more text to fit before truncation occurs, improving text rendering flexibility.
2026-08-25 16:38:51 +02:00
2cb1e266f7 Improve annotation handling and UI stability
Updated `appsettings.UI.json` to adjust annotation properties for better alignment and positioning. Refactored `createAnnotations` and `createAnnotationFrameBlob` in `annotation.js` to improve readability, reusability, and asynchronous handling.

Enhanced the `App` class in `app.js` by introducing `signatureWidgets` for stable frame placement and added logic to find and update the nearest signature widget. Improved bounding box calculations and removed widget backgrounds after signing.

Refactored `handleAnnotationsCreate` to align with new logic and updated `app.min.js` to reflect these changes. General improvements include better code maintainability, reduced redundancy, and enhanced user experience.
2026-08-25 16:38:22 +02:00
35c1374b9c Update text truncation logic to use rendered width
Replaced character-based truncation with pixel-accurate truncation based on rendered width in inches. Updated `TruncateText` in `PDFBurner.vb` to use `Graphics.MeasureString` for width calculation and introduced a binary search for efficient truncation. Renamed `egName` to `fieldEgName` for clarity.

Replaced `TextMaxLengths` with `TextMaxWidths` in `PDFBurnerParams.vb`, specifying maximum render widths in inches. Updated default values for `position`, `city`, and `date`. Revised XML documentation to reflect the new truncation logic.
2026-08-25 09:47:22 +02:00
feaea1626b Fix signature field formatting and add text truncation
Address customer feedback on signature field layout issues:
- Added truncation logic in `PDFBurner.vb` to limit text length
  based on per-field maximums, appending a suffix (e.g., `..`).
- Enhanced `PDFBurnerParams.vb` with `TextMaxLengths` and
  `TruncationSuffix` for configurable truncation behavior.
- Improved spacing and layout handling for signature fields to
  prevent text collisions within fixed dimensions.
- Updated `EnvelopeGenerator.sln` with documentation for the
  signature formatting fix (`fix-signature-field-formatting.md`).
- Applied truncation consistently across `AddFormFieldValue`
  overloads and ensured deterministic rendering of annotations.
2026-08-25 09:07:02 +02:00
68 changed files with 2500 additions and 77 deletions

View File

@@ -0,0 +1,39 @@
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
/// Represents a user's UI preferences stored in the distributed cache (dbo.TBDD_CACHE).
/// </summary>
public sealed record UserPreferencesDto
{
/// <summary>
/// The active DevExpress theme name.
/// Allowed values: "blazing-berry", "fluent", "purple"
/// </summary>
public string ThemeName { get; set; } = "blazing-berry";
/// <summary>
/// Whether dark mode is active.
/// Default: false (light mode).
/// </summary>
public bool IsDarkMode { get; set; } = false;
/// <summary>
/// Persisted DxGrid layout states, keyed by a stable grid identifier.
/// Example keys: "sender.active-envelopes", "sender.completed-envelopes"
/// Values are opaque JSON strings produced by DxGrid.SaveLayoutAsync().
/// </summary>
public Dictionary<string, string> GridLayouts { get; set; } = new();
/// <summary>
/// The authenticated user's display name ("{GivenName} {Surname}").
/// Populated by the server on GET responses from JWT claims.
/// NOT persisted in cache — injected at read time by UserPreferencesController.
/// Ignored on PUT requests.
/// </summary>
public string? DisplayName { get; set; }
/// <summary>
///
/// </summary>
public static UserPreferencesDto Default { get; } = new UserPreferencesDto();
}

View File

@@ -0,0 +1,29 @@
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Application.Common.Interfaces.Services;
/// <summary>
/// Manages per-user UI preferences (theme, dark mode, grid layouts).
/// Preferences are stored in the distributed cache (dbo.TBDD_CACHE) as JSON.
/// Cache key schema: "signflow.user-preferences:{username}"
/// </summary>
public interface IUserPreferencesService
{
/// <summary>
/// Retrieves preferences for the given user.
/// Always returns a valid object — never throws, never returns null.
/// Returns defaults if no entry exists in cache.
/// </summary>
/// <param name="id"></param>
/// <param name="ct">Cancellation token.</param>
Task<UserPreferencesDto> GetOrCreateAsync(string id, CancellationToken ct = default);
/// <summary>
/// Persists the given preferences for the user in the distributed cache.
/// Failures are non-fatal and silently ignored.
/// </summary>
/// <param name="id"></param>
/// <param name="preferences">The preferences to persist.</param>
/// <param name="ct">Cancellation token.</param>
Task SaveAsync(string id, UserPreferencesDto preferences, CancellationToken ct = default);
}

View File

@@ -119,7 +119,7 @@ Namespace Jobs.FinalizeDocument
Dim y = frameY + yOffsetofFF
If annot.Type = AnnotationType.FormField Then
AddFormFieldValue(annot.X / inchFactor, y, annot.Width / inchFactor, annot.Height / inchFactor, element.Page, annot.Value)
AddFormFieldValue(annot.X / inchFactor, y, annot.Width / inchFactor, annot.Height / inchFactor, element.Page, annot.Value, annot.Name)
ElseIf annot.Type = AnnotationType.Image Then
AddImageAnnotation(
annot.X / inchFactor,
@@ -269,11 +269,14 @@ Namespace Jobs.FinalizeDocument
Next
End Sub
Private Sub AddFormFieldValue(x As Double, y As Double, width As Double, height As Double, page As Integer, value As String)
Private Sub AddFormFieldValue(x As Double, y As Double, width As Double, height As Double, page As Integer, value As String, Optional egName As String = "")
Manager.SelectPage(page)
' Truncate if needed
Dim displayValue As String = TruncateText(value, egName)
' Add the text annotation
Dim ant = Manager.AddTextAnnot(x, y, width, height, value)
Dim ant = Manager.AddTextAnnot(x, y, width, height, displayValue)
' Set the font properties
ant.FontName = _pdfBurnerParams.FontName
@@ -294,8 +297,9 @@ Namespace Jobs.FinalizeDocument
Dim oHeight = oBounds.Item(3)
Manager.SelectPage(pAnnotation.pageIndex + 1)
' Add the text annotation
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, formFieldValue.value)
' Add the text annotation (truncate if needed)
Dim displayValue As String = TruncateText(formFieldValue.value, pAnnotation.egName)
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, displayValue)
' Set the font properties
ant.FontName = _pdfBurnerParams.FontName
@@ -306,6 +310,48 @@ Namespace Jobs.FinalizeDocument
#End Region
#Region "Helpers"
''' <summary>
''' Truncates a field value so its rendered width fits within the configured max width (inches).
''' Uses Graphics.MeasureString with the configured font for pixel-accurate measurement.
''' Appends TruncationSuffix when the value is cut.
''' </summary>
Private Function TruncateText(value As String, fieldEgName As String) As String
If String.IsNullOrEmpty(value) Then Return value
If _pdfBurnerParams.TextMaxWidths Is Nothing Then Return value
Dim maxWidthInches As Single = 0
If Not _pdfBurnerParams.TextMaxWidths.TryGetValue(fieldEgName, maxWidthInches) Then Return value
If maxWidthInches <= 0 Then Return value
Dim suffix As String = If(_pdfBurnerParams.TruncationSuffix, "")
Using bmp As New Bitmap(1, 1)
Using g As Graphics = Graphics.FromImage(bmp)
Dim font As New Font(_pdfBurnerParams.FontName, _pdfBurnerParams.FontSize, _pdfBurnerParams.FontStyle, GraphicsUnit.Point)
Dim maxWidthPx As Single = maxWidthInches * g.DpiX
' Already fits — no truncation needed
If g.MeasureString(value, font).Width <= maxWidthPx Then Return value
' Binary search for the longest prefix that fits (with suffix)
Dim lo As Integer = 0
Dim hi As Integer = value.Length
While lo < hi
Dim mid As Integer = (lo + hi + 1) \ 2
Dim candidate As String = value.Substring(0, mid) & suffix
If g.MeasureString(candidate, font).Width <= maxWidthPx Then
lo = mid
Else
hi = mid - 1
End If
End While
Return value.Substring(0, lo) & suffix
End Using
End Using
End Function
Private Function ToPointF(pPoints As List(Of Single)) As PointF
Dim oPoints = pPoints.Select(AddressOf ToInches).ToList()
Return New PointF(oPoints.Item(0), oPoints.Item(1))

View File

@@ -12,5 +12,24 @@ Namespace Jobs.FinalizeDocument
Public Property FontSize As Integer = 8
Public Property FontStyle As FontStyle = FontStyle.Italic
''' <summary>
''' The suffix appended to a truncated field value. Defaults to "..".
''' Can be changed to any string (e.g. "…", "...", "").
''' </summary>
Public Property TruncationSuffix As String = ".."
''' <summary>
''' Per-field maximum render width in inches.
''' Text is measured using the configured Font/FontSize via Graphics.MeasureString (pixel-accurate).
''' If the rendered width exceeds the limit, the text is cut and TruncationSuffix is appended.
''' Keys must match egName values used in PDFBurner (e.g. "name", "position", "city", "date").
''' Set a value to 0 to disable truncation for that field.
''' </summary>
Public Property TextMaxWidths As New Dictionary(Of String, Single) From {
{"position", 1.41F},
{"city", 1.41F},
{"date", 2.0F}
}
End Class
End Namespace

View File

@@ -1,5 +1,6 @@
#if NETFRAMEWORK
using System;
using System.Collections.Generic;
#endif
namespace EnvelopeGenerator.Domain.Constants
@@ -15,7 +16,11 @@ namespace EnvelopeGenerator.Domain.Constants
public static class Receiver
{
public const string TFA = "EGReceiverTFA";
[Obsolete("Use Receiver.Authorized")]
public const string Full = "EGReceiver";
public static readonly IReadOnlyList<string> Authorized = new[] { "receiver", "EGReceiver" };
}
public const string Sender = "EGSender";

View File

@@ -14,6 +14,7 @@ using EnvelopeGenerator.Infrastructure.Repositories;
using EnvelopeGenerator.Infrastructure.Executor;
using EnvelopeGenerator.Application.Common.Interfaces.Repositories;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using DigitalData.EmailProfilerDispatcher.Abstraction.Entities;
#elif NETFRAMEWORK
using System;
@@ -55,6 +56,9 @@ namespace EnvelopeGenerator.Infrastructure
services.TryAddScoped<IEnvelopeTypeRepository, EnvelopeTypeRepository>();
services.TryAddScoped<IReceiverRepository, ReceiverRepository>();
services.TryAddScoped<IEnvelopeReceiverReadOnlyRepository, EnvelopeReceiverReadOnlyRepository>();
// User Preferences — distributed cache (dbo.TBDD_CACHE)
services.TryAddScoped<IUserPreferencesService, UserPreferencesService>();
#endif
services.AddDbRepository(opt =>

View File

@@ -26,6 +26,7 @@
<PackageReference Include="DigitalData.Core.Infrastructure" Version="2.6.1" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
<PackageReference Include="QuestPDF" Version="2025.7.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />

View File

@@ -0,0 +1,46 @@
#if NET
using System.Text.Json;
using Microsoft.Extensions.Caching.Distributed;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
namespace EnvelopeGenerator.Infrastructure;
/// <summary>
/// Implements user preferences storage using the distributed cache (dbo.TBDD_CACHE).
/// No DB schema changes required — uses the existing SQL Server distributed cache table.
/// Cache key schema: "signflow.user-preferences:{username}"
/// Expiration: indefinite (no TTL) — preferences persist until explicitly overwritten.
/// </summary>
public sealed class UserPreferencesService(IDistributedCache cache) : IUserPreferencesService
{
private const string KeyPrefix = "signflow.user-preferences:";
private static string BuildKey(string id) => $"{KeyPrefix}{id}";
/// <inheritdoc />
public async Task<UserPreferencesDto> GetOrCreateAsync(string id, CancellationToken ct = default)
{
var json = await cache.GetStringAsync(BuildKey(id), ct);
if(json is not null && JsonSerializer.Deserialize<UserPreferencesDto>(json) is UserPreferencesDto pref)
{
return pref;
}
else
{
// init new
await SaveAsync(id, UserPreferencesDto.Default, ct);
return UserPreferencesDto.Default;
}
}
/// <inheritdoc />
public async Task SaveAsync(string id, UserPreferencesDto preferences, CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(preferences);
// No expiration — preferences are stored indefinitely.
await cache.SetStringAsync(BuildKey(id), json, new DistributedCacheEntryOptions(), ct);
}
}
#endif

View File

@@ -1,28 +1,43 @@
@using EnvelopeGenerator.Server.Client.Services;
@using EnvelopeGenerator.Server.Client.Services
@inherits LayoutComponentBase
@implements IDisposable
<div class="page">
<main>
@inject ThemeService ThemeService
<HeadContent>
<link href="@ThemeService.ThemeCssHref" rel="stylesheet" />
</HeadContent>
<div class="page" data-bs-theme="@ThemeService.BootstrapTheme">
<main>
<article class="content">
@Body
</article>
</main>
<footer class="receiver-footer">
<span>&copy; SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
<span class="receiver-footer__sep">&#124;</span>
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
<div class="receiver-footer__left">
<span>&copy; SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
<span class="receiver-footer__sep">&#124;</span>
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
</div>
<ThemeControls />
</footer>
</div>
@code {
[Inject] IHttpClientFactory HttpClientFactory { get; set; } = default!;
List<string> RequiredFonts = new() {
"opensans.ttf"
};
protected async override Task OnInitializedAsync() {
await FontLoader.LoadFonts(HttpClientFactory, RequiredFonts);
private Action? _themeChangeHandler;
protected override async Task OnInitializedAsync()
{
await FontLoader.LoadFonts(HttpClientFactory, ["opensans.ttf"]);
_themeChangeHandler = () => InvokeAsync(StateHasChanged);
ThemeService.OnChange += _themeChangeHandler;
await base.OnInitializedAsync();
}
}
public void Dispose()
=> ThemeService.OnChange -= _themeChangeHandler;
}

View File

@@ -0,0 +1,95 @@
@using EnvelopeGenerator.Server.Client.Services
@rendermode InteractiveWebAssembly
@implements IDisposable
@inject ThemeService ThemeService
<div class="sf-theme-slider">
@* Moon — left end (blazing-dark) *@
<svg class="sf-theme-icon @(ThemeService.ThemeName == "blazing-dark" ? "sf-theme-icon--active" : "")"
xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 24 24"
title="Blazing Dark">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
@* Track wrapper: range input + left/mid/right ticks *@
<div class="sf-theme-track-wrapper">
@* Left tick *@
<div class="sf-theme-tick sf-theme-tick--left"></div>
@* Mid tick (marks the middle position) *@
<div class="sf-theme-tick sf-theme-tick--mid"></div>
@* Right tick *@
<div class="sf-theme-tick sf-theme-tick--right"></div>
<input type="range"
class="sf-theme-range"
min="0" max="2" step="1"
value="@SliderValue"
@oninput="OnSliderChanged"
title="@ThemeService.AvailableThemes[SliderValue].Label" />
</div>
@* Sun — right end (purple) *@
<svg class="sf-theme-icon @(ThemeService.ThemeName == "purple" ? "sf-theme-icon--active" : (ThemeService.ThemeName == "blazing-berry" ? "sf-theme-icon--mid" : ""))"
xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 24 24"
stroke="currentColor" stroke-width="1.5"
title="Blazing Berry / Purple">
<circle cx="12" cy="12" r="4" fill="currentColor" stroke="none"/>
<line x1="12" y1="2" x2="12" y2="5"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34"/>
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78"/>
<line x1="2" y1="12" x2="5" y2="12"/>
<line x1="19" y1="12" x2="22" y2="12"/>
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66"/>
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22"/>
</svg>
</div>
@code {
private Action? _themeChangeHandler;
/// <summary>
/// Maps the active theme to a 0-based slider step.
/// 0 = blazing-dark (🌙 left)
/// 1 = blazing-berry (middle)
/// 2 = purple (☀️ right)
/// </summary>
private int SliderValue => ThemeService.ThemeName switch
{
"blazing-dark" => 0,
"purple" => 2,
_ => 1
};
protected override void OnInitialized()
{
_themeChangeHandler = () => InvokeAsync(StateHasChanged);
ThemeService.OnChange += _themeChangeHandler;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await ThemeService.InitializeAsync();
}
private async Task OnSliderChanged(ChangeEventArgs e)
{
if (!int.TryParse(e.Value?.ToString(), out var step)) return;
var themeName = step switch
{
0 => "blazing-dark",
2 => "purple",
_ => "blazing-berry"
};
await ThemeService.SetThemeAsync(themeName);
}
public void Dispose()
=> ThemeService.OnChange -= _themeChangeHandler;
}

View File

@@ -5,19 +5,15 @@
@using EnvelopeGenerator.Server.Client.Models
@using DevExpress.Blazor
@using EnvelopeGenerator.Server.Client.Services
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
@using EnvelopeGenerator.Application.Common.Dto
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
@inject NavigationManager Navigation
@inject IJSRuntime JSRuntime
@inject AppVersionService AppVersion
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
<link href="@AppVersion.GetVersionedUrl("css/sender-page.css")" rel="stylesheet" />
@@ -136,7 +132,9 @@
SelectionMode="GridSelectionMode.Single"
SelectedDataItem="@_selectedEnvelope"
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
CustomizeElement="OnCustomizeElement">
CustomizeElement="OnCustomizeElement"
LayoutAutoLoading="OnActiveGridLayoutAutoLoading"
LayoutAutoSaving="OnActiveGridLayoutAutoSaving">
<Columns>
<DxGridDataColumn FieldName="Id" Caption="ID"
SortIndex="0"
@@ -234,7 +232,9 @@
SelectionMode="GridSelectionMode.Single"
SelectedDataItem="@_selectedEnvelope"
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
CustomizeElement="OnCustomizeElement">
CustomizeElement="OnCustomizeElement"
LayoutAutoLoading="OnCompletedGridLayoutAutoLoading"
LayoutAutoSaving="OnCompletedGridLayoutAutoSaving">
<Columns>
<DxGridDataColumn FieldName="Id" Caption="ID"
SortIndex="0"
@@ -341,6 +341,10 @@
private DxGrid? _gridActive;
private DxGrid? _gridCompleted;
// Stable keys used to store/restore each grid's layout in UserPreferences.GridLayouts
private const string GridKeyActive = "sender.active-envelopes";
private const string GridKeyCompleted = "sender.completed-envelopes";
protected override async Task OnInitializedAsync()
{
var hasAccess = await AuthService.CheckSenderAccessAsync();
@@ -350,6 +354,12 @@
return;
}
// Load theme + DisplayName from server after authentication.
// Non-fatal: if this fails the page still loads with default theme.
// ThemeService.ScheduleSaveAsync() already logs errors internally.
try { await ThemeService.LoadFromServerAsync(); }
catch { /* swallow — default theme remains active */ }
await LoadEnvelopesAsync();
}
@@ -367,13 +377,10 @@
var envelopes = _allEnvelopes.ToList();
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
await JSRuntime.InvokeVoidAsync("console.log", $"Loaded {_activeEnvelopes.Count()} active and {_completedEnvelopes.Count()} completed envelopes");
}
catch (Exception ex)
{
_errorMessage = ex.Message;
await JSRuntime.InvokeVoidAsync("console.error", "Fehler beim Laden der Umschläge:", ex.ToString());
}
finally
{
@@ -396,20 +403,19 @@
{
if (_selectedEnvelope == null) return;
// TODO: Navigate to envelope editor
JSRuntime.InvokeVoidAsync("console.log", $"Edit envelope {_selectedEnvelope.Id} clicked - not yet implemented");
}
void DeleteEnvelope()
{
if (_selectedEnvelope == null) return;
// TODO: Show delete confirmation dialog
JSRuntime.InvokeVoidAsync("console.log", $"Delete envelope {_selectedEnvelope.Id} clicked - not yet implemented");
}
async Task LogoutAsync()
{
_isLoggingOut = true;
await InvokeAsync(StateHasChanged);
UserPreferencesService.InvalidateCache();
await AuthService.LogoutSenderAsync();
Navigation.NavigateTo("/sender/login", forceLoad: true);
}
@@ -446,4 +452,45 @@
{
_selectedEnvelope = envelope as EnvelopeDto;
}
// ── Grid Layout Persistence ───────────────────────────────────────────────
// LayoutAutoLoading fires when the grid initializes — restore saved layout.
// LayoutAutoSaving fires when the user changes column order/width/sort — persist it.
// UserPreferencesService.GetAsync() uses the 1h in-memory cache → 0 HTTP on restore.
// UserPreferencesService.SaveGridLayoutAsync() merges the single key → 1 PUT.
async Task OnActiveGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
{
// Use cached preferences only — avoid HTTP during grid initialization.
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(GridKeyActive, out var json))
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
async Task OnActiveGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
try
{
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(GridKeyActive, json);
}
catch (Exception ex)
{
await JSRuntime.InvokeVoidAsync("console.warn", $"[Sender] Grid layout save failed: {ex.Message}");
}
}
async Task OnCompletedGridLayoutAutoLoading(GridPersistentLayoutEventArgs e)
{
var prefs = UserPreferencesService.GetCached();
if (prefs is not null && prefs.GridLayouts.TryGetValue(GridKeyCompleted, out var json))
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
}
async Task OnCompletedGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
{
var json = JsonSerializer.Serialize(e.Layout);
await UserPreferencesService.SaveGridLayoutAsync(GridKeyCompleted, json);
}
}

View File

@@ -3,8 +3,6 @@
@inject NavigationManager Navigation
@rendermode InteractiveWebAssembly
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<div class="home-page-wrapper">
<div class="home-hero-header">

View File

@@ -4,8 +4,6 @@
@inject AuthService AuthService
@inject NavigationManager Navigation
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
@@ -15,14 +13,14 @@
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
</svg>
</div>
<h5 class="mb-0 fw-semibold">Dokument öffnen</h5>
<h5 class="mb-0 fw-semibold">Dokument öffnen</h5>
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">Sicherer Zugang mit Zugangscode</p>
</div>
<div class="card-body p-4">
<p class="text-muted mb-4" style="font-size: 0.875rem; line-height: 1.5;">
Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.
Bitte geben Sie den Zugangscode ein, den Sie per E-Mail erhalten haben, um das Dokument sicher zu öffnen.
</p>
@if (LoginResult == EnvelopeLoginResult.NotFound)
@@ -33,7 +31,7 @@
</svg>
<div>
<strong>Dokument nicht gefunden.</strong><br />
<span style="font-size:0.85rem;">Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</span>
<span style="font-size:0.85rem;">Der angegebene Zugangscode konnte keinem Dokument zugeordnet werden. Bitte prüfen Sie den Link in Ihrer E-Mail.</span>
</div>
</div>
}
@@ -44,7 +42,7 @@
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
</svg>
<div>
<strong>Ungültiger Zugangscode.</strong><br />
<strong>Ungültiger Zugangscode.</strong><br />
<span style="font-size:0.85rem;">Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.</span>
</div>
</div>
@@ -58,7 +56,7 @@
</svg>
<div>
<strong>Serverfehler.</strong><br />
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
</div>
</div>
}
@@ -115,14 +113,14 @@
@if (IsLoading)
{
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span>Überprüfen …</span>
<span>Überprüfen …</span>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z" />
</svg>
<span>Dokument öffnen</span>
<span>Dokument öffnen</span>
}
</button>

View File

@@ -4,8 +4,6 @@
@inject AuthService AuthService
@inject NavigationManager Navigation
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
<div class="login-page-wrapper d-flex align-items-center justify-content-center min-vh-100">
<div class="login-card card shadow border-0" style="max-width: 440px; width: 100%;">
@@ -32,7 +30,7 @@
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z"/>
</svg>
<div>
<strong>Ungültige Anmeldedaten.</strong><br />
<strong>Ungültige Anmeldedaten.</strong><br />
<span style="font-size:0.85rem;">Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.</span>
</div>
</div>
@@ -44,7 +42,7 @@
</svg>
<div>
<strong>Serverfehler.</strong><br />
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
<span style="font-size:0.85rem;">Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es später erneut.</span>
</div>
</div>
}
@@ -120,7 +118,7 @@
disabled="@(IsLoading || string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))">
@if (IsLoading) {
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
<span>Anmelden …</span>
<span>Anmelden …</span>
} else {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="me-2" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M10 3.5a.5.5 0 0 0-.5-.5h-8a.5.5 0 0 0-.5.5v9a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5v-2a.5.5 0 0 1 1 0v2A1.5 1.5 0 0 1 9.5 14h-8A1.5 1.5 0 0 1 0 12.5v-9A1.5 1.5 0 0 1 1.5 2h8A1.5 1.5 0 0 1 11 3.5v2a.5.5 0 0 1-1 0v-2z"/>

View File

@@ -34,6 +34,10 @@ builder.Services.AddScoped<SignatureCacheService>();
builder.Services.AddSingleton<AppVersionService>();
builder.Services.AddScoped<EnvelopeService>();
// UI Preferences & Theme
builder.Services.AddScoped<UserPreferencesService>();
builder.Services.AddScoped<ThemeService>();
// DevExpress WASM
builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer();
builder.Services.AddDevExpressWebAssemblyBlazorReportViewer();

View File

@@ -0,0 +1,232 @@
using Microsoft.JSInterop;
using Microsoft.Extensions.Logging;
using EnvelopeGenerator.Application.Common.Dto;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Manages the active DevExpress theme for the WASM client.
///
/// Theme selection is the sole UI control — no separate dark/light toggle.
/// Themes ordered dark → light: blazing-dark | blazing-berry | purple
///
/// Initialization:
/// 1. InitializeAsync() — reads localStorage["signflow.theme"], falls back to
/// system prefers-color-scheme (dark → blazing-dark, light → blazing-berry).
/// 2. LoadFromServerAsync() — called after login, syncs theme from server cache.
///
/// Persistence:
/// - SetThemeAsync() → debounced 500ms PUT /api/UserPreferences.
/// - ScheduleSaveAsync() uses GetCached() — no extra GET before each save.
///
/// Consumers subscribe to OnChange and call StateHasChanged (e.g. MainLayout).
/// </summary>
public class ThemeService : IAsyncDisposable
{
// ── Allowed themes (ordered dark → light for range slider) ───────────────
// DevExpress.Blazor.Themes 25.2.3: blazing-berry, blazing-dark, purple
public static readonly IReadOnlyList<ThemeOption> AvailableThemes =
[
new("Blazing Dark", "blazing-dark"), // index 0 — 🌙
new("Blazing Berry", "blazing-berry"), // index 1 — middle
new("Purple", "purple"), // index 2 — ☀️
];
private const string LocalStorageThemeKey = "signflow.theme";
// ── State ─────────────────────────────────────────────────────────────────
private string _themeName = "blazing-berry";
private bool _initialized = false;
// ── Dependencies ──────────────────────────────────────────────────────────
private readonly IJSRuntime _js;
private readonly UserPreferencesService _preferencesService;
private readonly ILogger<ThemeService> _logger;
// ── Debounce ──────────────────────────────────────────────────────────────
private CancellationTokenSource? _saveCts;
// ── Change & error notification ───────────────────────────────────────────
/// <summary>Fired whenever theme changes. Subscribers call StateHasChanged.</summary>
public event Action? OnChange;
/// <summary>
/// Set when a background save fails. UI layer can surface this as a warning.
/// Cleared on the next successful save or theme change.
/// </summary>
public Exception? SaveError { get; private set; }
public ThemeService(IJSRuntime js, UserPreferencesService preferencesService, ILogger<ThemeService> logger)
{
_js = js;
_preferencesService = preferencesService;
_logger = logger;
}
// ── Public state ──────────────────────────────────────────────────────────
public string ThemeName => _themeName;
/// <summary>True when blazing-dark is active — used for Bootstrap data-bs-theme.</summary>
public bool IsDarkMode => _themeName == "blazing-dark";
public string? DisplayName { get; private set; }
/// <summary>
/// CSS href for the active DevExpress theme.
/// Example: "_content/DevExpress.Blazor.Themes/blazing-dark.bs5.min.css"
/// </summary>
public string ThemeCssHref =>
$"_content/DevExpress.Blazor.Themes/{_themeName}.bs5.min.css";
/// <summary>Bootstrap 5 data-bs-theme value. Only "dark" for blazing-dark.</summary>
public string BootstrapTheme => _themeName == "blazing-dark" ? "dark" : "light";
// ── Initialization ────────────────────────────────────────────────────────
/// <summary>
/// Called once in OnAfterRenderAsync(firstRender: true).
/// Reads theme from localStorage; falls back to system dark-mode preference.
/// Safe to call multiple times — subsequent calls are no-ops.
/// </summary>
public async Task InitializeAsync()
{
if (_initialized) return;
_initialized = true;
var stored = await _js.InvokeAsync<string?>("localStorage.getItem", LocalStorageThemeKey);
if (stored is not null && AvailableThemes.Any(t => t.Value == stored))
{
_themeName = stored;
}
else
{
// No stored theme — use system preference to pick dark or light
var prefersDark = await _js.InvokeAsync<bool>(
"eval",
"(function(){ return window.matchMedia('(prefers-color-scheme: dark)').matches; })()");
_themeName = prefersDark ? "blazing-dark" : "blazing-berry";
}
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
}
/// <summary>
/// Called after successful sender login.
/// Syncs theme from the server cache; on first login propagates local theme to server.
/// </summary>
public async Task LoadFromServerAsync(CancellationToken ct = default)
{
var prefs = await _preferencesService.GetAsync(ct);
var isFirstLogin = prefs.GridLayouts.Count == 0
&& prefs.ThemeName == "blazing-berry"
&& !prefs.IsDarkMode;
if (isFirstLogin && _themeName != "blazing-berry")
{
// Propagate locally-detected theme to server on first login
prefs.ThemeName = _themeName;
prefs.IsDarkMode = IsDarkMode;
prefs = await _preferencesService.SaveAsync(prefs, ct);
}
ApplyPreferences(prefs);
// Persist resolved theme to localStorage
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
DisplayName = prefs.DisplayName;
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
}
// ── Mutation ──────────────────────────────────────────────────────────────
/// <summary>
/// Sets the active theme, applies it immediately, and schedules a server save.
/// State change is applied regardless of whether the save succeeds.
/// </summary>
public async Task SetThemeAsync(string themeName)
{
if (themeName == _themeName) return;
if (!AvailableThemes.Any(t => t.Value == themeName))
throw new ArgumentException($"Theme '{themeName}' is not allowed.", nameof(themeName));
_themeName = themeName;
SaveError = null;
await _js.InvokeVoidAsync("localStorage.setItem", LocalStorageThemeKey, _themeName);
await ApplyThemeToDocumentAsync();
OnChange?.Invoke();
await ScheduleSaveAsync();
}
// ── Private helpers ───────────────────────────────────────────────────────
private void ApplyPreferences(UserPreferencesDto prefs)
{
_themeName = AvailableThemes.Any(t => t.Value == prefs.ThemeName)
? prefs.ThemeName
: "blazing-berry";
// Legacy: if IsDarkMode flag was saved but ThemeName wasn't updated
if (prefs.IsDarkMode && _themeName == "blazing-berry")
_themeName = "blazing-dark";
}
private async Task ApplyThemeToDocumentAsync()
{
try
{
await _js.InvokeVoidAsync("signflowTheme.apply", ThemeCssHref, BootstrapTheme, _themeName);
}
catch (Exception ex)
{
// JS unavailable during server prerender — <HeadContent> link is the SSR fallback.
_logger.LogDebug(ex, "[ThemeService] ApplyThemeToDocumentAsync skipped (JS unavailable).");
}
}
private async Task ScheduleSaveAsync()
{
_saveCts?.Cancel();
_saveCts = new CancellationTokenSource();
try
{
await Task.Delay(500, _saveCts.Token);
var prefs = _preferencesService.GetCached() ?? new UserPreferencesDto();
prefs.ThemeName = _themeName;
prefs.IsDarkMode = IsDarkMode;
await _preferencesService.SaveAsync(prefs, _saveCts.Token);
}
catch (TaskCanceledException)
{
// Newer save scheduled — intentionally cancelled.
}
catch (Exception ex)
{
_logger.LogError(ex, "Background save of user preferences failed.");
SaveError = ex;
OnChange?.Invoke();
}
}
public ValueTask DisposeAsync()
{
_saveCts?.Cancel();
_saveCts?.Dispose();
return ValueTask.CompletedTask;
}
}
/// <summary>Describes a selectable DevExpress theme.</summary>
/// <param name="Label">User-facing display name.</param>
/// <param name="Value">CSS theme identifier.</param>
public sealed record ThemeOption(string Label, string Value);

View File

@@ -0,0 +1,136 @@
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.Common.Dto;
using Microsoft.Extensions.Logging;
namespace EnvelopeGenerator.Server.Client.Services;
/// <summary>
/// Retrieves and persists user UI preferences via the server API.
/// The server stores preferences in the distributed cache (dbo.TBDD_CACHE).
///
/// Client-side memory cache:
/// Preferences are cached in-memory for <see cref="CacheDuration"/> after the first fetch.
/// All reads (GetAsync, GetCachedAsync) are served from cache after the first load.
/// Writes (SaveAsync) update both the cache and the server atomically.
/// Cache is invalidated explicitly via InvalidateCache().
/// </summary>
public class UserPreferencesService(
IHttpClientFactory clientFactory,
ILogger<UserPreferencesService> logger)
{
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
private const string Endpoint = "/api/UserPreferences";
// ── Client-side memory cache ──────────────────────────────────────────────
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1);
private UserPreferencesDto? _cached;
private DateTimeOffset _cachedAt = DateTimeOffset.MinValue;
private bool IsCacheValid => _cached is not null
&& DateTimeOffset.UtcNow - _cachedAt < CacheDuration;
/// <summary>
/// Returns preferences from in-memory cache if valid; otherwise fetches from the server.
/// On first call after login this issues one HTTP GET — all subsequent calls within
/// <see cref="CacheDuration"/> are served from memory with zero network overhead.
/// </summary>
public async Task<UserPreferencesDto> GetAsync(CancellationToken ct = default)
{
if (IsCacheValid)
return _cached!;
try
{
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.GetAsync(Endpoint, ct);
// Not authenticated yet (login page) or no preferences saved — return defaults.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound ||
response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
SetCache(new UserPreferencesDto());
return _cached!;
}
response.EnsureSuccessStatusCode();
var dto = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct)
?? new UserPreferencesDto();
SetCache(dto);
return _cached!;
}
catch (Exception ex) when (ex is not TaskCanceledException)
{
logger.LogError(ex, "Failed to load user preferences from server.");
throw;
}
}
/// <summary>
/// Returns the cached preferences without hitting the network.
/// Returns null if the cache has not been populated yet (before first GetAsync call).
/// </summary>
public UserPreferencesDto? GetCached() => IsCacheValid ? _cached : null;
/// <summary>
/// Persists the given preferences on the server and updates the in-memory cache.
/// </summary>
public async Task<UserPreferencesDto> SaveAsync(UserPreferencesDto preferences, CancellationToken ct = default)
{
try
{
var client = clientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await client.PutAsJsonAsync(Endpoint, preferences, _jsonOptions, ct);
// Not authenticated (login page) — skip save, keep local state only.
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
return preferences;
response.EnsureSuccessStatusCode();
var saved = await response.Content.ReadFromJsonAsync<UserPreferencesDto>(_jsonOptions, ct)
?? preferences;
SetCache(saved);
return saved;
}
catch (Exception ex) when (ex is not TaskCanceledException)
{
logger.LogError(ex, "Failed to save user preferences to server.");
throw;
}
}
/// <summary>
/// Updates a single grid layout key without a network read.
/// Uses the in-memory cache for the current state, merges the new layout, then saves.
/// If the cache is empty (cold start), fetches from server first.
/// </summary>
/// <param name="gridKey">Stable grid identifier, e.g. "sender.active-envelopes".</param>
/// <param name="layoutJson">Opaque layout JSON produced by DxGrid.SaveLayoutAsync().</param>
public async Task SaveGridLayoutAsync(string gridKey, string layoutJson, CancellationToken ct = default)
{
// Use cached copy — avoids a GET before every grid layout save.
var prefs = GetCached() ?? await GetAsync(ct);
prefs.GridLayouts[gridKey] = layoutJson;
await SaveAsync(prefs, ct);
}
/// <summary>
/// Invalidates the in-memory cache, forcing the next GetAsync to fetch from the server.
/// Call this after logout or when a forced refresh is needed.
/// </summary>
public void InvalidateCache()
{
_cached = null;
_cachedAt = DateTimeOffset.MinValue;
}
private void SetCache(UserPreferencesDto dto)
{
_cached = dto;
_cachedAt = DateTimeOffset.UtcNow;
}
}

View File

@@ -17,6 +17,7 @@
<script src="_content/DevExpress.Blazor.Resources/js/preload-script.js"></script>
<script src="js/typed.umd.js"></script>
<script src="js/receiver-signature.js?v=9"></script>
<script src="js/signflow-theme.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>

View File

@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Server.Extensions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
/// Manages per-user UI preferences (theme, dark mode, grid layouts).
/// Preferences are stored in the distributed cache (dbo.TBDD_CACHE).
/// Accessible by both Sender and Receiver roles.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = AuthPolicy.SenderOrReceiver)]
public class UserPreferencesController(IUserPreferencesService preferencesService) : ControllerBase
{
private static readonly HashSet<string> AllowedThemes =
["blazing-berry", "blazing-dark", "purple"];
/// <summary>
/// Returns the current user's UI preferences.
/// Always returns 200 with defaults if no preferences have been saved yet.
/// DisplayName is populated from JWT claims for senders only.
/// </summary>
[HttpGet]
public async Task<ActionResult<UserPreferencesDto>> Get(CancellationToken ct)
{
bool isReceiver = User.IsReceiver();
var userId = isReceiver ? User.ReceiverMail() : User.GetUsername();
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var prefs = await preferencesService.GetOrCreateAsync(userId, ct);
// Populate DisplayName for sender only (receiver has no given/surname claims)
if (!isReceiver)
prefs.DisplayName = $"{User.GetPrename()} {User.GetName()}".Trim();
return Ok(prefs);
}
/// <summary>
/// Saves (full replace) the current user's UI preferences.
/// </summary>
[HttpPut]
public async Task<ActionResult<UserPreferencesDto>> Put(
[FromBody] UserPreferencesDto dto,
CancellationToken ct)
{
if (!AllowedThemes.Contains(dto.ThemeName))
return BadRequest($"Invalid theme '{dto.ThemeName}'. Allowed: {string.Join(", ", AllowedThemes)}.");
var userId = User.IsReceiver() ? User.ReceiverMail() : User.GetUsername();
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
await preferencesService.SaveAsync(userId, dto, ct);
return Ok(dto);
}
}

View File

@@ -1,4 +1,6 @@
using DigitalData.Auth.Claims;
using DigitalData.UserManager.Domain.Entities;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.IdentityModel.JsonWebTokens;
using System.Security.Claims;
@@ -93,4 +95,14 @@ public static class ReceiverClaimExtensions
else
throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.ReceiverId}' is not a valid integer.");
}
/// <summary>
///
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
public static bool IsReceiver(this ClaimsPrincipal user)
{
return Role.Receiver.Authorized.Any(user.IsInRole);
}
}

View File

@@ -271,7 +271,12 @@ try
.AddAuthenticationSchemes(AuthScheme.Receiver)
.RequireAuthenticatedUser()
.RequireRole(Role.Receiver.Full, "receiver"))
.AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA));
.AddPolicy(AuthPolicy.ReceiverTFA, policy => policy.RequireRole(Role.Receiver.TFA))
// Combined policy: accepts both Sender and Receiver tokens.
// Used by endpoints accessible to both roles (e.g. UserPreferences).
.AddPolicy(AuthPolicy.SenderOrReceiver, policy => policy
.AddAuthenticationSchemes(AuthScheme.Sender, AuthScheme.Receiver)
.RequireAuthenticatedUser());
// User Manager
#pragma warning disable CS0618
@@ -318,6 +323,16 @@ try
.AddEnvelopeGeneratorServices(config);
#pragma warning restore CS0618
// User Preferences (theme, dark mode, grid layouts — stored in TBDD_CACHE)
// Registration handled by Infrastructure DependencyInjection (AddEnvelopeGeneratorInfrastructureServices)
// Client-side services that MainLayout depends on must also be registered here
// so that Blazor Auto prerender (SSR pass) can resolve them on the server.
// ThemeService.InitializeAsync() uses IJSRuntime but is only called in OnAfterRenderAsync
// (never during prerender), so registering it server-side is safe.
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
// Business Services (Server specific)
builder.Services.AddScoped<DocumentService>();
builder.Services.AddScoped<AuthService>();

View File

@@ -342,7 +342,7 @@ article {
height: 36px;
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between; /* ← left group | right group */
gap: 10px;
padding: 0 20px;
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%);
@@ -363,6 +363,224 @@ article {
text-decoration: underline;
}
.receiver-footer__left {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.receiver-footer__sep {
opacity: 0.4;
}
/* ── Footer: Theme Slider ────────────────────────────────────────────────── */
/* 🌙 blazing-dark ──◇──·──◇── purple ☀️ */
/* Left group: copyright | datenschutz → Right group: slider */
.sf-theme-slider {
display: flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
}
/* Moon / Sun SVG icons */
.sf-theme-icon {
display: block;
opacity: 0.55; /* ↑ was 0.42 — more visible as base */
flex-shrink: 0;
transition: opacity 0.18s;
align-self: center;
}
.sf-theme-icon--active {
opacity: 0.92;
}
.sf-theme-icon--mid {
opacity: 0.62;
}
/* ── Wrapper holds the range input + tick marks ───────────────────────────── */
.sf-theme-track-wrapper {
position: relative;
display: flex;
align-items: center;
width: 80px;
height: 16px; /* enough height so ticks + thumb don't clip */
}
/* Actual range input — fills the wrapper */
.sf-theme-range {
-webkit-appearance: none;
appearance: none;
position: relative;
z-index: 1;
width: 100%;
height: 2px;
background: rgba(255, 255, 255, 0.28);
border-radius: 1px;
outline: none;
cursor: pointer;
margin: 0;
/* no extra padding so ticks align correctly */
}
.sf-theme-range:hover {
background: rgba(255, 255, 255, 0.42);
}
/* Thumb — white circle */
.sf-theme-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 11px;
height: 11px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.90);
cursor: pointer;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
transition: transform 0.12s, box-shadow 0.12s;
}
.sf-theme-range::-webkit-slider-thumb:hover {
transform: scale(1.25);
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.38);
}
/* Firefox */
.sf-theme-range::-moz-range-thumb {
width: 11px;
height: 11px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.90);
cursor: pointer;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
}
.sf-theme-range::-moz-range-track {
height: 2px;
background: rgba(255, 255, 255, 0.28);
border-radius: 1px;
}
/* ── End ticks (left & right): thin vertical bars ────────────────────────── */
.sf-theme-tick {
position: absolute;
z-index: 0;
width: 2px;
height: 7px;
border-radius: 1px;
background: rgba(255, 255, 255, 0.45);
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
.sf-theme-tick--left { left: 0; }
.sf-theme-tick--right { right: 0; }
/* Mid tick — thinner, subtler */
.sf-theme-tick--mid {
left: 50%;
transform: translate(-50%, -50%);
width: 1px;
height: 5px;
background: rgba(255, 255, 255, 0.50); /* ↑ was 0.22 */
}
/* Sun icon: always visible regardless of active state */
.sf-theme-icon--sun {
opacity: 0.72; /* minimum visibility */
}
/* ── Theme overrides: Home page & Login ──────────────────────────────────── */
/* selector: [data-sf-theme="<name>"] on <html>, set by signflow-theme.js */
/* ── Purple ── */
[data-sf-theme="purple"] .home-page-wrapper {
background: linear-gradient(160deg, #1a0533 0%, #3b0764 45%, #6b21a8 100%);
}
[data-sf-theme="purple"] .home-btn-primary {
background: linear-gradient(135deg, #6b21a8 0%, #9333ea 100%);
}
[data-sf-theme="purple"] .home-feature-badge {
background: rgba(147, 51, 234, 0.10);
color: #c084fc;
border-color: rgba(147, 51, 234, 0.25);
}
/* Login card header: set via a CSS rule so inline gradient can be overridden */
.login-card .card-header {
background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%) !important;
}
[data-sf-theme="purple"] .login-card .card-header {
background: linear-gradient(135deg, #1a0533 0%, #6b21a8 100%) !important;
}
[data-sf-theme="blazing-dark"] .home-page-wrapper {
background: linear-gradient(160deg, #0a0f1e 0%, #111827 50%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .home-btn-primary {
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%);
}
[data-sf-theme="blazing-dark"] .home-feature-badge {
background: rgba(99, 102, 241, 0.12);
color: #a5b4fc;
border-color: rgba(99, 102, 241, 0.28);
}
[data-sf-theme="blazing-dark"] .home-card {
background: rgba(15, 20, 40, 0.92) !important;
color: rgba(255, 255, 255, 0.85) !important;
}
[data-sf-theme="blazing-dark"] .home-card .text-muted {
color: rgba(255, 255, 255, 0.55) !important;
}
[data-sf-theme="blazing-dark"] .login-card {
background: rgba(15, 20, 40, 0.95) !important;
color: rgba(255, 255, 255, 0.85) !important;
}
[data-sf-theme="blazing-dark"] .login-card .card-header {
background: linear-gradient(135deg, #0a0f1e 0%, #1e1b4b 100%) !important;
}
[data-sf-theme="blazing-dark"] .login-card .card-body {
background: rgba(15, 20, 40, 0.95);
}
[data-sf-theme="blazing-dark"] .login-card .card-footer {
background: rgba(15, 20, 40, 0.95) !important;
color: rgba(255, 255, 255, 0.45) !important;
}
[data-sf-theme="blazing-dark"] .login-card .form-label {
color: rgba(255, 255, 255, 0.75);
}
[data-sf-theme="blazing-dark"] .login-card .form-control,
[data-sf-theme="blazing-dark"] .login-card .input-group-text {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.85);
}
[data-sf-theme="blazing-dark"] .login-card .btn-primary {
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%) !important;
border-color: transparent !important;
}
[data-sf-theme="blazing-dark"] .login-card .text-muted {
color: rgba(255, 255, 255, 0.50) !important;
}

View File

@@ -1103,3 +1103,111 @@ body.resizing {
padding: 1.5rem;
}
}
/* ── Theme overrides: Envelope Viewer / Editor / Receiver ───────────────── */
/* selector: [data-sf-theme="<name>"] on <html>, set by signflow-theme.js */
/* ── Purple ─────────────────────────────────────────────────────────────── */
[data-sf-theme="purple"] .envelope-viewer-layout {
background: linear-gradient(135deg, #1a0533 0%, #3b0764 50%, #6b21a8 100%);
}
[data-sf-theme="purple"] .envelope-action-bar {
border-bottom-color: rgba(147, 51, 234, 0.4);
}
[data-sf-theme="purple"] .envelope-logo svg {
color: #9333ea;
filter: drop-shadow(0 2px 4px rgba(147, 51, 234, 0.35));
}
[data-sf-theme="purple"] .pdf-toolbar__btn {
border-color: rgba(147, 51, 234, 0.25);
}
[data-sf-theme="purple"] .pdf-toolbar__btn:hover {
border-color: rgba(147, 51, 234, 0.5);
box-shadow: 0 2px 8px rgba(147, 51, 234, 0.2);
}
[data-sf-theme="purple"] .pdf-text-layer ::selection,
[data-sf-theme="purple"] .pdf-text-layer ::-moz-selection {
background: rgba(147, 51, 234, 0.3);
}
/* ── Blazing Dark ───────────────────────────────────────────────────────── */
[data-sf-theme="blazing-dark"] .envelope-viewer-layout {
background: linear-gradient(135deg, #0a0f1e 0%, #111827 55%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .envelope-action-bar {
background: rgba(20, 20, 35, 0.92);
border-bottom-color: rgba(99, 102, 241, 0.35);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
[data-sf-theme="blazing-dark"] .envelope-title {
color: rgba(255, 255, 255, 0.92);
}
[data-sf-theme="blazing-dark"] .envelope-key {
color: rgba(255, 255, 255, 0.45);
}
[data-sf-theme="blazing-dark"] .envelope-logo svg {
color: #818cf8;
filter: drop-shadow(0 2px 4px rgba(129, 140, 248, 0.3));
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.82);
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn:hover {
background: rgba(129, 140, 248, 0.15);
border-color: rgba(129, 140, 248, 0.4);
color: #fff;
}
[data-sf-theme="blazing-dark"] .pdf-toolbar__btn--signature-change-active {
background: rgba(99, 102, 241, 0.25);
border-color: rgba(99, 102, 241, 0.5);
color: #a5b4fc;
}
/* Error container */
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
/* Receiver info header */
[data-sf-theme="blazing-dark"] .receiver-info-header__gradient {
background: linear-gradient(135deg, #0a0f1e 0%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .receiver-info-message {
color: rgba(255, 255, 255, 0.70);
border-bottom-color: rgba(255, 255, 255, 0.06);
}
[data-sf-theme="blazing-dark"] .receiver-info-private-message {
background: rgba(245, 158, 11, 0.08);
border-top-color: rgba(245, 158, 11, 0.18);
color: #fde68a;
}
/* Signature action bar */
[data-sf-theme="blazing-dark"] .receiver-action-bar {
background: rgba(20, 20, 35, 0.92);
border-bottom-color: rgba(255, 255, 255, 0.06);
}
/* PDF text selection */
[data-sf-theme="blazing-dark"] .pdf-text-layer ::selection,
[data-sf-theme="blazing-dark"] .pdf-text-layer ::-moz-selection {
background: rgba(99, 102, 241, 0.35);
}

View File

@@ -295,3 +295,146 @@
font-size: 0.813rem;
}
}
/* ── Theme overrides ─────────────────────────────────────────────────────── */
/* Selector: [data-sf-theme="<name>"] set on <html> by signflow-theme.js */
/* ── Purple theme ──────────────────────────────────────────────────────────
Deeper violet, less blue — visually distinct from Blazing Berry */
[data-sf-theme="purple"] .sender-dashboard-layout {
background: linear-gradient(135deg, #1a0533 0%, #3b0764 50%, #6b21a8 100%);
}
[data-sf-theme="purple"] .sender-grid-container::before {
background: linear-gradient(90deg, #9333ea 0%, #6b21a8 100%);
}
[data-sf-theme="purple"] .sender-action-bar {
border-bottom-color: rgba(147, 51, 234, 0.4);
}
[data-sf-theme="purple"] .sender-logo svg {
color: #9333ea;
filter: drop-shadow(0 2px 4px rgba(147, 51, 234, 0.35));
}
[data-sf-theme="purple"] .sender-btn--primary {
background: linear-gradient(135deg, #9333ea 0%, #6b21a8 100%);
}
[data-sf-theme="purple"] .sender-btn--primary:hover:not(:disabled) {
background: linear-gradient(135deg, #7e22ce 0%, #581c87 100%);
box-shadow: 0 4px 16px rgba(147, 51, 234, 0.35);
}
[data-sf-theme="purple"] .sender-btn:hover:not(:disabled) {
border-color: rgba(147, 51, 234, 0.45);
box-shadow: 0 4px 12px rgba(147, 51, 234, 0.22);
}
[data-sf-theme="purple"] .sender-tabs {
border-bottom-color: rgba(147, 51, 234, 0.12);
}
[data-sf-theme="purple"] .sender-tab--active {
color: #9333ea;
border-bottom-color: #9333ea;
}
/* ── Blazing Dark theme ────────────────────────────────────────────────────
Dark navy background, frosted glass surfaces turn semi-dark */
[data-sf-theme="blazing-dark"] .sender-dashboard-layout {
background: linear-gradient(135deg, #0a0f1e 0%, #111827 55%, #1e1b4b 100%);
}
[data-sf-theme="blazing-dark"] .sender-action-bar {
background: rgba(20, 20, 35, 0.92);
border-bottom-color: rgba(99, 102, 241, 0.35);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
[data-sf-theme="blazing-dark"] .sender-title {
color: rgba(255, 255, 255, 0.92);
}
[data-sf-theme="blazing-dark"] .sender-logo svg {
color: #818cf8;
filter: drop-shadow(0 2px 4px rgba(129, 140, 248, 0.3));
}
[data-sf-theme="blazing-dark"] .sender-btn {
color: rgba(255, 255, 255, 0.82);
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.12);
}
[data-sf-theme="blazing-dark"] .sender-btn:hover:not(:disabled) {
background: rgba(129, 140, 248, 0.15);
border-color: rgba(129, 140, 248, 0.4);
color: #fff;
box-shadow: 0 4px 12px rgba(129, 140, 248, 0.2);
}
[data-sf-theme="blazing-dark"] .sender-btn:disabled {
color: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.06);
}
[data-sf-theme="blazing-dark"] .sender-btn--primary {
background: linear-gradient(135deg, #6366f1 0%, #4338ca 100%);
color: white;
border-color: transparent;
}
[data-sf-theme="blazing-dark"] .sender-btn--primary:hover:not(:disabled) {
background: linear-gradient(135deg, #818cf8 0%, #6366f1 100%);
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4);
}
[data-sf-theme="blazing-dark"] .sender-btn--danger {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.28);
color: #fca5a5;
}
[data-sf-theme="blazing-dark"] .sender-btn--danger:hover:not(:disabled) {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
border-color: transparent;
}
[data-sf-theme="blazing-dark"] .sender-grid-container {
background: rgba(15, 20, 40, 0.95);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06);
}
[data-sf-theme="blazing-dark"] .sender-grid-container::before {
background: linear-gradient(90deg, #6366f1 0%, #4338ca 100%);
}
[data-sf-theme="blazing-dark"] .sender-tabs {
background: rgba(255, 255, 255, 0.03);
border-bottom-color: rgba(99, 102, 241, 0.12);
}
[data-sf-theme="blazing-dark"] .sender-tab {
color: rgba(255, 255, 255, 0.45);
}
[data-sf-theme="blazing-dark"] .sender-tab--active {
color: #818cf8;
border-bottom-color: #818cf8;
}
[data-sf-theme="blazing-dark"] .sender-tab:hover:not(.sender-tab--active) {
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.04);
}
/* Error container in dark mode */
[data-sf-theme="blazing-dark"] .error-container .alert-danger {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}

View File

@@ -0,0 +1,37 @@
/**
* signFLOW Theme Helper
* Called from ThemeService (Blazor WASM) to apply theme changes.
*/
window.signflowTheme = {
apply: function (href, bsTheme, themeName) {
console.debug('[signflowTheme] apply', themeName, bsTheme);
// ── Bootstrap dark mode ─────────────────────────────────────────
document.documentElement.setAttribute('data-bs-theme', bsTheme);
// ── Theme name attribute for CSS overrides ──────────────────────
// Applied to <html> so any nested selector can use it:
// [data-sf-theme="blazing-dark"] .sender-dashboard-layout { ... }
document.documentElement.setAttribute('data-sf-theme', themeName);
// ── DevExpress theme CSS link ────────────────────────────────────
document.querySelectorAll('link[href*="DevExpress.Blazor.Themes"]').forEach(function(el) {
if (el.id !== 'dx-theme-link') el.parentNode.removeChild(el);
});
let link = document.getElementById('dx-theme-link');
if (!link) {
link = document.createElement('link');
link.id = 'dx-theme-link';
link.rel = 'stylesheet';
document.head.appendChild(link);
}
const currentRelative = link.getAttribute('href') || '';
if (currentRelative !== href) {
link.setAttribute('href', href);
}
}
};

View File

@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net4.6.2</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-EnvelopeGenerator.Service-816256a4-c636-4313-92c0-b62b04040c2b</UserSecretsId>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="7.0.0" />
<PackageReference Include="NLog" Version="5.3.1" />
<PackageReference Include="NLog.Extensions.Logging" Version="5.3.9" />
</ItemGroup>
<!-- DigitalData & Quartz: local packages, must match CommonServices versions exactly -->
<ItemGroup>
<Reference Include="DigitalData.Modules.Database">
<HintPath>..\packages\DigitalData.Modules.Database.2.3.5.4\lib\net462\DigitalData.Modules.Database.dll</HintPath>
</Reference>
<Reference Include="DigitalData.Modules.Encryption">
<HintPath>..\packages\DigitalData.Modules.Encryption.1.3.1\lib\net462\DigitalData.Modules.Encryption.dll</HintPath>
</Reference>
<Reference Include="DigitalData.Modules.Logging">
<HintPath>..\packages\DigitalData.Modules.Logging.2.6.5\lib\net462\DigitalData.Modules.Logging.dll</HintPath>
</Reference>
<!-- Quartz 3.8.0 <20> must match CommonServices (same AppDomain, one version) -->
<Reference Include="Quartz">
<HintPath>..\packages\Quartz.3.8.0\lib\net462\Quartz.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.CommonServices\EnvelopeGenerator.CommonServices.vbproj" />
<ProjectReference Include="..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,28 @@
using DigitalData.Modules.Logging;
namespace EnvelopeGenerator.Service;
/// <summary>
/// Builds a <see cref="LogConfig"/> from <see cref="ServiceConfig"/>.
/// Required because CommonServices jobs have a hard dependency on <see cref="LogConfig"/>
/// in their Quartz <c>JobDataMap</c>.
/// Log path mirrors the legacy behavior: <c>&lt;BaseDirectory&gt;\Log</c>.
/// </summary>
internal static class LogConfigFactory
{
public static LogConfig Create(ServiceConfig config)
{
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log");
var logConfig = new LogConfig(
LogConfig.PathType.CustomPath,
logPath,
null,
"Digital Data",
"EnvelopeGenerator.Service");
logConfig.Debug = config.Debug;
return logConfig;
}
}

View File

@@ -0,0 +1,315 @@
# Migration Plan: EnvelopeGenerator.Service_legacy ? EnvelopeGenerator.Service
**Revision v3** — Full deep analysis complete
## Overview
Migrate the legacy VB.NET Windows Service (`EnvelopeGenerator.Service_legacy`) to a modern
C# Worker Service (`EnvelopeGenerator.Service`) targeting **.NET Framework 4.6.2**, using
`Microsoft.Extensions.Hosting`, `IConfiguration`, `ILogger<T>` and **NLog**.
---
## Deep Analysis Findings (vs. v1/v2)
### Finding 1 — Connection String: Plain Text, No Encryption Required
`FinalizeDocumentJob` and `APIEnvelopeJob` call `MSSQLServer.DecryptConnectionString()` on the
`JobDataMap[Value.DATABASE]` value. However, `MSSQLServer.DecryptConnectionString()` is a pass-through
for plain-text strings — no wrapping/encryption step is needed.
**Resolution**: The connection string is read from `IConfiguration` via the standard
`ConnectionStrings:Default` key (same key as `appsettings.Database.json` in `EnvelopeGenerator.Web`).
It is passed **as-is** to both the Worker-level `MSSQLServer` and the `JobDataMap`. No
`EncryptConnectionString()` call is made anywhere in the new service.
### Finding 2 — Dual TempFiles Architecture
There are **two independent** `TempFiles` classes:
- `EnvelopeGenerator.CommonServices.TempFiles` — used **inside each job** via `New TempFiles(LogConfig)`.
This is called on every job execution and manages the job's own temp lifecycle. It expects `LogConfig`.
**We do not touch this.**
- `EnvelopeGenerator.Service_legacy.TempFiles` — used at the **service/host level** (startup cleanup,
shutdown cleanup). This is the one we rewrite in C# in the Service project using `ILogger<TempFiles>`.
### Finding 3 — LogConfig is a Hard Dependency of All Jobs
`FinalizeDocumentJob.Execute()` and `APIEnvelopeJob.Execute()` both do:
```vb
LogConfig = pContext.MergedJobDataMap.Item(Value.LOGCONFIG)
Logger = LogConfig.GetLogger()
myTempFiles = New TempFiles(LogConfig) ' ? CommonServices.TempFiles
```
`LogConfig` is **not optional**. The schedulers must construct a valid `LogConfig` and place it in the
`JobDataMap`. It must carry the correct log path, debug flag, and application identity.
**Resolution**: A `LogConfigFactory` static helper class will be created in the Service project to
build a `LogConfig` from `ServiceConfig` values:
- `LogPath` = `Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log")` (mirrors legacy `My.Application.Info.DirectoryPath + "\Log"`)
- `Debug` = `ServiceConfig.Debug`
- Application name = `"EnvelopeGenerator.Service"`
### Finding 4 — ProjectInstaller is Obsolete
`ProjectInstaller.vb` was the old `installutil.exe` mechanism for Windows Service registration.
With `AddWindowsService()` + `sc create` / PowerShell `New-Service`, this is **completely replaced**.
No equivalent is needed in the new project.
### Finding 5 — GDPicture License SQL
The legacy service queries: `SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME = 'GDPICTURE' and ACTIVE = 1`
This must be replicated exactly in the Worker startup sequence. If the result is null/empty, startup fails
with a descriptive exception (matching legacy behavior).
### Finding 6 — Quartz Version Mismatch
`CommonServices` uses `Quartz 3.8.0`. `Service_legacy` uses `Quartz 3.15.0`.
The new `EnvelopeGenerator.Service` must use **Quartz 3.8.0** to match `CommonServices` (same AppDomain,
same version must be loaded). The legacy version bump in `Service_legacy` was inconsistent.
---
## 1. Project File (`EnvelopeGenerator.Service.csproj`)
- Add **NuGet packages**:
- `NLog` (5.x, matching CommonServices' `NLog.5.0.5`)
- `NLog.Extensions.Logging` (latest net462-compatible)
- `Quartz` (**3.8.0** — must match CommonServices)
- `Microsoft.Extensions.Hosting.WindowsServices` (for `AddWindowsService()`)
- Add **project reference** to `EnvelopeGenerator.CommonServices` (vbproj)
- Add **project reference** to `EnvelopeGenerator.Domain` (csproj)
---
## 2. Configuration (`ServiceConfig.cs`)
```csharp
public class ServiceConfig
{
public string ConnectionString { get; set; } = string.Empty;
public bool Debug { get; set; } = false;
public int IntervalInMin { get; set; } = 1;
public PDFBurnerParams PDFBurnerParams { get; set; } = new PDFBurnerParams();
}
```
Connection string is read via the standard `ConnectionStrings:Default` key — consistent with
`appsettings.Database.json` used by `EnvelopeGenerator.Web`:
`appsettings.json`:
```json
{
"ConnectionStrings": {
"Default": ""
},
"ServiceConfig": {
"Debug": false,
"IntervalInMin": 1,
"PDFBurnerParams": {
"IgnoredLabels": [ "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" ],
"TopMargin": 0.1,
"YOffset": -0.3,
"FontName": "Arial",
"FontSize": 8,
"FontStyle": 2,
"TruncationSuffix": "..",
"TextMaxWidths": {
"position": 1.41,
"city": 1.41,
"date": 2.0
}
}
}
}
```
> `FontStyle` integer: Regular=0, Bold=1, **Italic=2**, Underline=4, Strikeout=8.
> All values mirror `PDFBurnerParams.vb` defaults exactly.
---
## 3. NLog Setup
**`nlog.config`**: File target ? `${basedir}/Log/service-${shortdate}.log`, async wrapper,
layout with timestamp + level + logger + message + exception.
**`Program.cs`**: Wire via `NLog.Extensions.Logging`:
```csharp
builder.Logging.ClearProviders();
builder.Logging.AddNLog("nlog.config");
```
---
## 4. LogConfigFactory (`LogConfigFactory.cs`)
New static helper — bridges `ServiceConfig` ? `LogConfig` (required by CommonServices jobs):
```csharp
internal static class LogConfigFactory
{
public static LogConfig Create(ServiceConfig config)
{
var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Log");
var logConfig = new LogConfig(
LogConfig.PathType.CustomPath, logPath, null,
"Digital Data", "EnvelopeGenerator.Service");
logConfig.Debug = config.Debug;
return logConfig;
}
}
```
This `LogConfig` instance is what gets placed into every `JobDataMap[Value.LOGCONFIG]`.
---
## 5. Quartz LogProvider (`QuartzLogProvider.cs`)
C# rewrite of `LogProvider.vb` — bridges Quartz `ILogProvider` ? `ILogger<T>`:
```csharp
internal class QuartzLogProvider : ILogProvider
{
private readonly ILogger _logger;
public QuartzLogProvider(ILogger logger) => _logger = logger;
public Logger GetLogger(string name) => (level, func, ex, parameters) => { ... };
// OpenNestedContext / OpenMappedContext ? NotImplementedException (same as legacy)
}
```
---
## 6. TempFiles (`TempFiles.cs`) — Service-level only
Service-level rewrite of `Service_legacy/TempFiles.vb` using `ILogger<TempFiles>`:
- `Create()` ? create `%TEMP%\EnvelopeGenerator`, or clean existing files
- `CleanUp()` ? delete the directory on service stop
> **Does NOT replace** `CommonServices.TempFiles` — that one continues to be used
> inside each job via `LogConfig`.
---
## 7. Schedulers
### `Schedulers/SchedulerFinishEnvelope.cs`
```csharp
public class SchedulerFinishEnvelope
{
// Constructor: ILogger<SchedulerFinishEnvelope>, IOptions<ServiceConfig>
// Start(licenseKey, logConfig):
// - Build JobDataMap:
// [GDPICTURE] = licenseKey
// [LOGCONFIG] = logConfig ? LogConfigFactory output
// [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString)
// [PDF_BURNER_PARAMS] = config.PDFBurnerParams
// - Schedule FinalizeDocumentJob with Quartz (interval from config)
// - Register QuartzLogProvider
// Stop(): Scheduler.Shutdown()
}
```
### `Schedulers/SchedulerEnvelopeTaskApi.cs`
```csharp
public class SchedulerEnvelopeTaskApi
{
// Constructor: ILogger<SchedulerEnvelopeTaskApi>, IOptions<ServiceConfig>
// Start(logConfig):
// - Build JobDataMap:
// [LOGCONFIG] = logConfig
// [DATABASE] = MSSQLServer.EncryptConnectionString(connectionString)
// - Schedule APIEnvelopeJob with Quartz (interval from config)
// - Register QuartzLogProvider
// Stop(): Scheduler.Shutdown()
}
```
---
## 8. Worker (`Worker.cs`)
Full lifecycle replacing the placeholder:
```
ExecuteAsync:
1. Read ServiceConfig from IOptions<ServiceConfig>
2. Validate ConnectionString ? throw if empty
3. Build LogConfig via LogConfigFactory
4. Connect to DB (plain connection string — Worker-level only)
5. Query: SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME='GDPICTURE' AND ACTIVE=1
? throw if null/empty (matches legacy behavior)
6. TempFiles.Create()
7. await SchedulerFinishEnvelope.Start(licenseKey, logConfig)
8. await Task.Delay(2500, stoppingToken) ? preserved from legacy
9. await SchedulerEnvelopeTaskApi.Start(logConfig)
10. await Task.Delay(Timeout.Infinite, stoppingToken) ? wait for cancellation
StopAsync override:
11. await SchedulerFinishEnvelope.Stop()
12. await SchedulerEnvelopeTaskApi.Stop()
13. TempFiles.CleanUp()
```
---
## 9. Program.cs
```csharp
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddNLog("nlog.config");
builder.Services.Configure<ServiceConfig>(
builder.Configuration.GetSection("ServiceConfig"));
builder.Services.AddSingleton<TempFiles>();
builder.Services.AddSingleton<SchedulerFinishEnvelope>();
builder.Services.AddSingleton<SchedulerEnvelopeTaskApi>();
builder.Services.AddHostedService<Worker>();
builder.Services.AddWindowsService(o => o.ServiceName = "EnvelopeGenerator.Service");
var host = builder.Build();
host.Run();
```
---
## 10. Files to Create / Modify
| File | Action | Notes |
|------|--------|-------|
| `EnvelopeGenerator.Service.csproj` | **Modify** | Add packages, project refs |
| `Program.cs` | **Modify** | Full DI/NLog/WindowsService setup |
| `Worker.cs` | **Modify** | Full service lifecycle |
| `ServiceConfig.cs` | **Create** | |
| `LogConfigFactory.cs` | **Create** | Critical bridge: ServiceConfig ? LogConfig |
| `TempFiles.cs` | **Create** | Service-level only; CommonServices.TempFiles untouched |
| `QuartzLogProvider.cs` | **Create** | |
| `Schedulers/SchedulerFinishEnvelope.cs` | **Create** | |
| `Schedulers/SchedulerEnvelopeTaskApi.cs` | **Create** | |
| `appsettings.json` | **Create** | All PDFBurnerParams explicit |
| `nlog.config` | **Create** | |
**Not created** (obsolete in new architecture):
- ~~`ProjectInstaller`~~ ? replaced by `sc create` / `New-Service`
- ~~`Service.Designer.vb`~~ ? BackgroundService handles this
- ~~`Service.resx`~~ ? not applicable
---
## 11. Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| **Quartz 3.8.0** (not 3.15.0) | Must match CommonServices; same AppDomain, one version |
| **Connection string plain-text, no encryption** | `MSSQLServer.DecryptConnectionString()` is a pass-through for plain strings; `ConnectionStrings:Default` key used (same convention as Web project) |
| **Connection string in `ConnectionStrings:Default`** | Consistent with rest of the solution (`appsettings.Database.json`); `ServiceConfig` holds only service-specific settings |
| **LogConfigFactory** | Jobs have hard dependency on `LogConfig` in JobDataMap; this is the clean bridge without leaking LogConfig into the DI container |
| **2500ms delay preserved** | Legacy behavior; ensures Scheduler1 is fully initialized before Scheduler2 starts |
| **`AddWindowsService()`** | Native SCM integration; replaces ProjectInstaller/installutil entirely |
| **NLog log path = BaseDirectory/Log** | Mirrors `My.Application.Info.DirectoryPath + "\Log"` from legacy |
| **CommonServices.TempFiles untouched** | Jobs create their own TempFiles internally via LogConfig; service-level TempFiles is separate concern |
---
## Approval Required
> Please review this plan and confirm before implementation begins.

View File

@@ -0,0 +1,41 @@
using EnvelopeGenerator.Service;
using EnvelopeGenerator.Service.Schedulers;
using Microsoft.Extensions.Options;
using NLog.Extensions.Logging;
var builder = Host.CreateApplicationBuilder(args);
// NLog
builder.Logging.ClearProviders();
builder.Logging.AddNLog("nlog.config");
// Configuration
builder.Services.Configure<ServiceConfig>(
builder.Configuration.GetSection("ServiceConfig"));
// Resolve connection string once for singleton registrations
var connectionString = builder.Configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("ConnectionStrings:Default is not configured.");
// Services
builder.Services.AddSingleton<TempFiles>();
builder.Services.AddSingleton<SchedulerFinishEnvelope>(sp => new SchedulerFinishEnvelope(
sp.GetRequiredService<ILogger<SchedulerFinishEnvelope>>(),
sp.GetRequiredService<IOptions<ServiceConfig>>(),
connectionString));
builder.Services.AddSingleton<SchedulerEnvelopeTaskApi>(sp => new SchedulerEnvelopeTaskApi(
sp.GetRequiredService<ILogger<SchedulerEnvelopeTaskApi>>(),
sp.GetRequiredService<IOptions<ServiceConfig>>(),
connectionString));
builder.Services.AddHostedService<Worker>();
// Windows Service (SCM) support — replaces legacy ProjectInstaller/installutil
builder.Services.AddWindowsService(options =>
options.ServiceName = "EnvelopeGenerator.Service");
var host = builder.Build();
host.Run();

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>bin\Release\net4.6.2\publish\</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<_TargetId>Folder</_TargetId>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,12 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"EnvelopeGenerator.Service": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,60 @@
using Quartz.Logging;
using QuartzLogLevel = Quartz.Logging.LogLevel;
namespace EnvelopeGenerator.Service;
/// <summary>
/// Bridges Quartz's <see cref="ILogProvider"/> to <see cref="ILogger"/>.
/// Mirrors the legacy <c>LogProvider.vb</c> behavior.
/// <c>OpenNestedContext</c> and <c>OpenMappedContext</c> are not implemented (same as legacy).
/// </summary>
internal sealed class QuartzLogProvider : ILogProvider
{
private readonly ILogger _logger;
public QuartzLogProvider(ILogger logger)
{
_logger = logger;
}
public Logger GetLogger(string name)
{
return (level, func, exception, parameters) =>
{
if (exception != null)
{
_logger.LogError(exception, exception.Message);
}
else if (func != null)
{
switch (level)
{
case QuartzLogLevel.Trace:
case QuartzLogLevel.Debug:
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug))
_logger.LogDebug(func(), parameters);
break;
case QuartzLogLevel.Info:
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Information))
_logger.LogInformation(func(), parameters);
break;
case QuartzLogLevel.Warn:
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Warning))
_logger.LogWarning(func(), parameters);
break;
case QuartzLogLevel.Error:
case QuartzLogLevel.Fatal:
if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Error))
_logger.LogError(func(), parameters);
break;
}
}
return true;
};
}
public IDisposable OpenNestedContext(string message) => throw new NotImplementedException();
public IDisposable OpenMappedContext(string key, object value, bool destructure = false) => throw new NotImplementedException();
}

View File

@@ -0,0 +1,94 @@
using System.Collections.Specialized;
using DigitalData.Modules.Logging;
using EnvelopeGenerator.CommonServices.Jobs;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.Extensions.Options;
using Quartz;
using Quartz.Logging;
namespace EnvelopeGenerator.Service.Schedulers;
/// <summary>
/// Schedules <see cref="APIEnvelopeJob"/> via Quartz.
/// C# rewrite of the legacy <c>Scheduler_Envelopetask_API.vb</c>.
/// </summary>
public sealed class SchedulerEnvelopeTaskApi
{
private readonly ILogger<SchedulerEnvelopeTaskApi> _logger;
private readonly ServiceConfig _config;
private readonly string _connectionString;
private IScheduler? _scheduler;
private const string JobName = "SendInvitationMailsAPI";
public SchedulerEnvelopeTaskApi(
ILogger<SchedulerEnvelopeTaskApi> logger,
IOptions<ServiceConfig> config,
string connectionString)
{
_logger = logger;
_config = config.Value;
_connectionString = connectionString;
}
public async Task Start(LogConfig logConfig)
{
try
{
_logger.LogDebug("Starting SchedulerEnvelopeTaskApi...");
LogProvider.SetCurrentLogProvider(new QuartzLogProvider(_logger));
_scheduler = await SchedulerBuilder
.Create(new NameValueCollection())
.UseDefaultThreadPool(x => x.MaxConcurrency = 5)
.BuildScheduler();
var jobKey = new JobKey(JobName);
var jobData = new JobDataMap
{
{ Value.LOGCONFIG, logConfig },
{ Value.DATABASE, _connectionString }
};
_logger.LogDebug("Initialized Job [{JobName}]", JobName);
var job = JobBuilder.Create<APIEnvelopeJob>()
.UsingJobData(jobData)
.WithIdentity(jobKey)
.Build();
var trigger = TriggerBuilder.Create()
.ForJob(jobKey)
.WithIdentity($"{JobName}-trigger")
.WithSimpleSchedule(s => s
.RepeatForever()
.WithIntervalInMinutes(_config.IntervalInMin))
.StartNow()
.Build();
_logger.LogDebug("{JobName}-trigger initialized", JobName);
await _scheduler.ScheduleJob(job, trigger);
_logger.LogDebug("{JobName} scheduled", JobName);
await _scheduler.Start();
_logger.LogInformation("{JobName} started", JobName);
}
catch (Exception ex)
{
_logger.LogError(ex, "SchedulerEnvelopeTaskApi failed to start");
throw;
}
}
public async Task Stop()
{
if (_scheduler is null) return;
_logger.LogInformation("Stopping SchedulerEnvelopeTaskApi...");
await _scheduler.Shutdown();
_logger.LogInformation("SchedulerEnvelopeTaskApi stopped!");
}
}

View File

@@ -0,0 +1,97 @@
using System.Collections.Specialized;
using DigitalData.Modules.Logging;
using EnvelopeGenerator.CommonServices.Jobs;
using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.Extensions.Options;
using Quartz;
using Quartz.Logging;
namespace EnvelopeGenerator.Service.Schedulers;
/// <summary>
/// Schedules <see cref="FinalizeDocumentJob"/> via Quartz.
/// C# rewrite of the legacy <c>Scheduler_FinishEnvelope.vb</c>.
/// </summary>
public sealed class SchedulerFinishEnvelope
{
private readonly ILogger<SchedulerFinishEnvelope> _logger;
private readonly ServiceConfig _config;
private readonly string _connectionString;
private IScheduler? _scheduler;
private const string JobName = "CertificateDocumentJob";
public SchedulerFinishEnvelope(
ILogger<SchedulerFinishEnvelope> logger,
IOptions<ServiceConfig> config,
string connectionString)
{
_logger = logger;
_config = config.Value;
_connectionString = connectionString;
}
public async Task Start(string licenseKey, LogConfig logConfig)
{
try
{
_logger.LogDebug("Starting SchedulerFinishEnvelope...");
LogProvider.SetCurrentLogProvider(new QuartzLogProvider(_logger));
_scheduler = await SchedulerBuilder
.Create(new NameValueCollection())
.UseDefaultThreadPool(x => x.MaxConcurrency = 5)
.BuildScheduler();
var jobKey = new JobKey(JobName);
var jobData = new JobDataMap
{
{ Value.GDPICTURE, licenseKey },
{ Value.LOGCONFIG, logConfig },
{ Value.DATABASE, _connectionString },
{ Value.PDF_BURNER_PARAMS, _config.PDFBurnerParams }
};
_logger.LogDebug("Initialized Job [{JobName}]", JobName);
var job = JobBuilder.Create<FinalizeDocumentJob>()
.UsingJobData(jobData)
.WithIdentity(jobKey)
.Build();
var trigger = TriggerBuilder.Create()
.ForJob(jobKey)
.WithIdentity($"{JobName}-trigger")
.WithSimpleSchedule(s => s
.RepeatForever()
.WithIntervalInMinutes(_config.IntervalInMin))
.StartNow()
.Build();
_logger.LogDebug("Initialized Trigger");
await _scheduler.ScheduleJob(job, trigger);
_logger.LogDebug("Job scheduled.");
await _scheduler.Start();
_logger.LogDebug("Scheduler started!");
}
catch (Exception ex)
{
_logger.LogError(ex, "SchedulerFinishEnvelope failed to start");
throw;
}
}
public async Task Stop()
{
if (_scheduler is null) return;
_logger.LogInformation("Stopping SchedulerFinishEnvelope...");
await _scheduler.Shutdown();
_logger.LogInformation("SchedulerFinishEnvelope stopped!");
}
}

View File

@@ -0,0 +1,14 @@
using EnvelopeGenerator.CommonServices.Jobs.FinalizeDocument;
namespace EnvelopeGenerator.Service;
/// <summary>
/// Service-level configuration bound from the "ServiceConfig" section of appsettings.json.
/// Connection string is read separately via the standard "ConnectionStrings:Default" key.
/// </summary>
public class ServiceConfig
{
public bool Debug { get; set; } = false;
public int IntervalInMin { get; set; } = 1;
public PDFBurnerParams PDFBurnerParams { get; set; } = new PDFBurnerParams();
}

View File

@@ -0,0 +1,75 @@
namespace EnvelopeGenerator.Service;
/// <summary>
/// Service-level temp file management. Mirrors <c>TempFiles.vb</c> from the legacy project.
/// Manages <c>%TEMP%\EnvelopeGenerator</c> at host startup and shutdown.
///
/// NOTE: This is independent from <c>EnvelopeGenerator.CommonServices.TempFiles</c>, which is
/// instantiated inside each Quartz job via <see cref="DigitalData.Modules.Logging.LogConfig"/>.
/// Do not confuse the two.
/// </summary>
public sealed class TempFiles
{
private readonly ILogger<TempFiles> _logger;
public string TempPath { get; }
public TempFiles(ILogger<TempFiles> logger)
{
_logger = logger;
TempPath = Path.Combine(Path.GetTempPath(), "EnvelopeGenerator");
}
public bool Create()
{
try
{
if (!Directory.Exists(TempPath))
{
Directory.CreateDirectory(TempPath);
}
else
{
CleanUpFiles();
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create temp directory: {TempPath}", TempPath);
return false;
}
}
private void CleanUpFiles()
{
try
{
foreach (var file in Directory.GetFiles(TempPath))
{
_logger.LogDebug("Deleting temp file: {File}", file);
File.Delete(file);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clean up temp files in: {TempPath}", TempPath);
}
}
public bool CleanUp()
{
try
{
_logger.LogDebug("Deleting temp directory: {TempPath}", TempPath);
Directory.Delete(TempPath, recursive: true);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete temp directory: {TempPath}", TempPath);
return false;
}
}
}

View File

@@ -0,0 +1,111 @@
using DigitalData.Modules.Database;
using EnvelopeGenerator.Service.Schedulers;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Service;
public sealed class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
private readonly ServiceConfig _config;
private readonly string _connectionString;
private readonly TempFiles _tempFiles;
private readonly SchedulerFinishEnvelope _schedulerFinishEnvelope;
private readonly SchedulerEnvelopeTaskApi _schedulerEnvelopeTaskApi;
public Worker(
ILogger<Worker> logger,
IOptions<ServiceConfig> config,
IConfiguration configuration,
TempFiles tempFiles,
SchedulerFinishEnvelope schedulerFinishEnvelope,
SchedulerEnvelopeTaskApi schedulerEnvelopeTaskApi)
{
_logger = logger;
_config = config.Value;
_connectionString = configuration.GetConnectionString("Default")
?? throw new InvalidOperationException("ConnectionStrings:Default is not configured.");
_tempFiles = tempFiles;
_schedulerFinishEnvelope = schedulerFinishEnvelope;
_schedulerEnvelopeTaskApi = schedulerEnvelopeTaskApi;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
_logger.LogInformation("EnvelopeGenerator.Service starting...");
// 1. Validate connection string
if (string.IsNullOrWhiteSpace(_connectionString))
throw new InvalidOperationException("Connection string is empty!");
// 2. Build LogConfig (required by CommonServices jobs via JobDataMap)
var logConfig = LogConfigFactory.Create(_config);
_logger.LogInformation("DEBUG = {Debug}", _config.Debug);
// 3. Connect to DB and load GDPicture license
_logger.LogDebug("Initializing Database...");
var database = new MSSQLServer(logConfig, _connectionString);
if (!database.DBInitialized)
throw new InvalidOperationException("Database connection could not be established!");
_logger.LogDebug("Database initialized.");
var licenseKey = database.GetScalarValue(
"SELECT LICENSE FROM TBDD_3RD_PARTY_MODULES WHERE NAME = 'GDPICTURE' AND ACTIVE = 1") as string;
if (string.IsNullOrWhiteSpace(licenseKey))
throw new InvalidOperationException("GDPicture license could not be loaded!");
_logger.LogDebug("GDPicture license loaded.");
// 4. Create service-level temp directory
_tempFiles.Create();
// 5. Start FinalizeDocument scheduler
_logger.LogDebug("Initializing schedulers...");
await _schedulerFinishEnvelope.Start(licenseKey!, logConfig);
// 6. Delay between schedulers (legacy behavior preserved)
await Task.Delay(2500, stoppingToken);
// 7. Start API envelope scheduler
await _schedulerEnvelopeTaskApi.Start(logConfig);
_logger.LogInformation("EnvelopeGenerator.Service started!");
// 8. Wait for cancellation (service runs until stopped)
await Task.Delay(Timeout.Infinite, stoppingToken);
}
catch (OperationCanceledException)
{
// Normal shutdown <20> not an error
}
catch (Exception ex)
{
_logger.LogError(ex, "EnvelopeGenerator.Service encountered a fatal error during startup.");
throw;
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("EnvelopeGenerator.Service stopping...");
try
{
await _schedulerFinishEnvelope.Stop();
await _schedulerEnvelopeTaskApi.Stop();
_tempFiles.CleanUp();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during service shutdown.");
}
await base.StopAsync(cancellationToken);
_logger.LogInformation("EnvelopeGenerator.Service stopped!");
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}

View File

@@ -0,0 +1,24 @@
{
"ConnectionStrings": {
"Default": "Server=SDD-VMP04-SQL17\\DD_DEVELOP01;Database=DD_ECM;User Id=sa;Password=dd;Encrypt=false;TrustServerCertificate=True;"
},
"ServiceConfig": {
"Debug": false,
"IntervalInMin": 1,
"PDFBurnerParams": {
"IgnoredLabels": [ "Date", "Datum", "ZIP", "PLZ", "Place", "Ort", "Position", "Stellung" ],
"TopMargin": 0.1,
"YOffset": -0.3,
"FontName": "Arial",
"FontSize": 8,
"FontStyle": 2,
"TruncationSuffix": "..",
"TextMaxWidths": {
"position": 1.41,
"city": 1.41,
"date": 2.0
}
}
}
}

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
autoReload="true">
<targets async="true">
<target xsi:type="File"
name="file"
fileName="${basedir}/Log/service-${shortdate}.log"
layout="${longdate} | ${level:uppercase=true} | ${logger} | ${message}${onexception:${newline}${exception:format=tostring}}" />
<target xsi:type="Console"
name="console"
layout="${longdate} | ${level:uppercase=true} | ${logger} | ${message}" />
</targets>
<rules>
<logger name="*" minlevel="Debug" writeTo="file,console" />
</rules>
</nlog>

View File

@@ -23,19 +23,20 @@
"Annotations": [
{
"Name": "Signature",
"MarginTop": 0
"MarginTop": 0,
"MarginLeft": 0.20
},
{
"Name": "PositionLabel",
"VerBoundAnnotName": "Signature",
"WidthRatio": 1.2,
"HeightRatio": 0.5,
"MarginTopRatio": 0.22
"MarginTopRatio": 0.30
},
{
"Name": "Position",
"VerBoundAnnotName": "PositionLabel",
"WidthRatio": 1.2,
"WidthRatio": 1.4,
"HeightRatio": 0.5,
"MarginTopRatio": -0.05
},
@@ -49,7 +50,7 @@
{
"Name": "City",
"VerBoundAnnotName": "CityLabel",
"WidthRatio": 1.2,
"WidthRatio": 1.4,
"HeightRatio": 0.5,
"MarginTopRatio": -0.05
},

View File

@@ -11,13 +11,16 @@ async function createAnnotations(document, envelopeId, receiverId) {
//#region signatures
const id = generateId(envelopeId, receiverId, element.id, 'signature');
const boundingBox = new PSPDFKit.Geometry.Rect(annotParams.signature);
const annotation = new PSPDFKit.Annotations.WidgetAnnotation({
id: id,
pageIndex: page,
formFieldName: id,
backgroundColor: PSPDFKit.Color.LIGHT_YELLOW,
backgroundColor: null,
blendMode: 'normal',
boundingBox: new PSPDFKit.Geometry.Rect(annotParams.signature),
boundingBox: boundingBox,
})
const formField = new PSPDFKit.FormFields.SignatureFormField({
@@ -237,7 +240,7 @@ async function createAnnotationFrameBlob(receiverName, receiverSignature, timest
Comp.SignatureProgress.SignedCount += 1;
const canvas = document.createElement('canvas')
const scale = 4
const fontSize = 10
const fontSize = 8
canvas.width = width * scale
canvas.height = height * scale
@@ -276,15 +279,11 @@ async function createAnnotationFrameBlob(receiverName, receiverSignature, timest
ctx.fillStyle = 'black'
ctx.font = `${fontSize * scale}px sans-serif`
ctx.fillText((READ_AND_CONFIRM ? localized.confirmedBy : localized.signedBy) ?? 'Signed by', 15 * scale, 10 * scale)
ctx.fillText(receiverName, 15 * scale, 60 * scale)
ctx.fillText(signatureString, 15 * scale, 70 * scale)
ctx.fillText(receiverName, 15 * scale, 58.5 * scale)
ctx.fillText(signatureString, 15 * scale, 69 * scale)
return new Promise((resolve) => {
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob)
resolve(url)
})
})
const blob = await new Promise((resolve) => canvas.toBlob(resolve))
return URL.createObjectURL(blob)
}
//required

View File

@@ -37,10 +37,15 @@ class App {
});
// Load annotations into PSPDFKit
this.signatureWidgets = [] // { pageIndex, boundingBox, id }
if (!READ_AND_CONFIRM)
try {
let signatures = await createAnnotations(this.currentDocument, this.envelopeReceiver.envelopeId, this.envelopeReceiver.receiverId);
await this.pdfKit.create(signatures);
// Cache signature widget bounding boxes for stable frame placement
this.signatureWidgets = signatures
.filter(s => s instanceof PSPDFKit.Annotations.WidgetAnnotation && s.formFieldName?.includes('signature'))
.map(s => ({ id: s.id, pageIndex: s.pageIndex, boundingBox: s.boundingBox.toJS() }))
} catch (e) {
console.error("Error loading annotations:", e);
}
@@ -66,8 +71,27 @@ class App {
if (isFormField === false && isSignature === true) {
const left = annotation.boundingBox.left - 20
const top = annotation.boundingBox.top - 20
// Find the nearest signature widget on the same page to use its stable bounding box
const inkBBox = annotation.boundingBox
const samePageWidgets = this.signatureWidgets.filter(w => w.pageIndex === annotation.pageIndex)
const nearestWidget = samePageWidgets.reduce((nearest, w) => {
const dx = w.boundingBox.left - inkBBox.left
const dy = w.boundingBox.top - inkBBox.top
const dist = Math.sqrt(dx * dx + dy * dy)
return dist < nearest.dist ? { dist, widget: w } : nearest
}, { dist: Infinity, widget: null }).widget
// 1. Remove widget background after signing
if (nearestWidget) {
const pageAnnots = await this.pdfKit.getAnnotations(annotation.pageIndex)
const widgetAnnot = pageAnnots.find(a => a.id === nearestWidget.id)
if (widgetAnnot)
await this.pdfKit.update(widgetAnnot.set('backgroundColor', PSPDFKit.Color.TRANSPARENT))
}
const bbox = nearestWidget?.boundingBox ?? inkBBox
const left = bbox.left - 20
const top = bbox.top - 12
const width = 150
const height = 75
const timestamp = new Date()
@@ -330,7 +354,7 @@ class App {
}
async handleReset(event) {
const result = Swal.fire({
const result = await Swal.fire({
title: localized.resetConfirmTitle,
text: localized.resetConfirmText,
icon: 'question',
@@ -338,7 +362,24 @@ class App {
})
if (result.isConfirmed) {
const result = await deleteAnnotations(this.pdfKit)
await deleteAnnotations(this.pdfKit)
// Reset editable text form field values (Position, City)
const allFormFields = await this.pdfKit.getFormFields()
const editableFields = allFormFields.filter(f =>
f instanceof PSPDFKit.FormFields.TextFormField && !f.readOnly
)
for (const field of editableFields) {
await this.pdfKit.setFormFieldValues({ [field.name]: '' })
}
// Restore widget backgrounds that were made transparent after signing
for (const w of this.signatureWidgets) {
const pageAnnots = await this.pdfKit.getAnnotations(w.pageIndex)
const widgetAnnot = pageAnnots.find(a => a.id === w.id)
if (widgetAnnot)
await this.pdfKit.update(widgetAnnot.set('backgroundColor', null))
}
}
return result

View File

@@ -1,3 +1,3 @@
const formatLocalized=(n,...t)=>typeof n=="string"?n.replace(/\{(\d+)\}/g,(n,i)=>t[i]??""):"";class App{constructor(n,t,i,r,u,f){this.container=f??`#${this.constructor.name.toLowerCase()}`;this.envelopeKey=n;this.pdfKit=null;this.currentDocument=t.envelope.documents[0];this.currentReceiver=t.receiver;this.signatureCount=t.envelope.documents[0].elements.length;this.envelopeReceiver=t;this.documentBytes=i;this.licenseKey=r;this.locale=u}async init(){if(this.pdfKit=await loadPSPDFKit(this.documentBytes,this.container,this.licenseKey,this.locale),addToolbarItems(this.pdfKit,this.handleClick.bind(this)),this.pdfKit.addEventListener("annotations.load",this.handleAnnotationsLoad.bind(this)),this.pdfKit.addEventListener("annotations.change",this.handleAnnotationsChange.bind(this)),this.pdfKit.addEventListener("annotations.create",this.handleAnnotationsCreate.bind(this)),this.pdfKit.addEventListener("annotations.willChange",()=>{Comp.ActPanel.Toggle()}),!READ_AND_CONFIRM)try{let n=await createAnnotations(this.currentDocument,this.envelopeReceiver.envelopeId,this.envelopeReceiver.receiverId);await this.pdfKit.create(n)}catch(n){console.error("Error loading annotations:",n)}READ_AND_CONFIRM||[...document.getElementsByClassName("btn_refresh")].forEach(n=>n.addEventListener("click",()=>this.handleClick("RESET")));[...document.getElementsByClassName("btn_complete")].forEach(n=>n.addEventListener("click",()=>this.handleClick("FINISH")));[...document.getElementsByClassName("btn_reject")].forEach(n=>n.addEventListener("click",()=>this.handleClick("REJECT")))}handleAnnotationsLoad(n){n.toJS()}handleAnnotationsChange(){}async handleAnnotationsCreate(n){const t=n.toJS()[0],i=!!t.formFieldName,r=!!t.isSignature;if(i===!1&&r===!0){const r=t.boundingBox.left-20,u=t.boundingBox.top-20,n=150,i=75,f=new Date,e=await createAnnotationFrameBlob(this.envelopeReceiver.name,this.currentReceiver.signature,f,n,i),o=await fetch(e),s=await o.blob(),h=await this.pdfKit.createAttachment(s),c=createImageAnnotation(new PSPDFKit.Geometry.Rect({left:r,top:u,width:n,height:i}),t.pageIndex,h,generateId(this.envelopeReceiver.envelopeId,this.envelopeReceiver.receiverId,this.fakeElementId--,"signed"));this.pdfKit.create(c)}}async handleClick(n){let t=!1;switch(n){case"RESET":t=await this.handleReset(null);Comp.SignatureProgress.SignedCount=0;t.isConfirmed&&Swal.fire({title:localized.success,text:localized.documentReset,icon:"info"});break;case"FINISH":t=await this.handleFinish(null);t==!0&&(window.location.href=`/Envelope/${this.envelopeKey}`);break;case"REJECT":Swal.fire({title:localized.rejection,html:`<div class="text-start fs-6 p-0 m-0">${localized.rejectionReasonQ}</div>`,icon:"question",input:"text",inputAttributes:{autocapitalize:"off"},showCancelButton:!0,confirmButtonColor:"#3085d6",cancelButtonColor:"#d33",confirmButtonText:localized.complete,cancelButtonText:localized.back,showLoaderOnConfirm:!0,preConfirm:async n=>{try{return await rejectEnvelope(n)}catch(t){Swal.showValidationMessage(`
const formatLocalized=(n,...t)=>typeof n=="string"?n.replace(/\{(\d+)\}/g,(n,i)=>t[i]??""):"";class App{constructor(n,t,i,r,u,f){this.container=f??`#${this.constructor.name.toLowerCase()}`;this.envelopeKey=n;this.pdfKit=null;this.currentDocument=t.envelope.documents[0];this.currentReceiver=t.receiver;this.signatureCount=t.envelope.documents[0].elements.length;this.envelopeReceiver=t;this.documentBytes=i;this.licenseKey=r;this.locale=u}async init(){if(this.pdfKit=await loadPSPDFKit(this.documentBytes,this.container,this.licenseKey,this.locale),addToolbarItems(this.pdfKit,this.handleClick.bind(this)),this.pdfKit.addEventListener("annotations.load",this.handleAnnotationsLoad.bind(this)),this.pdfKit.addEventListener("annotations.change",this.handleAnnotationsChange.bind(this)),this.pdfKit.addEventListener("annotations.create",this.handleAnnotationsCreate.bind(this)),this.pdfKit.addEventListener("annotations.willChange",()=>{Comp.ActPanel.Toggle()}),this.signatureWidgets=[],!READ_AND_CONFIRM)try{let n=await createAnnotations(this.currentDocument,this.envelopeReceiver.envelopeId,this.envelopeReceiver.receiverId);await this.pdfKit.create(n);this.signatureWidgets=n.filter(n=>n instanceof PSPDFKit.Annotations.WidgetAnnotation&&n.formFieldName?.includes("signature")).map(n=>({id:n.id,pageIndex:n.pageIndex,boundingBox:n.boundingBox.toJS()}))}catch(n){console.error("Error loading annotations:",n)}READ_AND_CONFIRM||[...document.getElementsByClassName("btn_refresh")].forEach(n=>n.addEventListener("click",()=>this.handleClick("RESET")));[...document.getElementsByClassName("btn_complete")].forEach(n=>n.addEventListener("click",()=>this.handleClick("FINISH")));[...document.getElementsByClassName("btn_reject")].forEach(n=>n.addEventListener("click",()=>this.handleClick("REJECT")))}handleAnnotationsLoad(n){n.toJS()}handleAnnotationsChange(){}async handleAnnotationsCreate(n){const t=n.toJS()[0],i=!!t.formFieldName,r=!!t.isSignature;if(i===!1&&r===!0){const n=t.boundingBox,e=this.signatureWidgets.filter(n=>n.pageIndex===t.pageIndex),i=e.reduce((t,i)=>{const r=i.boundingBox.left-n.left,u=i.boundingBox.top-n.top,f=Math.sqrt(r*r+u*u);return f<t.dist?{dist:f,widget:i}:t},{dist:Infinity,widget:null}).widget;if(i){const r=await this.pdfKit.getAnnotations(t.pageIndex),n=r.find(n=>n.id===i.id);n&&await this.pdfKit.update(n.set("backgroundColor",PSPDFKit.Color.TRANSPARENT))}const r=i?.boundingBox??n,o=r.left-20,s=r.top-12,u=150,f=75,h=new Date,c=await createAnnotationFrameBlob(this.envelopeReceiver.name,this.currentReceiver.signature,h,u,f),l=await fetch(c),a=await l.blob(),v=await this.pdfKit.createAttachment(a),y=createImageAnnotation(new PSPDFKit.Geometry.Rect({left:o,top:s,width:u,height:f}),t.pageIndex,v,generateId(this.envelopeReceiver.envelopeId,this.envelopeReceiver.receiverId,this.fakeElementId--,"signed"));this.pdfKit.create(y)}}async handleClick(n){let t=!1;switch(n){case"RESET":t=await this.handleReset(null);Comp.SignatureProgress.SignedCount=0;t.isConfirmed&&Swal.fire({title:localized.success,text:localized.documentReset,icon:"info"});break;case"FINISH":t=await this.handleFinish(null);t==!0&&(window.location.href=`/Envelope/${this.envelopeKey}`);break;case"REJECT":Swal.fire({title:localized.rejection,html:`<div class="text-start fs-6 p-0 m-0">${localized.rejectionReasonQ}</div>`,icon:"question",input:"text",inputAttributes:{autocapitalize:"off"},showCancelButton:!0,confirmButtonColor:"#3085d6",cancelButtonColor:"#d33",confirmButtonText:localized.complete,cancelButtonText:localized.back,showLoaderOnConfirm:!0,preConfirm:async n=>{try{return await rejectEnvelope(n)}catch(t){Swal.showValidationMessage(`
Request failed: ${t}
`)}},allowOutsideClick:()=>!Swal.isLoading()}).then(n=>{if(n.isConfirmed){const t=n.value;t.ok?reload():Swal.showValidationMessage(`Request failed: ${t.message}`)}});break;case"COPY_URL":const n=window.location.href.replace(/\/readonly/gi,"");navigator.clipboard.writeText(n).then(function(){bsNotify(localized.copyLinkSuccess,{alert_type:"success",delay:4,icon_name:"check_circle"})}).catch(function(){bsNotify(localized.copyLinkFailure??localized.unexpectedErrorTitle,{alert_type:"danger",delay:4,icon_name:"error"})});break;case"SHARE":Comp.ShareBackdrop.show();break;case"LOGOUT":await logout()}}async handleFinish(){let n=undefined;if(READ_AND_CONFIRM){const n=JSON.parse(sessionStorage.getItem("pspdf_all_pages_rendered")||"false")===!0;if(!n){const n=JSON.parse(sessionStorage.getItem("pspdf_unviewed_pages")||"[]"),t=n.length?formatLocalized(localized.viewRemainingPages,n.join(", ")):localized.viewAllPages;return await Swal.fire({title:localized.warning,text:t,icon:"warning"}),!1}}else{const i=await this.pdfKit.exportInstantJSON(),r=i.formFieldValues,u=r.filter(n=>isFieldRequired(n)),f=u.some(n=>n.value===undefined||n.value===null||n.value==="");if(f)return Swal.fire({title:localized.warning,text:localized.locationFieldsRequired,icon:"warning"}),!1;const e=new RegExp("^[a-zA-Z\\u0080-\\u024F]+(?:([\\ \\-\\']|(\\.\\ ))[a-zA-Z\\u0080-\\u024F]+)*$"),o=r.filter(n=>isCityField(n));for(var t of o)if(!IS_MOBILE_DEVICE&&!e.test(t.value))return Swal.fire({title:localized.warning,text:formatLocalized(localized.cityFormatInvalid,t.value),icon:"warning"}),!1;const s=await this.validateAnnotations(this.signatureCount);if(s===!1)return Swal.fire({title:localized.warning,text:localized.missingSignatures,icon:"warning"}),!1;n={instant:i,structured:mapSignature(i)}}return Swal.fire({title:localized.confirmation,html:`<div class="text-start fs-6 p-0 m-0">${READ_AND_CONFIRM?localized.confirmAgree:localized.sigAgree}</div>`,icon:"question",showCancelButton:!0,confirmButtonColor:"#3085d6",cancelButtonColor:"#d33",confirmButtonText:localized.finalize,cancelButtonText:localized.back}).then(async t=>{if(t.isConfirmed){try{await this.pdfKit.save()}catch(i){return Swal.fire({title:localized.warning,text:localized.envelopeSignError,icon:"error"}),!1}try{const t=READ_AND_CONFIRM?await signEnvelope():await signEnvelope(n);if(t.ok)return!0;if(t.status===409)return Swal.fire({title:localized.warning,text:localized.envelopeUnavailable,icon:"warning"}),!1;if(t.status===423)Swal.fire({title:localized.info??localized.warning,text:localized.envelopeRejectedRedirect,icon:"info",timer:2e3,showConfirmButton:!1}).then(()=>{location.reload()});else throw new Error;}catch(i){return Swal.fire({title:localized.warning,text:localized.envelopeSignError,icon:"error"}),!1}}else return!1})}async validateAnnotations(n){const t=await getAnnotations(this.pdfKit),i=t.map(n=>n.toJS()).filter(n=>n.isSignature);return n<=i.length}async handleReset(){const n=Swal.fire({title:localized.resetConfirmTitle,text:localized.resetConfirmText,icon:"question",showCancelButton:!0});if(n.isConfirmed){const n=await deleteAnnotations(this.pdfKit)}return n}fakeElementId=0;}
`)}},allowOutsideClick:()=>!Swal.isLoading()}).then(n=>{if(n.isConfirmed){const t=n.value;t.ok?reload():Swal.showValidationMessage(`Request failed: ${t.message}`)}});break;case"COPY_URL":const n=window.location.href.replace(/\/readonly/gi,"");navigator.clipboard.writeText(n).then(function(){bsNotify(localized.copyLinkSuccess,{alert_type:"success",delay:4,icon_name:"check_circle"})}).catch(function(){bsNotify(localized.copyLinkFailure??localized.unexpectedErrorTitle,{alert_type:"danger",delay:4,icon_name:"error"})});break;case"SHARE":Comp.ShareBackdrop.show();break;case"LOGOUT":await logout()}}async handleFinish(){let n=undefined;if(READ_AND_CONFIRM){const n=JSON.parse(sessionStorage.getItem("pspdf_all_pages_rendered")||"false")===!0;if(!n){const n=JSON.parse(sessionStorage.getItem("pspdf_unviewed_pages")||"[]"),t=n.length?formatLocalized(localized.viewRemainingPages,n.join(", ")):localized.viewAllPages;return await Swal.fire({title:localized.warning,text:t,icon:"warning"}),!1}}else{const i=await this.pdfKit.exportInstantJSON(),r=i.formFieldValues,u=r.filter(n=>isFieldRequired(n)),f=u.some(n=>n.value===undefined||n.value===null||n.value==="");if(f)return Swal.fire({title:localized.warning,text:localized.locationFieldsRequired,icon:"warning"}),!1;const e=new RegExp("^[a-zA-Z\\u0080-\\u024F]+(?:([\\ \\-\\']|(\\.\\ ))[a-zA-Z\\u0080-\\u024F]+)*$"),o=r.filter(n=>isCityField(n));for(var t of o)if(!IS_MOBILE_DEVICE&&!e.test(t.value))return Swal.fire({title:localized.warning,text:formatLocalized(localized.cityFormatInvalid,t.value),icon:"warning"}),!1;const s=await this.validateAnnotations(this.signatureCount);if(s===!1)return Swal.fire({title:localized.warning,text:localized.missingSignatures,icon:"warning"}),!1;n={instant:i,structured:mapSignature(i)}}return Swal.fire({title:localized.confirmation,html:`<div class="text-start fs-6 p-0 m-0">${READ_AND_CONFIRM?localized.confirmAgree:localized.sigAgree}</div>`,icon:"question",showCancelButton:!0,confirmButtonColor:"#3085d6",cancelButtonColor:"#d33",confirmButtonText:localized.finalize,cancelButtonText:localized.back}).then(async t=>{if(t.isConfirmed){try{await this.pdfKit.save()}catch(i){return Swal.fire({title:localized.warning,text:localized.envelopeSignError,icon:"error"}),!1}try{const t=READ_AND_CONFIRM?await signEnvelope():await signEnvelope(n);if(t.ok)return!0;if(t.status===409)return Swal.fire({title:localized.warning,text:localized.envelopeUnavailable,icon:"warning"}),!1;if(t.status===423)Swal.fire({title:localized.info??localized.warning,text:localized.envelopeRejectedRedirect,icon:"info",timer:2e3,showConfirmButton:!1}).then(()=>{location.reload()});else throw new Error;}catch(i){return Swal.fire({title:localized.warning,text:localized.envelopeSignError,icon:"error"}),!1}}else return!1})}async validateAnnotations(n){const t=await getAnnotations(this.pdfKit),i=t.map(n=>n.toJS()).filter(n=>n.isSignature);return n<=i.length}async handleReset(){const n=await Swal.fire({title:localized.resetConfirmTitle,text:localized.resetConfirmText,icon:"question",showCancelButton:!0});if(n.isConfirmed){await deleteAnnotations(this.pdfKit);const n=await this.pdfKit.getFormFields(),t=n.filter(n=>n instanceof PSPDFKit.FormFields.TextFormField&&!n.readOnly);for(const n of t)await this.pdfKit.setFormFieldValues({[n.name]:""});for(const n of this.signatureWidgets){const i=await this.pdfKit.getAnnotations(n.pageIndex),t=i.find(t=>t.id===n.id);t&&await this.pdfKit.update(t.set("backgroundColor",null))}}return n}fakeElementId=0;}

View File

@@ -9,8 +9,6 @@ Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.CommonSer
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvelopeGenerator.Web", "EnvelopeGenerator.Web\EnvelopeGenerator.Web.csproj", "{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}"
EndProject
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.Service", "EnvelopeGenerator.Service\EnvelopeGenerator.Service.vbproj", "{83ED2617-B398-4859-8F59-B38F8807E83E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvelopeGenerator.Domain", "EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj", "{4F32A98D-E6F0-4A09-BD97-1CF26107E837}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EnvelopeGenerator.Infrastructure", "EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj", "{63E32615-0ECA-42DC-96E3-91037324B7C7}"
@@ -23,6 +21,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{134D4164-B29
ProjectSection(SolutionItems) = preProject
COPILOT_CONTEXT.md = COPILOT_CONTEXT.md
fix-report-label-read-and-confirmed.md = fix-report-label-read-and-confirmed.md
fix-signature-field-formatting.md = fix-signature-field-formatting.md
FORM_APPLICATION_CONTEXT.md = FORM_APPLICATION_CONTEXT.md
RECEIVER_PDF_VIEWER_CONTEXT.md = RECEIVER_PDF_VIEWER_CONTEXT.md
EndProjectSection
@@ -54,6 +53,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Server.Client", "EnvelopeGenerator.Server\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj", "{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}"
EndProject
Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.Service_legacy", "EnvelopeGenerator.Service_legacy\EnvelopeGenerator.Service_legacy.vbproj", "{83ED2617-B398-4859-8F59-B38F8807E83E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Service", "EnvelopeGenerator.Service\EnvelopeGenerator.Service.csproj", "{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -72,10 +75,6 @@ Global
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Release|Any CPU.ActiveCfg = Debug|Any CPU
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27}.Release|Any CPU.Build.0 = Debug|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.ActiveCfg = Release|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.Build.0 = Release|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.ActiveCfg = Debug|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.Build.0 = Debug|Any CPU
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4F32A98D-E6F0-4A09-BD97-1CF26107E837}.Release|Any CPU.ActiveCfg = Debug|Any CPU
@@ -120,6 +119,14 @@ Global
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF}.Release|Any CPU.Build.0 = Release|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{83ED2617-B398-4859-8F59-B38F8807E83E}.Release|Any CPU.Build.0 = Release|Any CPU
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -128,7 +135,6 @@ Global
{089D5634-FB6B-42D0-B912-7AA7457044E7} = {0CBC2432-A561-4440-89BC-671B66A24146}
{6EA0C51F-C2B1-4462-8198-3DE0B32B74F8} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{5E0E17C0-FF5A-4246-BF87-1ADD85376A27} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{4F32A98D-E6F0-4A09-BD97-1CF26107E837} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{63E32615-0ECA-42DC-96E3-91037324B7C7} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{5A9984F8-51A2-4558-A415-EC5FEED7CF7D} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
@@ -144,6 +150,8 @@ Global
{BF1700D5-592E-4FFA-84E8-5480E289A1F0} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{4E6C54DA-576D-0955-2564-9EC890BB8279} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
{9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0}
{83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}

View File

@@ -0,0 +1,111 @@
# Signature Field Formatting — WISAG Feedback
## Context
Customer feedback received from the new WISAG signFLOW project lead (email dated 3/7/2026).
The signature stamp rendered inside the finalized PDF has layout issues that need to be addressed.
### Current State (screenshot from customer email)
```
Signiert von ___________
[ signature image ]
Armin Schneider
C6B85E96A520C60... ← certificate hash, too close to name
Bereichsleiter Kern- ← position text wraps, no spacing to date
Anwendungen
Frankfurt am Main
23.06.2026, 17:13:42 GMT+2
```
### Issues Reported
1. **Spacing between name and certificate number** is too small — needs more vertical gap.
2. **Spacing between position/title and date/time** is too small — should be dynamic (expand with content).
3. *(Secondary, out of scope here)* Multiple signature fields side-by-side scale individually, causing uneven heights.
---
## Agreed Solution
> **"Height stays fixed. Text stretches horizontally. Beyond a certain character limit, truncate with '...'"**
> *(Original: "Höhe bleibt fix. Text dehnt sich horizontal aus. Ab einem bestimmten Limit wird mit '...' abgekürzt.")*
### Rationale
- The signature field dimensions are **fixed** (`1.77" × 1.96"`). Allowing text to wrap causes position and date rows to collide vertically.
- Truncating long strings with ellipsis (`...`) keeps the layout deterministic and prevents overflow.
- Per-field max-length limits allow tuning each text row independently (name, certificate, position, city, date).
---
## Implementation Plan
### 1. `PDFBurnerParams.vb` — Add per-field max-length dictionary
Add a new property `TextMaxLengths` that maps each field name (matching `EGName` keys) to a maximum character count.
```vbnet
' EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurnerParams.vb
Public Property TextMaxLengths As New Dictionary(Of String, Integer) From {
{"name", 28},
{"position", 32},
{"city", 24},
{"date", 28}
}
```
Default values are starting points and should be tuned against real customer data.
### 2. `PDFBurner.vb` — Add truncation helper and apply in `AddFormFieldValue`
#### 2a. Add a private helper function
```vbnet
Private Function TruncateText(value As String, fieldName As String) As String
If String.IsNullOrEmpty(value) Then Return value
Dim maxLen As Integer = 0
If _pdfBurnerParams.TextMaxLengths IsNot Nothing AndAlso
_pdfBurnerParams.TextMaxLengths.TryGetValue(fieldName, maxLen) AndAlso
maxLen > 0 AndAlso value.Length > maxLen Then
Return value.Substring(0, maxLen - 3) & "..."
End If
Return value
End Function
```
#### 2b. Apply truncation in the `egName`-aware overload
In `AddFormFieldValue(pAnnotation As Annotation, formFieldValue As FormFieldValue)`:
```vbnet
' Before passing value to AddTextAnnot, truncate:
Dim displayValue As String = TruncateText(formFieldValue.value, pAnnotation.egName)
Dim ant = Manager.AddTextAnnot(oX, oY, oWidth, oHeight, displayValue)
```
#### 2c. Apply truncation in the element-based overload (optional)
In `AddFormFieldValue(x, y, width, height, page, value)` — if a field name is available in calling context, pass it through; otherwise this path does not use `egName` and truncation can be skipped or applied with a generic fallback key.
---
## Affected Files
| File | Change |
|------|--------|
| `EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurnerParams.vb` | Add `TextMaxLengths As Dictionary(Of String, Integer)` |
| `EnvelopeGenerator.CommonServices/Jobs/FinalizeDocument/PDFBurner.vb` | Add `TruncateText()` helper; apply in `AddFormFieldValue` overloads |
---
## Notes
- Field names (`"name"`, `"position"`, `"city"`, `"date"`) must match the keys used in `EGName.Index` dictionary (`PDFBurner.vb:443–450`).
- The certificate hash field (e.g. `C6B85E96A520C60...`) is already truncated upstream — verify its `egName` mapping if it needs its own limit.
- The `YOffset` / `TopMargin` values in `PDFBurnerParams` may also need adjustment to increase vertical spacing between rows. That is a separate (simpler) change.
- This issue is **not** related to the Blazor WebUI rendering — it is purely in the GdPicture14 PDF burning pipeline.