Compare commits

...

13 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
24 changed files with 1293 additions and 46 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

@@ -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

@@ -13,7 +13,7 @@ async function createAnnotations(document, envelopeId, receiverId) {
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,
@@ -273,7 +273,7 @@ async function createAnnotationFrameBlob(receiverName, receiverSignature, timest
ctx.moveTo(30 * scale, 50 * scale)
ctx.lineTo(signatureLength, 50 * scale)
ctx.strokeStyle = 'darkgray'
ctx.strokeStyle = 'darkblue'
ctx.stroke()
ctx.fillStyle = 'black'