Compare commits
97 Commits
master
...
feat/migr-
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d069cdaa0 | |||
| bbbfa4de01 | |||
| 3ff3373b27 | |||
| 2af8815cf6 | |||
| ca24a96084 | |||
| db368b889a | |||
| 8f451b9c2c | |||
| 6120e6062e | |||
| cc2aea90ed | |||
| 762a9e8bca | |||
| 6ed4caea4f | |||
| d94821433a | |||
| 278b9964f1 | |||
| e6722803bb | |||
| 47bc7675c9 | |||
| 789e312316 | |||
| 2a9bbb3fe5 | |||
| bc34317720 | |||
| 76ff3e47e1 | |||
| 2d22bfcd06 | |||
| 185c783824 | |||
| b957b4b4bb | |||
| df154d83cc | |||
| 49ec9fbead | |||
| 01fc29f59e | |||
| 733b70cca2 | |||
| 8f4b751303 | |||
| a5e4f97397 | |||
| 6ca03a50eb | |||
| 96a84ba1a5 | |||
| ec0ea72890 | |||
| 7b912387e7 | |||
| 6c142eba08 | |||
| 489d2808a1 | |||
| 7466fd78f6 | |||
| e34b5ddbbe | |||
| b56f906848 | |||
| fe09c5c7ae | |||
| 0763d82f6e | |||
| 5a30bc050b | |||
| a4b218b9f3 | |||
| 67798b35da | |||
| b5bb2bbaae | |||
| 85a0736106 | |||
| de9c9da176 | |||
| f4571320ce | |||
| 2abfffdeba | |||
| bfd1a9d060 | |||
| 78ed49a077 | |||
| 6aa97adf84 | |||
| 7456babe0d | |||
| 71e375d6ea | |||
| 05f64e2b61 | |||
| ed17852542 | |||
| 9947774ba8 | |||
| c6c1decd2a | |||
| 0fdaa1a38d | |||
| 5d66de9f32 | |||
| b6ec5307b6 | |||
| 106e62a912 | |||
| 27940f5d34 | |||
| e776c2edb4 | |||
| 3f0f5d7fb9 | |||
| e11bc9df8e | |||
| 4dca17d39c | |||
| 8baf6b5553 | |||
| 3ca99fdd83 | |||
| 9e37bf1fe2 | |||
| 9a0837caa9 | |||
| 030646f33d | |||
| 88317e40f5 | |||
| 3a2fa77862 | |||
| cfa6dbd2de | |||
| eb2603f389 | |||
| 456178bee1 | |||
| 2c41c74510 | |||
| bb73795d68 | |||
| 207992d95a | |||
| d6bafc64a6 | |||
| 3090711892 | |||
| 9dbd8f7952 | |||
| 48a41f2987 | |||
| 96688a951c | |||
| 6f07de3ec4 | |||
| 4611266224 | |||
| c529d03129 | |||
| 829fab9647 | |||
| b2e3605b54 | |||
| 8cbdee2491 | |||
| 4281eaeb22 | |||
| 150fca5f47 | |||
| 1f889d8b58 | |||
| d599fe3156 | |||
| 6c40c48ac8 | |||
| 536b8ef5da | |||
| d35a35c75e | |||
| 7fb1a87cf2 |
263
AGENTS.md
Normal file
263
AGENTS.md
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
# EnvelopeGenerator - Agent Guide
|
||||||
|
|
||||||
|
## Must Read First
|
||||||
|
- **`COPILOT_CONTEXT.md`** - Architecture, coordinate systems, migration status
|
||||||
|
- **`FORM_APPLICATION_CONTEXT.md`** - Legacy VB.NET features to migrate
|
||||||
|
|
||||||
|
## Active Architecture (Post-Migration)
|
||||||
|
|
||||||
|
**Frontend:** Blazor Auto (Server+WASM hybrid)
|
||||||
|
- **WebUI** (Server): `@rendermode InteractiveServer` - PDF viewers requiring DevExpress backend
|
||||||
|
- **WebUI.Client** (WASM): `@rendermode InteractiveWebAssembly` - Login, dashboards, business logic
|
||||||
|
|
||||||
|
**Backend:** EnvelopeGenerator.API (ASP.NET Core 8.0)
|
||||||
|
|
||||||
|
**Proxy:** YARP in WebUI routes `/api/*` → `localhost:8088` (API)
|
||||||
|
|
||||||
|
### Deprecated Projects - DO NOT USE
|
||||||
|
- `EnvelopeGenerator.ReceiverUI` - Pure WASM (migrated to WebUI)
|
||||||
|
- `EnvelopeGenerator.Web` - Razor Pages (replaced by WebUI)
|
||||||
|
- **VB.NET projects** (`Form`, `Service`, `BBTests`) - Legacy, read-only for reference
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
### Run Both Projects (Required)
|
||||||
|
```powershell
|
||||||
|
# Terminal 1 - API Backend
|
||||||
|
cd EnvelopeGenerator.API
|
||||||
|
dotnet run
|
||||||
|
|
||||||
|
# Terminal 2 - Blazor Frontend
|
||||||
|
cd EnvelopeGenerator.WebUI\EnvelopeGenerator.WebUI
|
||||||
|
dotnet run
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical:** Both must run simultaneously. WebUI proxy forwards `/api/*` to API.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
```powershell
|
||||||
|
dotnet build EnvelopeGenerator.sln
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Boundaries
|
||||||
|
|
||||||
|
```
|
||||||
|
EnvelopeGenerator.Domain/ # Entities (Envelope, Receiver, Document, etc.)
|
||||||
|
EnvelopeGenerator.Application/ # MediatR CQRS (Commands, Queries, Handlers)
|
||||||
|
EnvelopeGenerator.Infrastructure/ # EF Core, SQL executors, repositories
|
||||||
|
EnvelopeGenerator.API/ # Controllers, endpoints
|
||||||
|
EnvelopeGenerator.WebUI/ # Server-side Blazor components
|
||||||
|
├─ Components/Pages/ # @rendermode InteractiveServer
|
||||||
|
EnvelopeGenerator.WebUI.Client/ # Client-side WASM components
|
||||||
|
├─ Pages/ # @rendermode InteractiveWebAssembly
|
||||||
|
├─ Services/ # HTTP API clients
|
||||||
|
├─ Models/ # DTOs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Route Structure (Critical)
|
||||||
|
|
||||||
|
| Route | File Location | Render Mode | Purpose |
|
||||||
|
|-------|--------------|-------------|---------|
|
||||||
|
| `/` | `WebUI.Client/Pages/Index.razor` | WASM | Landing page |
|
||||||
|
| `/sender/login` | `WebUI.Client/Pages/LoginSenderPage.razor` | WASM | Sender auth |
|
||||||
|
| `/sender` | `WebUI.Client/Pages/EnvelopeSenderPage.razor` | WASM | Sender dashboard |
|
||||||
|
| `/envelope/login/{key}` | `WebUI.Client/Pages/LoginReceiverPage.razor` | WASM | Receiver auth |
|
||||||
|
| `/envelope/{key}` | `WebUI/Components/Pages/EnvelopeReceiverPage.razor` | **Server** | PDF viewer + signing |
|
||||||
|
|
||||||
|
**Rule:** PDF viewers MUST use `@rendermode InteractiveServer` (DevExpress backend requirement). Everything else uses WASM.
|
||||||
|
|
||||||
|
## Coordinate System (CRITICAL)
|
||||||
|
|
||||||
|
**Database stores INCHES** (GdPicture14 native). Origin: top-left, Y-axis down.
|
||||||
|
|
||||||
|
### Conversions
|
||||||
|
```csharp
|
||||||
|
// Database (INCHES) → PDF Points
|
||||||
|
float points = inches * 72;
|
||||||
|
|
||||||
|
// Database (INCHES) → DevExpress DX
|
||||||
|
float dx = inches * 100;
|
||||||
|
|
||||||
|
// PDF.js Pixels → Database (INCHES)
|
||||||
|
float inches = (pixelX / canvasWidth) * pageWidthInches;
|
||||||
|
```
|
||||||
|
|
||||||
|
**A4 Page:** 8.27" wide × 11.69" tall = 595pt × 842pt
|
||||||
|
|
||||||
|
**Signature Field Size:** 1.77" × 1.96" (FIXED, do not change)
|
||||||
|
|
||||||
|
**Evidence:** See `COPILOT_CONTEXT.md` lines 158-185, `EnvelopeGenerator.Form/frmFieldEditor.vb`
|
||||||
|
|
||||||
|
## API Architecture Quirks
|
||||||
|
|
||||||
|
### Monolithic Endpoint (Avoid for UI)
|
||||||
|
`POST /api/EnvelopeReceiver` - Creates envelope+document+receivers+fields atomically.
|
||||||
|
- **Use case:** External API consumers
|
||||||
|
- **Not suitable for:** Step-by-step UI workflow (no draft support, no partial updates)
|
||||||
|
|
||||||
|
### Missing Granular Endpoints (Need to Create)
|
||||||
|
```
|
||||||
|
POST /api/Envelope/draft # Create draft envelope
|
||||||
|
PUT /api/Envelope/{id} # Update metadata
|
||||||
|
DELETE /api/Envelope/{id} # Delete with reason
|
||||||
|
POST /api/Envelope/{id}/document # Upload PDF
|
||||||
|
POST /api/Envelope/{id}/receivers # Add receiver
|
||||||
|
POST /api/Envelope/{id}/signature-fields # Place signature field
|
||||||
|
POST /api/Envelope/{id}/send # Send to receivers
|
||||||
|
```
|
||||||
|
|
||||||
|
See `FORM_APPLICATION_CONTEXT.md` for detailed workflow requirements.
|
||||||
|
|
||||||
|
## Status Color Coding
|
||||||
|
|
||||||
|
Form app uses DevExpress `CustomDrawCell`. WebUI needs CSS:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.envelope-row.status-partly-signed { background-color: #81C784; } /* GREEN_300 */
|
||||||
|
.envelope-row.status-queued,
|
||||||
|
.envelope-row.status-sent { background-color: #FFB74D; } /* ORANGE_300 */
|
||||||
|
.envelope-row.status-completed { background-color: #81C784; }
|
||||||
|
.envelope-row.status-deleted,
|
||||||
|
.envelope-row.status-rejected { background-color: #E57373; } /* RED_300 */
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### YARP Proxy (`WebUI/yarp.json`)
|
||||||
|
Routes `/api/*`, `/swagger/*`, `/openapi/*`, `/scalar/*` → `https://localhost:8088`
|
||||||
|
|
||||||
|
### PDF.js Settings (`WebUI/wwwroot/appsettings.json`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"PdfViewerOptions": {
|
||||||
|
"ThumbnailBaseScale": 0.75,
|
||||||
|
"ThumbnailEnableHiDPI": true,
|
||||||
|
"MainCanvasEnableHiDPI": true,
|
||||||
|
"ZoomStepPercentage": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Config (`API/appsettings.json`)
|
||||||
|
- `ConnectionStrings:Default` - SQL Server DB
|
||||||
|
- `AllowedOrigins` - CORS (includes `http://localhost:5131`, `http://localhost:7192`)
|
||||||
|
- `Cache:SignatureCacheExpiration` - Signature persistence timeout
|
||||||
|
- `PSPDFKitLicenseKey` - **DEPRECATED** (use PDF.js instead)
|
||||||
|
|
||||||
|
## Migration Status
|
||||||
|
|
||||||
|
### Complete ✅
|
||||||
|
- Receiver login/authentication
|
||||||
|
- PDF viewing with PDF.js (HiDPI, zoom, thumbnails)
|
||||||
|
- Signature capture (draw/type/image)
|
||||||
|
- Signature caching (Redis/SQL)
|
||||||
|
- Sender login
|
||||||
|
|
||||||
|
### Missing (High Priority) ❌
|
||||||
|
- Sender dashboard (`/sender`) - Empty stub
|
||||||
|
- Envelope editor (`/sender/envelope/{id}`)
|
||||||
|
- Signature field placement tool (PDF.js + draggable overlays)
|
||||||
|
- Granular API endpoints (draft, receivers, fields)
|
||||||
|
- Master-detail grids for receivers/history
|
||||||
|
|
||||||
|
## Common Mistakes (DO NOT REPEAT)
|
||||||
|
|
||||||
|
| Mistake | Why Wrong |
|
||||||
|
|---------|-----------|
|
||||||
|
| Using iText7 in receiver pages | GPL license issue. Use PDF.js overlays. |
|
||||||
|
| Using PSPDFKit | Removed from architecture. Use PDF.js + DevExpress. |
|
||||||
|
| `@rendermode InteractiveWebAssembly` on PDF viewers | DevExpress DxPdfViewer requires server-side rendering. |
|
||||||
|
| Hardcoded quality in PDF.js | Use `appsettings.json` `PdfViewerOptions`. |
|
||||||
|
| Coordinates in points/pixels for DB | Database uses INCHES. Convert before save. |
|
||||||
|
| `BottomMarginBand` for signatures | Repeats on every page. Use `DetailBand`. |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
**No automated tests exist yet.**
|
||||||
|
|
||||||
|
Manual testing workflow:
|
||||||
|
1. Start API (`dotnet run` in `EnvelopeGenerator.API`)
|
||||||
|
2. Start WebUI (`dotnet run` in `EnvelopeGenerator.WebUI\EnvelopeGenerator.WebUI`)
|
||||||
|
3. Navigate to `https://localhost:5131` (or check console output for port)
|
||||||
|
4. Test sender login at `/sender/login`
|
||||||
|
5. Test receiver flow at `/envelope/login/{envelopeKey}`
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
**SQL Server** (DD_ECM)
|
||||||
|
- Connection string in `API/appsettings.json`
|
||||||
|
- EF Core migrations NOT used (manual SQL scripts)
|
||||||
|
- Stored procedures: `PRSIG_*` prefix
|
||||||
|
|
||||||
|
**Key Tables:**
|
||||||
|
- `TBSIG_ENVELOPE` - Envelope metadata
|
||||||
|
- `TBSIG_ENVELOPE_RECEIVER` - Receiver assignments
|
||||||
|
- `TBSIG_DOC_RECEIVER_ELEMENT` - Signature fields (X, Y in INCHES)
|
||||||
|
- `TBSIG_RECEIVER` - Receiver registry
|
||||||
|
- `TBSIG_DOCUMENT` - PDF binary data
|
||||||
|
- `TBSIG_ENVELOPE_HISTORY` - Audit trail
|
||||||
|
|
||||||
|
## DevExpress
|
||||||
|
|
||||||
|
**License:** Commercial (v25.2.3)
|
||||||
|
**Components Used:**
|
||||||
|
- `DxGrid` - Master-detail grids
|
||||||
|
- `DxPdfViewer` - Server-side PDF rendering
|
||||||
|
- `DxPopup` - Modal dialogs
|
||||||
|
- `DxToolbar` - Action bars
|
||||||
|
- `DxFormLayout` - Forms
|
||||||
|
|
||||||
|
**Theme:** Blazing Berry (default)
|
||||||
|
|
||||||
|
## JavaScript Interop
|
||||||
|
|
||||||
|
**PDF Viewer:** `wwwroot/js/pdf-viewer.js`
|
||||||
|
```javascript
|
||||||
|
window.pdfViewer = {
|
||||||
|
initialize(canvasId, pdfDataUrl, dotNetRef),
|
||||||
|
renderPage(num),
|
||||||
|
renderSignatureButtons(signatures, pageNum, dotNetRef),
|
||||||
|
applySignature(signatureId, dataUrl, fullName, position, place),
|
||||||
|
zoomIn(), zoomOut(), dispose()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Signature Pad:** `wwwroot/js/receiver-signature.js`
|
||||||
|
```javascript
|
||||||
|
window.receiverSignature = {
|
||||||
|
initializeDrawPad(canvasId, dotNetRef),
|
||||||
|
getSignatureDataUrl(canvasId),
|
||||||
|
clearPad(canvasId)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multi-Envelope Support
|
||||||
|
|
||||||
|
Receivers can login to **multiple envelopes simultaneously** via per-envelope cookies:
|
||||||
|
```
|
||||||
|
AuthTokenSignFLOWReceiver.{envelopeKey}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each envelope maintains independent authentication state.
|
||||||
|
|
||||||
|
## External Dependencies
|
||||||
|
|
||||||
|
**CDN:**
|
||||||
|
- PDF.js 3.11.174: `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js`
|
||||||
|
|
||||||
|
**NuGet (WebUI.Client):**
|
||||||
|
- `DevExpress.Blazor.*` 25.2.3
|
||||||
|
- `SkiaSharp.*` 3.119.1 (WASM rendering)
|
||||||
|
|
||||||
|
**External Services:**
|
||||||
|
- LDAP/AD authentication (optional)
|
||||||
|
- GTX Messaging (SMS 2FA)
|
||||||
|
- Email dispatcher (signFlow)
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
None required. All config in `appsettings.json`.
|
||||||
|
|
||||||
|
**Local dev ports:**
|
||||||
|
- API: `https://localhost:8088`
|
||||||
|
- WebUI: `https://localhost:5131` or `http://localhost:7192`
|
||||||
@@ -1,489 +1,351 @@
|
|||||||
# EnvelopeGenerator — AI Context Reference
|
# EnvelopeGenerator — Current Workspace Context
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Digital document signing system with **unified Blazor WASM frontend** for both Senders and Receivers. Senders create envelopes and place signature fields. Receivers view PDFs, sign documents, export stamped PDFs.
|
Digital document signing system for senders and receivers.
|
||||||
|
|
||||||
**Primary Libraries:** DevExpress + PDF.js (PSPDFKit removed)
|
- Senders authenticate, view envelope lists, and manage envelope workflows.
|
||||||
|
- Receivers authenticate per envelope, open PDFs, create signatures, and apply them in the viewer.
|
||||||
|
- The active UI stack is `Blazor Auto` with server-side and WebAssembly render modes.
|
||||||
|
- Primary UI/PDF libraries are `DevExpress` and `PDF.js`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Deployment Architecture
|
## Active Application Structure
|
||||||
|
|
||||||
**Two Presentation Projects (Both Required):**
|
### Main Host
|
||||||
|
**Primary active application:** `EnvelopeGenerator.Server`
|
||||||
|
|
||||||
1. **EnvelopeGenerator.API** (ASP.NET Core Web API)
|
`EnvelopeGenerator.Server` is the current runtime host and contains:
|
||||||
- Runs independently (development & production)
|
- Blazor server host
|
||||||
- **YARP Reverse Proxy** configured via `yarp.json`
|
- WebAssembly host integration
|
||||||
- Proxies requests to:
|
- API controllers
|
||||||
- `EnvelopeGenerator.ReceiverUI` (Blazor WASM)
|
- authentication/authorization setup
|
||||||
- External Auth.API service
|
- Swagger/Scalar setup
|
||||||
- Serves as single entry point for all requests
|
- YARP reverse proxy configuration
|
||||||
|
- DevExpress server-side services
|
||||||
|
- SQL Server distributed cache setup
|
||||||
|
|
||||||
2. **EnvelopeGenerator.ReceiverUI** (Blazor WebAssembly)
|
### Client Project
|
||||||
- Runs on separate host/port
|
**Client UI project:** `EnvelopeGenerator.Server.Client`
|
||||||
- Accessed **only through API proxy** (not directly)
|
|
||||||
- Serves static files (HTML, JS, CSS, WASM)
|
|
||||||
|
|
||||||
**Request Flow:**
|
This project contains:
|
||||||
```
|
- WebAssembly-rendered pages
|
||||||
Client ? API:8088 (YARP Proxy) ? ReceiverUI:52936 (Blazor WASM)
|
- client-side services
|
||||||
? Auth.API:9090 (External Auth Service)
|
- client models and options
|
||||||
```
|
- sender and receiver login flows
|
||||||
|
|
||||||
**Configuration:** `EnvelopeGenerator.API/yarp.json`
|
### Other Projects
|
||||||
|
- `EnvelopeGenerator.Application` — MediatR/CQRS handlers and business logic
|
||||||
|
- `EnvelopeGenerator.Domain` — domain models, constants, shared abstractions
|
||||||
|
- `EnvelopeGenerator.Infrastructure` — EF Core and infrastructure services
|
||||||
|
- `EnvelopeGenerator.PdfEditor` — PDF-related backend utilities
|
||||||
|
- `EnvelopeGenerator.API` — still exists in the solution, but the current merged app host is `EnvelopeGenerator.Server`
|
||||||
|
|
||||||
|
### Legacy / Do Not Touch
|
||||||
|
- `EnvelopeGenerator.Service`
|
||||||
|
- `EnvelopeGenerator.Form`
|
||||||
|
- `EnvelopeGenerator.BBTests`
|
||||||
|
- `EnvelopeGenerator.CommonServices`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ReceiverUI Route Structure
|
## Current Hosting Model
|
||||||
|
|
||||||
### Root Route
|
`EnvelopeGenerator.Server/Program.cs` currently configures:
|
||||||
| Route | File | Purpose |
|
- `AddRazorComponents()` with both interactive server and interactive WebAssembly components
|
||||||
|---|---|---|
|
- `AddControllers()` and `MapControllers()`
|
||||||
| `/` | `Index.razor` | Application entry point (landing page). |
|
- JWT authentication for sender and receiver flows
|
||||||
|
- cookie authentication
|
||||||
|
- authorization policies using `AuthScheme.Sender`, `AuthScheme.Receiver`, `AuthPolicy.Sender`, `AuthPolicy.Receiver`
|
||||||
|
- `AddReverseProxy()` with `yarp.json`
|
||||||
|
- Swagger / OpenAPI / Scalar
|
||||||
|
- distributed SQL Server cache
|
||||||
|
- DevExpress Blazor and DevExpress PDF Viewer server-side services
|
||||||
|
- request localization middleware
|
||||||
|
|
||||||
### Sender Routes
|
This means the active app is a **merged UI + API host**.
|
||||||
| Route | File | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `/sender/login` | `LoginSenderPage.razor` | Username/password authentication |
|
|
||||||
| `/sender` | `EnvelopeSenderPage.razor` | Sender dashboard (envelope list) |
|
|
||||||
|
|
||||||
### Receiver Routes
|
|
||||||
| Route | File | Purpose |
|
|
||||||
|---|---|---|
|
|
||||||
| `/envelope/login/{EnvelopeKey}` | `LoginReceiverPage.razor` | Access code authentication for specific envelope |
|
|
||||||
| `/envelope/{EnvelopeKey}` | `EnvelopeReceiverPage.razor` | View & sign envelope (PDF.js viewer) |
|
|
||||||
|
|
||||||
**Multi-Envelope Support:** Receivers can login to multiple envelopes simultaneously (per-envelope cookie authentication).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture Evolution
|
## Reverse Proxy
|
||||||
|
|
||||||
### Old Architecture (Deprecated)
|
**Config file:** `EnvelopeGenerator.Server/EnvelopeGenerator.Server/yarp.json`
|
||||||
- **Sender UI:** `EnvelopeGenerator.Web` (Razor Pages + PSPDFKit)
|
|
||||||
- **Receiver UI:** `EnvelopeGenerator.ReceiverUI` (Blazor WASM + PDF.js)
|
|
||||||
- **Backend:** `EnvelopeGenerator.API`
|
|
||||||
|
|
||||||
### Current Architecture
|
Current YARP usage is focused on **AuthHub forwarding**, not a general `/api/* -> EnvelopeGenerator.API` proxy.
|
||||||
- **Unified Frontend:** `EnvelopeGenerator.ReceiverUI` (Blazor WASM) — **Both Senders & Receivers**
|
|
||||||
- **Backend:** `EnvelopeGenerator.API` — **Both Senders & Receivers**
|
Configured routes forward:
|
||||||
- **Libraries:** DevExpress + PDF.js
|
- `POST /api/auth` -> AuthHub `/api/auth/sign-flow`
|
||||||
- **PSPDFKit:** **REMOVED**
|
- `POST /api/Auth/envelope-receiver/{key}` -> AuthHub `/api/auth/envelope-receiver/{key}?cookie=true`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Solution Structure
|
## Active Routes and Files
|
||||||
|
|
||||||
| Project | Target | Purpose |
|
### WebAssembly Pages (`EnvelopeGenerator.Server.Client`)
|
||||||
|---|---|---|
|
| Route | File | Render Mode | Purpose |
|
||||||
| `EnvelopeGenerator.API` | net8.0 | ASP.NET Core Web API. Backend for **both Senders & Receivers**. Auth, PDF serving, signature endpoints. |
|
|
||||||
| `EnvelopeGenerator.ReceiverUI` | net8.0 WASM | **Unified Blazor WebAssembly Frontend**. UI for **both Senders & Receivers**. YARP proxy to API. |
|
|
||||||
| `EnvelopeGenerator.Web` | net7/8/9 | **DEPRECATED.** Legacy Razor Pages (Sender UI). No longer used. |
|
|
||||||
| `EnvelopeGenerator.Application` | multi | MediatR CQRS handlers. Business logic. |
|
|
||||||
| `EnvelopeGenerator.Domain` | multi | Domain models, constants, interfaces. |
|
|
||||||
| `EnvelopeGenerator.Infrastructure` | multi | EF Core repos, DB context. |
|
|
||||||
| `EnvelopeGenerator.PdfEditor` | multi | iText7 utilities (NOT used in ReceiverUI). |
|
|
||||||
| `EnvelopeGenerator.DependencyInjection` | multi | DI registration helpers. |
|
|
||||||
| **VB.NET projects** (Service/Form/BBTests) | net462 | **Legacy. Do NOT touch.** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Localization & Culture Management
|
|
||||||
|
|
||||||
**Current Architecture:** Blazor WebAssembly (client-side culture management)
|
|
||||||
|
|
||||||
### Implementation Details
|
|
||||||
|
|
||||||
**Culture Storage:**
|
|
||||||
- Culture preference stored in browser's `localStorage` (key: `AppCulture`)
|
|
||||||
- Managed by `CultureService.cs` (ReceiverUI/Services)
|
|
||||||
- Supported cultures: `de-DE`, `en-US`, `fr-FR`
|
|
||||||
|
|
||||||
**Culture Initialization:**
|
|
||||||
- **Location:** `Program.cs` (lines 53-57)
|
|
||||||
- Sets `CultureInfo.DefaultThreadCurrentCulture/UICulture` **before** app runs
|
|
||||||
- **WASM-Safe:** Each user has isolated browser instance
|
|
||||||
|
|
||||||
**Language Selector:**
|
|
||||||
- **Component:** `LanguageSelector.razor` (ReceiverUI/Shared)
|
|
||||||
- Displays flag icon + language name
|
|
||||||
- Changes culture via `CultureService.SetCultureAsync()`
|
|
||||||
- Navigates with `forceLoad: false` (smooth transition, no page reload)
|
|
||||||
|
|
||||||
### ⚠️ MIGRATION WARNING: Blazor Server/Auto
|
|
||||||
|
|
||||||
**Current approach is WASM-specific and will break in Server/Auto render modes!**
|
|
||||||
|
|
||||||
**Why it breaks:**
|
|
||||||
- `Program.cs:53-57` sets **global** `DefaultThreadCurrentCulture`
|
|
||||||
- In Server/Auto, one app instance serves **all users**
|
|
||||||
- User A selects German → User B sees German too (shared state)
|
|
||||||
- Thread-safety issues and culture conflicts
|
|
||||||
|
|
||||||
**Migration Checklist (when moving to Server/Auto):**
|
|
||||||
|
|
||||||
1. **Remove global culture initialization** from `Program.cs` (lines 53-57)
|
|
||||||
- See detailed warning comment in the code
|
|
||||||
|
|
||||||
2. **Add RequestLocalizationMiddleware** (Server-side approach):
|
|
||||||
```csharp
|
|
||||||
app.UseRequestLocalization(options => {
|
|
||||||
options.SupportedCultures = new[] { "de-DE", "en-US", "fr-FR" };
|
|
||||||
options.SupportedUICultures = options.SupportedCultures;
|
|
||||||
options.RequestCultureProviders.Insert(0, new CookieRequestCultureProvider());
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **OR** Use **per-circuit culture** (Blazor Server approach):
|
|
||||||
- Store culture in circuit-scoped service
|
|
||||||
- Use `CascadingParameter` to distribute to components
|
|
||||||
- See: https://learn.microsoft.com/aspnet/core/blazor/globalization-localization
|
|
||||||
|
|
||||||
4. **Update `LanguageSelector.razor`:**
|
|
||||||
- Remove manual `CultureInfo.DefaultThreadCurrentCulture` assignment
|
|
||||||
- Use middleware/circuit culture provider instead
|
|
||||||
|
|
||||||
5. **Update `CultureService.cs`:**
|
|
||||||
- Integrate with Server-side culture provider
|
|
||||||
- May need to store in cookies instead of localStorage
|
|
||||||
|
|
||||||
**References:**
|
|
||||||
- Microsoft Docs: [Blazor Globalization/Localization](https://learn.microsoft.com/aspnet/core/blazor/globalization-localization)
|
|
||||||
- Current implementation: `Program.cs`, `CultureService.cs`, `LanguageSelector.razor`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Files & Routes
|
|
||||||
|
|
||||||
| File | Route/Purpose |
|
|
||||||
|---|---|
|
|
||||||
| `ReceiverUI/Pages/Index.razor` | `/` — Application entry point (landing page). |
|
|
||||||
| `ReceiverUI/Pages/EnvelopeSenderPage.razor` | `/sender` — Sender dashboard (envelope list). |
|
|
||||||
| `ReceiverUI/Pages/EnvelopeReceiverPage.razor` | `/envelope/{key}` — Receiver PDF viewer & signing. |
|
|
||||||
| `ReceiverUI/Pages/LoginSenderPage.razor` | `/sender/login` — Sender username/password auth. |
|
|
||||||
| `ReceiverUI/Pages/LoginReceiverPage.razor` | `/envelope/login/{EnvelopeKey}` — Receiver access code auth. |
|
|
||||||
| `ReceiverUI/wwwroot/js/pdf-viewer.js` | PDF.js wrapper (zoom, pagination, thumbnails). |
|
|
||||||
| `ReceiverUI/wwwroot/js/receiver-signature.js` | Signature pad (draw/type/image). |
|
|
||||||
| `ReceiverUI/wwwroot/css/envelope-viewer.css` | EnvelopeViewer styles. |
|
|
||||||
| `ReceiverUI/Services/AuthService.cs` | Receiver + Sender authentication. |
|
|
||||||
| `ReceiverUI/Services/SignatureCacheService.cs` | Signature caching (Redis/SQL). |
|
|
||||||
| `API/Controllers/CacheController.cs` | Signature cache endpoints. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Coordinate System — CRITICAL
|
|
||||||
|
|
||||||
**Database Format:** INCHES (GdPicture14 native)
|
|
||||||
**Origin:** Top-left corner
|
|
||||||
**Axes:** X right, Y down
|
|
||||||
|
|
||||||
### Conversion Formulas
|
|
||||||
|
|
||||||
| From INCHES to | Formula | Example |
|
|
||||||
|---|---|---|
|
|
||||||
| **DevExpress DX** | `x_DX = x_inches * 100` | 1.5" ? 150 DX |
|
|
||||||
| **PDF Points** | `x_pt = x_inches * 72` | 1.5" ? 108 pt |
|
|
||||||
| **PDF.js Pixels** | Normalize ? scale | `(x_inches / pageWidth) * canvasWidth * scale` |
|
|
||||||
|
|
||||||
**A4 Dimensions:**
|
|
||||||
- Width: 8.27" = 595pt = 827 DX
|
|
||||||
- Height: 11.69" = 842pt = 1169 DX
|
|
||||||
|
|
||||||
### Unit Systems
|
|
||||||
|
|
||||||
| System | Unit | Origin | Y-Axis |
|
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Database (GdPicture14)** | Inches | Top-left | Down |
|
| `/` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/IndexPage.razor` | WebAssembly | Landing page |
|
||||||
| PDF.js | Pixels | Top-left | Down |
|
| `/sender/login` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/LoginSenderPage.razor` | WebAssembly | Sender login |
|
||||||
| iText7 PDF | Points (1/72") | **Bottom-left** | **Up** (flip required) |
|
| `/sender` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/EnvelopeSenderPage.razor` | WebAssembly (`prerender: false`) | Sender dashboard |
|
||||||
| ~~PSPDFKit~~ | ~~Points~~ | ~~Top-left~~ | **REMOVED** |
|
| `/envelope/login/{EnvelopeKey}` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/LoginReceiverPage.razor` | WebAssembly | Receiver login |
|
||||||
|
|
||||||
|
### Server Pages (`EnvelopeGenerator.Server`)
|
||||||
|
| Route | File | Render Mode | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `/envelope/{EnvelopeKey}` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage.razor` | InteractiveServer | Main receiver PDF viewer and signing page |
|
||||||
|
| `/envelope/DxPdfViewer` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage_DxPdfViewer.razor` | InteractiveServer | DevExpress PDF Viewer test page |
|
||||||
|
| `/envelope/{EnvelopeKey}/DxReportViewer` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage_DxReportViewer.razor` | InteractiveServer | DevExpress report-based PDF rendering |
|
||||||
|
| `/envelope/Embed` | `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage_embed.razor` | InteractiveServer | Embedded browser PDF view test page |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## EnvelopeReceiver — PDF.js Viewer & Signing
|
## Current API Location
|
||||||
|
|
||||||
**Route:** `/envelope/{EnvelopeKey}`
|
The active application exposes controllers from:
|
||||||
**Tech:** PDF.js 3.11.174 + Blazor WASM + configurable quality
|
`EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers`
|
||||||
**File:** `ReceiverUI/Pages/EnvelopeReceiverPage.razor`
|
|
||||||
|
|
||||||
### Key Features
|
Current controller set includes:
|
||||||
1. HiDPI/Retina support (4x quality)
|
- `AnnotationController`
|
||||||
2. Configurable quality (`appsettings.json`)
|
- `AuthController`
|
||||||
3. Unlimited zoom (50%-300%)
|
- `CacheController`
|
||||||
4. Ctrl+Wheel global zoom
|
- `ConfigController`
|
||||||
5. Resizable thumbnail sidebar (150-400px, localStorage)
|
- `DocumentController`
|
||||||
6. Responsive (desktop/mobile)
|
- `EmailTemplateController`
|
||||||
|
- `EnvelopeController`
|
||||||
|
- `EnvelopeReceiverController`
|
||||||
|
- `EnvelopeTypeController`
|
||||||
|
- `HistoryController`
|
||||||
|
- `LocalizationController`
|
||||||
|
- `ReadOnlyController`
|
||||||
|
- `ReceiverController`
|
||||||
|
- `SignatureController`
|
||||||
|
- `TfaRegistrationController`
|
||||||
|
|
||||||
### Configuration
|
Do not assume API behavior lives only in `EnvelopeGenerator.API`; the active merged host contains controller endpoints directly.
|
||||||
**File:** `ReceiverUI/wwwroot/appsettings.json`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"PdfViewer": {
|
|
||||||
"ThumbnailBaseScale": 0.75,
|
|
||||||
"ThumbnailEnableHiDPI": true,
|
|
||||||
"MainCanvasEnableHiDPI": true,
|
|
||||||
"ZoomStepPercentage": 5
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### JavaScript API
|
|
||||||
**File:** `ReceiverUI/wwwroot/js/pdf-viewer.js`
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
window.pdfViewer = {
|
|
||||||
initialize(canvasId, pdfDataUrl, dotNetRef),
|
|
||||||
renderPage(num),
|
|
||||||
renderSignatureButtons(signatures, pageNum, dotNetRef),
|
|
||||||
applySignature(signatureId, dataUrl, fullName, position, place),
|
|
||||||
zoomIn(), zoomOut(), dispose()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Signature Workflow — EnvelopeReceiver
|
## Authentication Model
|
||||||
|
|
||||||
**IMPORTANT:** iText7 NOT used (GPL license issue). Client-side overlay system only.
|
### Sender
|
||||||
|
Client login page uses `EnvelopeGenerator.Server.Client/Services/AuthService.cs`.
|
||||||
|
|
||||||
### Workflow Steps
|
Key sender endpoints:
|
||||||
|
- `POST /api/auth?cookie=true` — login
|
||||||
|
- `GET /api/auth/check` — current sender access check
|
||||||
|
- `POST /api/auth/logout` — logout
|
||||||
|
|
||||||
1. **Page Load:**
|
### Receiver
|
||||||
- Check `SignatureCacheService` for cached signature
|
Receiver authentication is **per envelope**.
|
||||||
- If cached ? skip popup, load signature
|
|
||||||
- If not ? show automatic popup (mandatory)
|
|
||||||
|
|
||||||
2. **Signature Popup (DxPopup):**
|
Key receiver endpoints used by client services:
|
||||||
- **Cannot close** (no X, no ESC, no outside-click)
|
- `POST /api/Auth/envelope-receiver/{envelopeKey}` — submit access code
|
||||||
- **3 Tabs:** Draw (canvas) / Text (font select) / Image (upload)
|
- `GET /api/auth/check/envelope/{envelopeKey}` — check access
|
||||||
- **Required:** Full name, Place
|
- `POST /api/auth/logout/envelope/{envelopeKey}` — logout receiver for one envelope
|
||||||
- **Optional:** Position
|
|
||||||
- **Save ?** Store in `_capturedSignature`, cache via API
|
|
||||||
|
|
||||||
3. **Signature Buttons:**
|
Receiver cookie resolution in server auth uses an envelope-specific cookie name derived from:
|
||||||
- Render purple "Unterschreiben" buttons at signature field positions
|
- `AuthTokenSignFLOWReceiver.{envelopeKey}` pattern
|
||||||
- Coordinates: INCHES ? POINTS ? Pixels (scaled)
|
|
||||||
- File: `pdf-viewer.js` ? `renderSignatureButtons()`
|
|
||||||
|
|
||||||
4. **Apply Signature (Click "Unterschreiben"):**
|
### Receiver Server-Side Authorization
|
||||||
- JS: Remove button, create HTML overlay
|
`EnvelopeReceiverPage.razor` does **not** rely on its own API access-check call for page authorization.
|
||||||
- Format: Image + separator + text (Name, Position, Place, Date)
|
|
||||||
- **NOT stamped on PDF bytes** (visual overlay only)
|
|
||||||
|
|
||||||
5. **Re-rendering:**
|
It uses:
|
||||||
- Zoom/Page change ? recalculate button positions
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverAuthorizationService.cs`
|
||||||
- Session state: `_capturedSignature` (lost on refresh)
|
|
||||||
|
|
||||||
### Data Model
|
Behavior:
|
||||||
**File:** `ReceiverUI/Models/SignatureCaptureDto.cs`
|
- tries the current `HttpContext.User`
|
||||||
|
- if needed, reads the per-envelope receiver cookie directly
|
||||||
|
- validates the JWT with the receiver auth scheme
|
||||||
|
- verifies the token subject matches the route envelope key
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Receiver Page Data Loading
|
||||||
|
|
||||||
|
Main server-side page data service:
|
||||||
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverPageDataService.cs`
|
||||||
|
|
||||||
|
This service loads directly via MediatR and distributed cache:
|
||||||
|
- document bytes
|
||||||
|
- receiver envelope data
|
||||||
|
- signature placeholders
|
||||||
|
- cached signature data
|
||||||
|
|
||||||
|
For signature placeholders, the service:
|
||||||
|
- reads document receiver elements
|
||||||
|
- filters them for the authenticated receiver
|
||||||
|
- converts coordinates to `UnitOfLength.Point` before UI use
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Receiver PDF Viewer
|
||||||
|
|
||||||
|
**Main file:** `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage.razor`
|
||||||
|
|
||||||
|
Current receiver viewer characteristics:
|
||||||
|
- route: `/envelope/{EnvelopeKey}`
|
||||||
|
- render mode: `InteractiveServer`
|
||||||
|
- PDF rendering: `PDF.js`
|
||||||
|
- toolbar: page navigation, zoom, thumbnail toggle, signature navigation, signature reset
|
||||||
|
- signature popup: `DxPopup`
|
||||||
|
- thumbnail sidebar: resizable and stored in `localStorage`
|
||||||
|
|
||||||
|
### JS Assets
|
||||||
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/pdf-viewer.js`
|
||||||
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/receiver-signature.js`
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/envelope-viewer.css`
|
||||||
|
|
||||||
|
### PDF.js CDN
|
||||||
|
- `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js`
|
||||||
|
- `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf_viewer.min.css`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Signature Workflow
|
||||||
|
|
||||||
|
Receiver signatures are handled as a **viewer overlay workflow**.
|
||||||
|
|
||||||
|
### Current behavior
|
||||||
|
1. Server-side authorization validates receiver access.
|
||||||
|
2. The page loads document bytes, receiver data, signature placeholders, and cached signature state.
|
||||||
|
3. If no cached signature exists, the signature popup opens automatically.
|
||||||
|
4. Receiver creates signature using one of three tabs:
|
||||||
|
- draw
|
||||||
|
- text
|
||||||
|
- image
|
||||||
|
5. Required metadata:
|
||||||
|
- full name
|
||||||
|
- place
|
||||||
|
6. Optional metadata:
|
||||||
|
- position
|
||||||
|
7. Clicking a signature placeholder applies the signature as a client-side overlay in the PDF viewer.
|
||||||
|
|
||||||
|
### Important note
|
||||||
|
Although `itext` is referenced by the server project, the current receiver page signing flow is **not PDF stamping-based**. The active receiver UI uses client-side overlay behavior in the viewer.
|
||||||
|
|
||||||
|
### Signature DTO
|
||||||
|
`EnvelopeGenerator.Server.Client/Models/SignatureCaptureDto.cs`
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public sealed record SignatureCaptureDto {
|
public sealed record SignatureCaptureDto {
|
||||||
public required string DataUrl { get; init; } // base64 PNG
|
public required string DataUrl { get; init; }
|
||||||
public required string FullName { get; init; }
|
public required string FullName { get; init; }
|
||||||
public string Position { get; init; } = ""; // Optional
|
public string Position { get; init; } = "";
|
||||||
public required string Place { get; init; }
|
public required string Place { get; init; }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Signature Caching
|
## Signature Cache
|
||||||
|
|
||||||
**Purpose:** Persist signature across page refreshes (distributed cache: Redis/SQL)
|
### Active cache model
|
||||||
|
The current receiver page cache flow is handled directly in the server project through:
|
||||||
|
- `EnvelopeReceiverPageDataService`
|
||||||
|
- `IDistributedCache`
|
||||||
|
- SQL Server distributed cache configuration from `Program.cs`
|
||||||
|
|
||||||
### API Endpoints
|
### Cache key format
|
||||||
**Controller:** `API/Controllers/CacheController.cs`
|
Current server-side key prefix:
|
||||||
|
- `envelope-generator.receiver-ui.signature:{receiverSignature}`
|
||||||
|
|
||||||
- `POST /api/Cache/SignatureCapture/{envelopeKey}` — Save
|
This is different from an envelope-key-only cache convention.
|
||||||
- `GET /api/Cache/SignatureCapture/{envelopeKey}` — Load
|
|
||||||
- `DELETE /api/Cache/SignatureCapture/{envelopeKey}` — Delete
|
|
||||||
|
|
||||||
**Cache Key Format:**
|
### Config
|
||||||
```
|
`EnvelopeGenerator.Server/EnvelopeGenerator.Server/Options/CacheOptions.cs`
|
||||||
signature:91751687-8ae6-4777-bf5f-b8846085e62e:{envelopeKey}
|
- section name: `Cache`
|
||||||
```
|
- option: `SignatureCacheExpiration`
|
||||||
|
|
||||||
**Configuration:** `appsettings.json`
|
### Related controller
|
||||||
```json
|
A cache API controller also exists in:
|
||||||
{
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/CacheController.cs`
|
||||||
"Cache": {
|
|
||||||
"SignatureCacheExpiration": null // or "02:00:00" for 2h
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Service
|
|
||||||
**File:** `ReceiverUI/Services/SignatureCacheService.cs`
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class SignatureCacheService {
|
|
||||||
Task SaveSignatureAsync(string envelopeKey, SignatureCaptureDto signature);
|
|
||||||
Task<SignatureCaptureDto?> GetSignatureAsync(string envelopeKey);
|
|
||||||
Task DeleteSignatureAsync(string envelopeKey);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Error Handling:** Fire-and-forget saves, graceful degradation on load failure.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sender Login
|
## Sender Dashboard
|
||||||
|
|
||||||
**Route:** `/sender/login`
|
**Main file:** `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Pages/EnvelopeSenderPage.razor`
|
||||||
**File:** `ReceiverUI/Pages/LoginSenderPage.razor`
|
|
||||||
**Tech:** Bootstrap 5 + DevExpress Blazing Berry theme
|
|
||||||
|
|
||||||
### AuthService Extension
|
Current behavior:
|
||||||
**File:** `ReceiverUI/Services/AuthService.cs`
|
- checks sender access through `AuthService.CheckSenderAccessAsync()`
|
||||||
|
- redirects to `/sender/login` when unauthorized
|
||||||
|
- loads envelope list through client `EnvelopeService`
|
||||||
|
- separates envelopes into active/completed tabs
|
||||||
|
- uses `DevExpress DxGrid`
|
||||||
|
|
||||||
```csharp
|
The sender page is active, but create/edit/delete actions are still marked with TODO behavior in the UI page.
|
||||||
public enum SenderLoginResult { Success, InvalidCredentials, Error }
|
|
||||||
|
|
||||||
public async Task<SenderLoginResult> LoginSenderAsync(string username, string password) {
|
|
||||||
var response = await http.PostAsJsonAsync(
|
|
||||||
$"{_api.BaseUrl}/api/auth?cookie=true",
|
|
||||||
new { username, password });
|
|
||||||
|
|
||||||
return response.StatusCode switch {
|
|
||||||
HttpStatusCode.OK => SenderLoginResult.Success,
|
|
||||||
HttpStatusCode.Unauthorized => SenderLoginResult.InvalidCredentials,
|
|
||||||
_ => SenderLoginResult.Error
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Integration
|
|
||||||
**Endpoint:** `POST /api/auth?cookie=true`
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
```json
|
|
||||||
{ "username": "TekH", "password": "***" }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
- `200 OK` ? Cookie set, redirect to `/sender`
|
|
||||||
- `401 Unauthorized` ? Show error: "Ungültige Anmeldedaten"
|
|
||||||
- Other ? Show error: "Serverfehler"
|
|
||||||
|
|
||||||
**Cookie:** HTTP-only, Secure (HTTPS), SameSite=Strict
|
|
||||||
|
|
||||||
### UI Flow
|
|
||||||
1. User enters username + password
|
|
||||||
2. Click "Anmelden" or press Enter
|
|
||||||
3. Call `AuthService.LoginSenderAsync()`
|
|
||||||
4. Success ? `Navigation.NavigateTo("/sender", forceLoad: true)`
|
|
||||||
5. Error ? Display alert
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Receiver Login
|
## Localization
|
||||||
|
|
||||||
**Route:** `/envelope/login/{EnvelopeKey}`
|
Current server host localization setup in `Program.cs`:
|
||||||
**File:** `ReceiverUI/Pages/LoginReceiverPage.razor`
|
- supported cultures: `de-DE`, `en-US`
|
||||||
|
- request localization middleware is enabled
|
||||||
|
- `QueryStringRequestCultureProvider` is added
|
||||||
|
- cookie-based localization services are registered via `AddCookieBasedLocalizer()`
|
||||||
|
|
||||||
**Multi-Envelope Support:** Cookies are stored per-envelope (e.g., `AuthTokenSignFLOWReceiver.{envelopeKey}`), allowing simultaneous authentication for multiple envelopes in the same browser session.
|
Do not assume the old ReceiverUI-only `localStorage` culture approach is the current source of truth for the active host.
|
||||||
|
|
||||||
### AuthService Method
|
|
||||||
```csharp
|
|
||||||
public enum EnvelopeLoginResult { Success, InvalidCode, NotFound, Error }
|
|
||||||
|
|
||||||
public async Task<EnvelopeLoginResult> LoginEnvelopeReceiverAsync(string key, string accessCode) {
|
|
||||||
var form = new MultipartFormDataContent();
|
|
||||||
form.Add(new StringContent(accessCode), "AccessCode");
|
|
||||||
|
|
||||||
var response = await http.PostAsync(
|
|
||||||
$"{_api.BaseUrl}/api/Auth/envelope-receiver/{Uri.EscapeDataString(key)}", form);
|
|
||||||
|
|
||||||
return response.StatusCode switch {
|
|
||||||
HttpStatusCode.OK => EnvelopeLoginResult.Success,
|
|
||||||
HttpStatusCode.Unauthorized => EnvelopeLoginResult.InvalidCode,
|
|
||||||
HttpStatusCode.NotFound => EnvelopeLoginResult.NotFound,
|
|
||||||
_ => EnvelopeLoginResult.Error
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Success:** Redirect to `/envelope/{key}`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## NuGet Packages (ReceiverUI)
|
## Coordinate System
|
||||||
|
|
||||||
| Package | Version | Purpose |
|
### Source data
|
||||||
|---|---|---|
|
Database signature coordinates are still based on:
|
||||||
| `DevExpress.Blazor.*` | 25.2.3 | UI components (grids, popups, etc.) |
|
- **unit:** inches
|
||||||
| `SkiaSharp.*` | 3.119.1 | WASM rendering |
|
- **origin:** top-left
|
||||||
| ~~`itext`~~ | ~~8.0.5~~ | **NOT USED** (GPL license) |
|
- **axes:** X right, Y down
|
||||||
|
|
||||||
**External CDN:**
|
### Relevant conversions
|
||||||
- PDF.js 3.11.174: `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js`
|
- inches -> PDF points: `x_pt = x_inches * 72`
|
||||||
|
- inches -> DevExpress DX units: `x_dx = x_inches * 100`
|
||||||
|
|
||||||
|
### Current receiver page behavior
|
||||||
|
The server page data service converts signature placeholders to **points** before sending them into the viewer workflow.
|
||||||
|
|
||||||
|
### Unit systems to keep in mind
|
||||||
|
| System | Unit | Origin | Y-axis |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Database | Inches | Top-left | Down |
|
||||||
|
| PDF.js display | Pixels | Top-left | Down |
|
||||||
|
| PDF points | Points | Depends on PDF model | Depends on consumer |
|
||||||
|
| DevExpress DX | 1/100 inch style coordinates | Top-left-oriented usage in this app | Down-oriented usage |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Mistakes History — Do NOT Repeat
|
## Key Services and Files
|
||||||
|
|
||||||
| Mistake | Why Wrong |
|
### Client services
|
||||||
|---|---|
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/AuthService.cs`
|
||||||
| Using iText7 in EnvelopeReceiver | GPL license issue. Use overlay system instead. |
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/EnvelopeService.cs`
|
||||||
| Using PSPDFKit | Removed from architecture. Use PDF.js + DevExpress. |
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/DocumentService.cs`
|
||||||
| Hardcoded quality values in PDF.js | Use `appsettings.json` for configurability. |
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/SignatureCacheService.cs`
|
||||||
| Complex toolbar layouts | User wants simplicity. Keep horizontal layout. |
|
|
||||||
| Over-designed UI (gradients/badges) | User prefers simple text labels. |
|
### Server services
|
||||||
| Ignoring "revert" instructions | Revert HTML structure, not just CSS. |
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeAuthService.cs`
|
||||||
| `BottomMarginBand` for signatures | Repeats on every page. Use DetailBand. |
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/IEnvelopeAuthService.cs`
|
||||||
| `imageY = (page-1) * 1169 + ann.Y` | Inflates DetailBand. Calculate per-page. |
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverAuthorizationService.cs`
|
||||||
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverPageDataService.cs`
|
||||||
|
|
||||||
|
### Server config and host files
|
||||||
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Program.cs`
|
||||||
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/yarp.json`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development Notes
|
## Working Rules for This Workspace
|
||||||
|
|
||||||
### Deprecated Projects
|
- Treat `EnvelopeGenerator.Server` as the active main application host.
|
||||||
**DO NOT USE:**
|
- Treat `EnvelopeGenerator.Server.Client` as the active client UI project.
|
||||||
- `EnvelopeGenerator.Web` (Razor Pages) — Replaced by unified ReceiverUI
|
- Prefer current `Server` / `Server.Client` paths over old `WebUI` / `ReceiverUI` references.
|
||||||
- PSPDFKit — Removed, use PDF.js + DevExpress instead
|
- Do not use `EnvelopeGenerator.Web` or `EnvelopeGenerator.ReceiverUI` as the primary implementation target unless explicitly asked.
|
||||||
|
- Do not modify the legacy VB.NET projects unless explicitly requested.
|
||||||
### Legacy Projects (VB.NET)
|
- For receiver PDF/signature work, prefer the current `PDF.js`-based flow in `EnvelopeReceiverPage.razor`.
|
||||||
**DO NOT TOUCH:** `EnvelopeGenerator.Service`, `EnvelopeGenerator.Form`, `EnvelopeGenerator.BBTests`
|
- For DevExpress PDF viewer issues, remember server-side services are registered in `EnvelopeGenerator.Server`.
|
||||||
|
|
||||||
### Signature Coordinate Evidence
|
|
||||||
**File:** `EnvelopeGenerator.Form/frmFieldEditor.vb` (VB.NET)
|
|
||||||
|
|
||||||
```vb
|
|
||||||
Private Const SIGNATURE_WIDTH As Single = 1.77 ' inches
|
|
||||||
Private Const SIGNATURE_HEIGHT As Single = 1.96 ' inches
|
|
||||||
|
|
||||||
Sub LoadAnnotation(pElement As Signature, ...)
|
|
||||||
oAnnotation.Left = CSng(pElement.X) ' Direct INCHES assignment
|
|
||||||
oAnnotation.Top = CSng(pElement.Y)
|
|
||||||
End Sub
|
|
||||||
```
|
|
||||||
|
|
||||||
Proves database uses INCHES natively.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Reference
|
**Last Updated:** 2026-06-29
|
||||||
|
|
||||||
### When working with coordinates:
|
|
||||||
1. **Database ? UI:** INCHES × 72 = PDF Points
|
|
||||||
2. **UI ? Display:** Points × scale = Pixels
|
|
||||||
3. **iText7 stamping:** Flip Y-axis (top-down ? bottom-up)
|
|
||||||
|
|
||||||
### When adding features:
|
|
||||||
1. Check `Mistakes History` first
|
|
||||||
2. Prefer simplicity over complexity
|
|
||||||
3. Use `appsettings.json` for configuration
|
|
||||||
4. Keep consistent with existing design (Bootstrap 5 + Blazing Berry)
|
|
||||||
5. **Unified frontend:** ReceiverUI serves both Senders and Receivers
|
|
||||||
|
|
||||||
### When debugging:
|
|
||||||
1. **Coordinates:** Always check unit system (inches/points/pixels)
|
|
||||||
2. **Authentication:** Check cookie name/domain/SameSite
|
|
||||||
3. **Cache:** Check Redis/SQL connection + key format
|
|
||||||
4. **Frontend confusion:** Only use ReceiverUI (Web is deprecated)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** Session 19 (Razor file naming convention + Index route proxy)
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using DigitalData.EmailProfilerDispatcher.Abstraction.Attributes;
|
using DigitalData.EmailProfilerDispatcher.Abstraction.Attributes;
|
||||||
using DigitalData.UserManager.Application.DTOs.User;
|
using DigitalData.UserManager.Application.DTOs.User;
|
||||||
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
|
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto.History;
|
||||||
using EnvelopeGenerator.Domain.Constants;
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
using EnvelopeGenerator.Domain.Interfaces;
|
using EnvelopeGenerator.Domain.Interfaces;
|
||||||
@@ -129,4 +130,9 @@ public record EnvelopeDto : IEnvelope
|
|||||||
///
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IEnumerable<EnvelopeReceiverDto>? EnvelopeReceivers { get; set; }
|
public IEnumerable<EnvelopeReceiverDto>? EnvelopeReceivers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Envelope history entries tracking actions like DocumentSigned, EnvelopeOpened, etc.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<HistoryDto>? Histories { get; set; }
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using DigitalData.EmailProfilerDispatcher.Abstraction.Attributes;
|
using DigitalData.EmailProfilerDispatcher.Abstraction.Attributes;
|
||||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
|
namespace EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver;
|
||||||
|
|
||||||
@@ -73,4 +74,13 @@ public record EnvelopeReceiverDto
|
|||||||
///
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HasPhoneNumber { get; init; }
|
public bool HasPhoneNumber { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indicates whether this receiver has signed the envelope.
|
||||||
|
/// Checks if there is a DocumentSigned history entry for this receiver in the envelope's history.
|
||||||
|
/// </summary>
|
||||||
|
public bool Signed => Envelope?.Histories?.Any(h =>
|
||||||
|
h.Receiver?.Id == ReceiverId &&
|
||||||
|
h.Status == EnvelopeStatus.DocumentSigned
|
||||||
|
) ?? false;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace EnvelopeGenerator.Application.Common.Query;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Application.Common.Query;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stellt eine Abfrage dar, um die Details eines Empfängers zu lesen.
|
/// Stellt eine Abfrage dar, um die Details eines Empfängers zu lesen.
|
||||||
@@ -29,5 +31,6 @@ public record ReceiverQueryBase
|
|||||||
/// <see cref="Id"/>, <see cref="EmailAddress"/>, or <see cref="Signature"/> is not null.
|
/// <see cref="Id"/>, <see cref="EmailAddress"/>, or <see cref="Signature"/> is not null.
|
||||||
/// <para>Usage example: The query can be executed only if at least one criterion is specified.</para>
|
/// <para>Usage example: The query can be executed only if at least one criterion is specified.</para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool HasAnyCriteria => Id is not null || EmailAddress is not null || Signature is not null;
|
[NotMapped]
|
||||||
|
public virtual bool HasAnyCriteria => Id is not null || EmailAddress is not null || Signature is not null;
|
||||||
}
|
}
|
||||||
@@ -51,8 +51,8 @@ public static class DependencyInjection
|
|||||||
services.Configure<TotpSmsParams>(config.GetSection(nameof(TotpSmsParams)));
|
services.Configure<TotpSmsParams>(config.GetSection(nameof(TotpSmsParams)));
|
||||||
|
|
||||||
services.AddHttpClientService<GtxMessagingParams>(config.GetSection(nameof(GtxMessagingParams)));
|
services.AddHttpClientService<GtxMessagingParams>(config.GetSection(nameof(GtxMessagingParams)));
|
||||||
services.TryAddSingleton<ISmsSender, GTXSmsSender>();
|
services.TryAddScoped<ISmsSender, GTXSmsSender>(); // Changed: Singleton → Scoped
|
||||||
services.TryAddSingleton<IEnvelopeSmsHandler, EnvelopeSmsHandler>();
|
services.TryAddScoped<IEnvelopeSmsHandler, EnvelopeSmsHandler>(); // Changed: Singleton → Scoped
|
||||||
services.TryAddSingleton<IAuthenticator, Authenticator>();
|
services.TryAddSingleton<IAuthenticator, Authenticator>();
|
||||||
services.TryAddSingleton<QRCodeGenerator>();
|
services.TryAddSingleton<QRCodeGenerator>();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using EnvelopeGenerator.Application.Common.Query;
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
using EnvelopeGenerator.Domain.Entities;
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
namespace EnvelopeGenerator.Application.Receivers.Queries;
|
namespace EnvelopeGenerator.Application.Receivers.Queries;
|
||||||
|
|
||||||
@@ -12,7 +13,24 @@ namespace EnvelopeGenerator.Application.Receivers.Queries;
|
|||||||
/// Stellt eine Abfrage dar, um die Details eines Empfängers zu lesen.
|
/// Stellt eine Abfrage dar, um die Details eines Empfängers zu lesen.
|
||||||
/// um spezifische Informationen über einen Empfänger abzurufen.
|
/// um spezifische Informationen über einen Empfänger abzurufen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record ReadReceiverQuery : ReceiverQueryBase, IRequest<IEnumerable<ReceiverDto>>;
|
public record ReadReceiverQuery : ReceiverQueryBase, IRequest<IEnumerable<ReceiverDto>>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Suchbegriff für eine teilweise Übereinstimmung in der E-Mail Adresse des Empfängers
|
||||||
|
/// </summary>
|
||||||
|
public virtual string? EmailAddressSearch { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether any of the specified query criteria have a value.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This property returns <c>true</c> if at least one of the fields
|
||||||
|
/// <see cref="ReceiverQueryBase.Id"/>, <see cref="ReceiverQueryBase.EmailAddress"/>, <see cref="EmailAddressSearch"/>, or <see cref="ReceiverQueryBase.Signature"/> is not null.
|
||||||
|
/// <para>Usage example: The query can be executed only if at least one criterion is specified.</para>
|
||||||
|
/// </remarks>
|
||||||
|
[NotMapped]
|
||||||
|
public override bool HasAnyCriteria => EmailAddressSearch is not null || base.HasAnyCriteria;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
///
|
||||||
@@ -53,6 +71,11 @@ public class ReadReceiverQueryHandler : IRequestHandler<ReadReceiverQuery, IEnum
|
|||||||
query = query.Where(r => r.EmailAddress == email);
|
query = query.Where(r => r.EmailAddress == email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.EmailAddressSearch))
|
||||||
|
{
|
||||||
|
query = query.Where(r => EF.Functions.Like(r.EmailAddress, $"%{request.EmailAddressSearch}%"));
|
||||||
|
}
|
||||||
|
|
||||||
if (request.Signature is string signature)
|
if (request.Signature is string signature)
|
||||||
{
|
{
|
||||||
query = query.Where(r => r.Signature == signature);
|
query = query.Where(r => r.Signature == signature);
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Data {
|
||||||
|
public class Adjustment
|
||||||
|
{
|
||||||
|
public static Adjustment CreateBalanceForward(DateTime dt, int random)
|
||||||
|
{
|
||||||
|
var rnd = new DeterministicRandom(random);
|
||||||
|
Adjustment res = new Adjustment();
|
||||||
|
res.currentDateTime = dt;
|
||||||
|
res.currentDescription = "Balance Forward";
|
||||||
|
res.currentAmount = rnd.Random(10, 300) * 10;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
public static Adjustment CreatePayment(DateTime dt, int random)
|
||||||
|
{
|
||||||
|
var rnd = new DeterministicRandom(random);
|
||||||
|
Adjustment res = new Adjustment();
|
||||||
|
res.currentDateTime = dt;
|
||||||
|
res.currentDescription = "Payment";
|
||||||
|
res.currentAmount = -rnd.Random(1, 40) * 10;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
public static Adjustment CreateCharge(DateTime dt, int random)
|
||||||
|
{
|
||||||
|
var rnd = new DeterministicRandom(random);
|
||||||
|
Adjustment res = new Adjustment();
|
||||||
|
res.currentDateTime = dt;
|
||||||
|
res.currentDescription = rnd.GetRandomItem(bills);
|
||||||
|
res.currentAmount = rnd.Random(10, 50) * 10;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime currentDateTime;
|
||||||
|
string currentDescription = "";
|
||||||
|
double currentAmount = 0;
|
||||||
|
static readonly string[] bills = new string[] { "Bill - Insurance", "Bill - Electricity", "Bill - Rent", "Bill - Phone", "Bill - Office Supplies" };
|
||||||
|
public DateTime Date { get { return currentDateTime; } }
|
||||||
|
public string Description { get { return currentDescription; } }
|
||||||
|
public double Amount { get { return currentAmount; } }
|
||||||
|
|
||||||
|
public Adjustment()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using DevExpress.DataAccess.Sql;
|
||||||
|
using DevExpress.DataAccess.Sql.DataApi;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Data {
|
||||||
|
public class Customer {
|
||||||
|
static List<Customer> currentCustomers = new List<Customer>();
|
||||||
|
|
||||||
|
public static List<Customer> Customers { get { return currentCustomers; } }
|
||||||
|
static Customer() {
|
||||||
|
try {
|
||||||
|
SqlDataSource ds = new SqlDataSource("NWindConnectionString");
|
||||||
|
SelectQuery query = SelectQueryFluentBuilder
|
||||||
|
.AddTable("Customers")
|
||||||
|
.SelectAllColumns()
|
||||||
|
.Build("Customers");
|
||||||
|
ds.Queries.Add(query);
|
||||||
|
ds.RebuildResultSchema();
|
||||||
|
ds.Fill();
|
||||||
|
ITable src = ds.Result["Customers"];
|
||||||
|
foreach(var row in src) {
|
||||||
|
currentCustomers.Add(new Customer() {
|
||||||
|
CustomerID = row.GetValue<string>("CustomerID"),
|
||||||
|
Address = row.GetValue<string>("Address"),
|
||||||
|
CompanyName = row.GetValue<string>("CompanyName"),
|
||||||
|
ContactName = row.GetValue<string>("ContactName"),
|
||||||
|
ContactTitle = row.GetValue<string>("ContactTitle"),
|
||||||
|
Country = row.GetValue<string>("Country"),
|
||||||
|
City = row.GetValue<string>("City"),
|
||||||
|
Fax = row.GetValue<string>("Fax"),
|
||||||
|
Phone = row.GetValue<string>("Phone"),
|
||||||
|
PostalCode = row.GetValue<string>("PostalCode"),
|
||||||
|
Region = row.GetValue<string>("Region")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
currentCustomers.Add(new Customer() {
|
||||||
|
Address = "Obere Str. 57",
|
||||||
|
City = "Berlin",
|
||||||
|
CompanyName = "Alfreds Futterkiste",
|
||||||
|
ContactName = "Maria Anders",
|
||||||
|
ContactTitle = "Sales Representative",
|
||||||
|
Country = "Germany",
|
||||||
|
CustomerID = "ALFKI",
|
||||||
|
Fax = "030-0076545",
|
||||||
|
Phone = "030-0074321",
|
||||||
|
PostalCode = "12209"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string CustomerID { get; set; }
|
||||||
|
public string CompanyName { get; set; }
|
||||||
|
public string ContactName { get; set; }
|
||||||
|
public string ContactTitle { get; set; }
|
||||||
|
public string Address { get; set; }
|
||||||
|
public string City { get; set; }
|
||||||
|
public string PostalCode { get; set; }
|
||||||
|
public string Region { get; set; }
|
||||||
|
public string Country { get; set; }
|
||||||
|
public string Phone { get; set; }
|
||||||
|
public string Fax { get; set; }
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Data {
|
||||||
|
public class DataItem {
|
||||||
|
static readonly string[] accountType = new string[] { "Energy", "Manufacturing", "Estate", "Food", "Services" };
|
||||||
|
public string CustomerID { get; set; }
|
||||||
|
public string CompanyName { get; set; }
|
||||||
|
public string ContactName { get; set; }
|
||||||
|
public string ContactTitle { get; set; }
|
||||||
|
public string Address { get; set; }
|
||||||
|
public string City { get; set; }
|
||||||
|
public string PostalCode { get; set; }
|
||||||
|
public string Region { get; set; }
|
||||||
|
public string Country { get; set; }
|
||||||
|
public string Phone { get; set; }
|
||||||
|
public string Fax { get; set; }
|
||||||
|
public string Email { get; set; }
|
||||||
|
public string Invoice { get; set; }
|
||||||
|
public string CustomerAccount { get; set; }
|
||||||
|
public string CustomerIdentifiers { get; set; }
|
||||||
|
public DateTime BillingDate { get; set; }
|
||||||
|
public DateTime BillingPeriodStart { get; set; }
|
||||||
|
public DateTime BillingPeriodEnd { get; set; }
|
||||||
|
public string Terms { get; set; }
|
||||||
|
public string TermsID { get; set; }
|
||||||
|
public Adjustment[] Adjustments { get; set; }
|
||||||
|
|
||||||
|
public DataItem(int i) {
|
||||||
|
var rnd = new DeterministicRandom(i);
|
||||||
|
Customer c = rnd.GetRandomItem(Customer.Customers);
|
||||||
|
CustomerID = c.CustomerID;
|
||||||
|
CompanyName = c.CompanyName;
|
||||||
|
ContactName = c.ContactName;
|
||||||
|
ContactTitle = c.ContactTitle;
|
||||||
|
Address = c.Address;
|
||||||
|
City = c.City;
|
||||||
|
PostalCode = c.PostalCode;
|
||||||
|
Region = c.Region;
|
||||||
|
Country = c.Country;
|
||||||
|
Phone = c.Phone;
|
||||||
|
Fax = c.Fax;
|
||||||
|
Email = ContactName.Split(' ')[0].Replace(' ', '.').ToLower() + "@" + CompanyName.Split(' ')[0].ToLower() + ".com";
|
||||||
|
Invoice = string.Format("{0}{1}-{2}", rnd.RandomChar, rnd.Random(100, 1000), rnd.Random(100, 1000));
|
||||||
|
CustomerAccount = rnd.GetRandomItem(accountType);
|
||||||
|
CustomerIdentifiers = string.Format("{0}-{1}", rnd.Random(1000, 10000), rnd.Random(10, 100));
|
||||||
|
BillingPeriodStart = rnd.RandomTime();
|
||||||
|
BillingPeriodEnd = rnd.RandomTime(BillingPeriodStart, 7 * 24, 30 * 24);
|
||||||
|
BillingDate = rnd.RandomTime(BillingPeriodEnd, 7 * 24, 30 * 24);
|
||||||
|
Term currentTerm = rnd.GetRandomItem(Term.Terms);
|
||||||
|
Terms = currentTerm.Name;
|
||||||
|
|
||||||
|
int adjustmentsCount = rnd.Random(6) + 4;
|
||||||
|
Adjustments = new Adjustment[adjustmentsCount];
|
||||||
|
int h = (int)((BillingPeriodEnd - BillingPeriodStart).TotalHours / adjustmentsCount);
|
||||||
|
|
||||||
|
Adjustments[0] = Adjustment.CreateBalanceForward(rnd.RandomTime(BillingPeriodStart, 0, h), rnd.Random(10000));
|
||||||
|
|
||||||
|
int[] items = rnd.RandomList(adjustmentsCount - 1, 2);
|
||||||
|
|
||||||
|
for(int j = 1; j < Adjustments.Length; j++) {
|
||||||
|
DateTime nextDate = rnd.RandomTime(BillingPeriodStart.AddHours(h * j), 0, h);
|
||||||
|
switch(items[j - 1]) {
|
||||||
|
case 0:
|
||||||
|
Adjustments[j] = Adjustment.CreateCharge(nextDate, rnd.Random(10000));
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
Adjustments[j] = Adjustment.CreatePayment(nextDate, rnd.Random(10000));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System.Collections;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Data {
|
||||||
|
public class DataItemList : IList<DataItem>, IList {
|
||||||
|
readonly int rowCount;
|
||||||
|
|
||||||
|
public DataItem this[int index] { get { return new DataItem(index); } set { } }
|
||||||
|
public int Count { get { return rowCount; } }
|
||||||
|
public bool IsReadOnly { get { return false; } }
|
||||||
|
public bool IsFixedSize { get { return false; } }
|
||||||
|
public object SyncRoot { get { return true; } }
|
||||||
|
public bool IsSynchronized { get { return true; } }
|
||||||
|
object IList.this[int index] { get { return new DataItem(index); } set { } }
|
||||||
|
|
||||||
|
public DataItemList(int rowCount) {
|
||||||
|
this.rowCount = rowCount;
|
||||||
|
}
|
||||||
|
public IEnumerator<DataItem> GetEnumerator() {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public int Add(object value) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public bool Contains(object value) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public void Clear() {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public int IndexOf(object value) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public void Insert(int index, object value) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public void Remove(object value) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public void RemoveAt(int index) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public void CopyTo(Array array, int index) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
IEnumerator IEnumerable.GetEnumerator() {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public int IndexOf(DataItem item) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public void Insert(int index, DataItem item) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public void Add(DataItem item) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public bool Contains(DataItem item) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public void CopyTo(DataItem[] array, int arrayIndex) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
public bool Remove(DataItem item) {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
void ICollection<DataItem>.CopyTo(DataItem[] array, int arrayIndex) {
|
||||||
|
CopyTo(array, arrayIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Data {
|
||||||
|
class DeterministicRandom {
|
||||||
|
const int randomCount = 10000;
|
||||||
|
static readonly int[] deterministicRandomNumbers;
|
||||||
|
static readonly DateTime time;
|
||||||
|
int rnd;
|
||||||
|
int Next {
|
||||||
|
get {
|
||||||
|
rnd = deterministicRandomNumbers[rnd % randomCount];
|
||||||
|
return rnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public char RandomChar {
|
||||||
|
get {
|
||||||
|
return (char)((int)'A' + Random(0, 26));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public int[] RandomList(int count, int to) {
|
||||||
|
int[] res = new int[count];
|
||||||
|
for(int i = 0; i < Math.Min(count, to); i++)
|
||||||
|
res[i] = i;
|
||||||
|
for(int i = to; i < count; i++)
|
||||||
|
res[i] = Random(to);
|
||||||
|
|
||||||
|
for(int i = 0; i < count; i++) {
|
||||||
|
int ind = Random(count);
|
||||||
|
int temp = res[ind];
|
||||||
|
res[ind] = res[i];
|
||||||
|
res[i] = temp;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
public int Random(int to) {
|
||||||
|
return Random(0, to);
|
||||||
|
}
|
||||||
|
public int Random(int from, int to) {
|
||||||
|
return Next % Math.Max(1, to - from) + from;
|
||||||
|
}
|
||||||
|
public T GetRandomItem<T>(IList<T> list) {
|
||||||
|
return list[Next % list.Count];
|
||||||
|
}
|
||||||
|
public DateTime RandomTime() {
|
||||||
|
return RandomTime(time, 0, 30 * 24);
|
||||||
|
}
|
||||||
|
public DateTime RandomTime(DateTime from, int fromHours, int toHours) {
|
||||||
|
return from.AddHours(Next % (toHours - fromHours) + fromHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
static DeterministicRandom() {
|
||||||
|
time = DateTime.Now.AddDays(-62);
|
||||||
|
Random currentRandom = new Random(randomCount);
|
||||||
|
deterministicRandomNumbers = new int[randomCount];
|
||||||
|
for(int i = 0; i < randomCount; i++)
|
||||||
|
deterministicRandomNumbers[i] = currentRandom.Next(randomCount);
|
||||||
|
}
|
||||||
|
public DeterministicRandom(int i) {
|
||||||
|
this.rnd = i + (i >> 10) + (i >> 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Data {
|
||||||
|
public struct Term {
|
||||||
|
public static readonly Term[] Terms = new Term[] {
|
||||||
|
new Term("Payment seven days after invoice date" ),
|
||||||
|
new Term("Payment ten days after invoice date" ),
|
||||||
|
new Term("End of month" ),
|
||||||
|
new Term("21st of the month following invoice date" ),
|
||||||
|
};
|
||||||
|
readonly string currentName;
|
||||||
|
public string Name { get { return currentName; } }
|
||||||
|
public Term(string currentName) {
|
||||||
|
this.currentName = currentName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||||
|
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||||
|
<WasmBuildNative>true</WasmBuildNative>
|
||||||
|
<InvariantGlobalization>false</InvariantGlobalization>
|
||||||
|
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="DevExpress.Blazor.PdfViewer" Version="25.2.3" />
|
||||||
|
<PackageReference Include="DevExpress.Blazor.Reporting.JSBasedControls" Version="25.2.3" />
|
||||||
|
<PackageReference Include="DevExpress.Blazor.Reporting.Viewer" Version="25.2.3" />
|
||||||
|
<PackageReference Include="DevExpress.Drawing.Skia" Version="25.2.3" />
|
||||||
|
<PackageReference Include="HarfBuzzSharp.NativeAssets.WebAssembly" Version="8.3.1.2" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="8.0.28" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||||
|
<PackageReference Include="SkiaSharp.NativeAssets.WebAssembly" Version="3.119.1" />
|
||||||
|
<PackageReference Include="SkiaSharp.Views.Blazor" Version="3.119.1" />
|
||||||
|
<NativeFileReference Include="$(HarfBuzzSharpStaticLibraryPath)\2.0.23\*.a" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.22" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.11" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Update="PredefinedReports\Report.cs">
|
||||||
|
<SubType>XtraReport</SubType>
|
||||||
|
</Compile>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<nav class="navbar header-navbar p-0">
|
||||||
|
<button class="navbar-toggler bg-primary d-block" @onclick="OnToggleClick">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="ms-3 fw-bold title pe-4">EnvelopeGenerator.ReceiverUI</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool ToggleOn { get; set; }
|
||||||
|
[Parameter] public EventCallback<bool> ToggleOnChanged { get; set; }
|
||||||
|
|
||||||
|
async Task OnToggleClick() => await Toggle();
|
||||||
|
|
||||||
|
async Task Toggle(bool? value = null) {
|
||||||
|
var newValue = value ?? !ToggleOn;
|
||||||
|
if(ToggleOn != newValue) {
|
||||||
|
ToggleOn = newValue;
|
||||||
|
await ToggleOnChanged.InvokeAsync(ToggleOn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
@using EnvelopeGenerator.Server.Client.Services;
|
||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<main>
|
||||||
|
<article class="content">
|
||||||
|
@Body
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
<footer class="receiver-footer">
|
||||||
|
<span>© SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
|
||||||
|
<span class="receiver-footer__sep">|</span>
|
||||||
|
<a href="docs/privacy-policy.de-DE.html" target="_blank" rel="noopener">Datenschutz</a>
|
||||||
|
</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);
|
||||||
|
await base.OnInitializedAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#blazor-error-ui {
|
||||||
|
background: lightyellow;
|
||||||
|
bottom: 0;
|
||||||
|
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
display: none;
|
||||||
|
left: 0;
|
||||||
|
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blazor-error-ui .dismiss {
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
top: 0.5rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<div class="top-row ps-3 navbar navbar-dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="">EnvelopeGenerator.ReceiverUI</a>
|
||||||
|
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
|
||||||
|
<nav class="flex-column">
|
||||||
|
@*
|
||||||
|
<div class="nav-item px-3">
|
||||||
|
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||||
|
<span class="oi oi-home" aria-hidden="true"></span> Home
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item px-3">
|
||||||
|
<NavLink class="nav-link" href="documentviewer">
|
||||||
|
<span class="oi oi-plus" aria-hidden="true"></span> Document Viewer (JS-Based)
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
*@
|
||||||
|
<div class="nav-item px-3">
|
||||||
|
<NavLink class="nav-link" href="receiver">
|
||||||
|
<span class="oi oi-plus" aria-hidden="true"></span> Empfänger-UI
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
<div class="nav-item px-3">
|
||||||
|
<NavLink class="nav-link" href="sender">
|
||||||
|
<span class="oi oi-plus" aria-hidden="true"></span> Umschlag-UI
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool collapseNavMenu = true;
|
||||||
|
|
||||||
|
private string NavMenuCssClass => collapseNavMenu ? "collapse" : null;
|
||||||
|
|
||||||
|
private void ToggleNavMenu()
|
||||||
|
{
|
||||||
|
collapseNavMenu = !collapseNavMenu;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a pre-assigned signature annotation position on a specific page.
|
||||||
|
/// <br/><br/>
|
||||||
|
/// <b>Coordinate unit (X, Y):</b> Inches (GdPicture14 native unit),
|
||||||
|
/// origin at the <b>top-left</b> corner of the page, both axes increase downward/rightward.
|
||||||
|
/// <br/><br/>
|
||||||
|
/// <b>Conversion to DevExpress:</b> Multiply by 100 (DX uses 1/100 inch).
|
||||||
|
/// Convert: <c>xDX = xInches * 100.0</c>
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Conversion to PDF Points:</b> Multiply by 72 (1 inch = 72 points).
|
||||||
|
/// Convert: <c>xPt = xInches * 72.0</c>
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Y-axis for PDF (bottom-left origin):</b> Flip required for iText7.
|
||||||
|
/// Convert: <c>yPt = (pageHeightInches - yInches - elemHeightInches) * 72.0</c>
|
||||||
|
/// </summary>
|
||||||
|
[Obsolete("Use SignatureDto with SignatureService.")]
|
||||||
|
public record AnnotationDto
|
||||||
|
{
|
||||||
|
/// <summary>Unique identifier of the annotation.</summary>
|
||||||
|
public long Id { get; init; }
|
||||||
|
|
||||||
|
/// <summary>1-based page number within the document.</summary>
|
||||||
|
public int Page { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Horizontal position in INCHES from the left edge of the page.</summary>
|
||||||
|
public double X { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Vertical position in INCHES from the top edge of the page.</summary>
|
||||||
|
public double Y { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Models.Constants
|
||||||
|
{
|
||||||
|
public enum SenderAppType
|
||||||
|
{
|
||||||
|
LegacyFormApp = 0,
|
||||||
|
ReceiverUIBlazorApp = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Models.Constants;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the unit of measurement for coordinate values in signature positioning.
|
||||||
|
/// Used for converting coordinates between different systems (GdPicture14, PDF.js, iText7).
|
||||||
|
/// </summary>
|
||||||
|
public enum UnitOfLength
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Inch unit (1 inch = 25.4 mm).
|
||||||
|
/// This is the native unit used by GdPicture14 (EnvelopeGenerator.Form - Legacy VB.NET app).
|
||||||
|
/// Database stores all coordinates (X, Y, Width, Height) in INCHES.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Source:</b> GdPicture14.Annotations.AnnotationStickyNote uses INCHES natively.
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Evidence:</b> VB.NET code directly assigns database values to annotation properties without conversion:
|
||||||
|
/// <code>
|
||||||
|
/// oAnnotation.Left = CSng(pElement.X) ' Direct assignment → INCHES
|
||||||
|
/// oAnnotation.Top = CSng(pElement.Y)
|
||||||
|
/// </code>
|
||||||
|
/// <b>Standard Page Dimensions:</b>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>A4: 8.27" × 11.69" (210mm × 297mm)</item>
|
||||||
|
/// <item>Letter: 8.5" × 11"</item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
Inch = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// PDF Point unit (1 point = 1/72 inch).
|
||||||
|
/// This is the standard unit used by PDF specification and PDF.js viewer.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Definition:</b> According to PDF specification and Microsoft documentation:
|
||||||
|
/// <br/>
|
||||||
|
/// <i>"PDF pages are sized in point units. 1 pt == 1/72 inch"</i>
|
||||||
|
/// <br/><br/>
|
||||||
|
/// <b>Conversion Formula:</b>
|
||||||
|
/// <code>
|
||||||
|
/// points = inches * 72.0
|
||||||
|
/// inches = points / 72.0
|
||||||
|
/// </code>
|
||||||
|
/// <b>Important:</b> Point ≠ Pixel!
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><b>Point (pt):</b> Device-independent unit (always 1/72 inch)</item>
|
||||||
|
/// <item><b>Pixel (px):</b> Device-dependent unit (varies with screen DPI)</item>
|
||||||
|
/// <item>At 72 DPI: 1 point = 1 pixel (coincidence)</item>
|
||||||
|
/// <item>At 96 DPI: 1 point ≈ 1.33 pixels</item>
|
||||||
|
/// <item>At 300 DPI: 1 point ≈ 4.17 pixels</item>
|
||||||
|
/// </list>
|
||||||
|
/// <b>Standard Page Dimensions (in points):</b>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>A4: 595 × 842 points (8.27" × 11.69" × 72)</item>
|
||||||
|
/// <item>Letter: 612 × 792 points (8.5" × 11" × 72)</item>
|
||||||
|
/// </list>
|
||||||
|
/// <b>Usage in EnvelopeGenerator:</b>
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>PDF.js viewer expects coordinates in points</item>
|
||||||
|
/// <item>iText7 library uses points for PDF manipulation</item>
|
||||||
|
/// <item>PSPDFKit (Web) uses points for annotation placement</item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
Point
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side model for the envelope receiver returned by
|
||||||
|
/// <c>GET api/EnvelopeReceiver/{envelopeKey}</c>.
|
||||||
|
/// </summary>
|
||||||
|
public record EnvelopeReceiverDto
|
||||||
|
{
|
||||||
|
public int EnvelopeId { get; init; }
|
||||||
|
public int ReceiverId { get; init; }
|
||||||
|
public int Sequence { get; init; }
|
||||||
|
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public string? JobTitle { get; init; }
|
||||||
|
public string? CompanyName { get; init; }
|
||||||
|
public string? PrivateMessage { get; init; }
|
||||||
|
|
||||||
|
public DateTime AddedWhen { get; init; }
|
||||||
|
public DateTime? ChangedWhen { get; init; }
|
||||||
|
public bool HasPhoneNumber { get; init; }
|
||||||
|
|
||||||
|
public EnvelopeClientDto? Envelope { get; init; }
|
||||||
|
public ReceiverClientDto? Receiver { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side model for the envelope data embedded in <see cref="EnvelopeReceiverDto"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record EnvelopeClientDto
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int UserId { get; init; }
|
||||||
|
public int Status { get; init; }
|
||||||
|
public string StatusName { get; init; } = string.Empty;
|
||||||
|
public string Uuid { get; init; } = string.Empty;
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
public string Message { get; init; } = string.Empty;
|
||||||
|
public DateTime AddedWhen { get; init; }
|
||||||
|
public DateTime? ChangedWhen { get; init; }
|
||||||
|
public string Language { get; init; } = "de-DE";
|
||||||
|
public int? EnvelopeTypeId { get; init; }
|
||||||
|
public string? EnvelopeTypeTitle { get; init; }
|
||||||
|
public int? ContractType { get; init; }
|
||||||
|
public int? CertificationType { get; init; }
|
||||||
|
public bool UseAccessCode { get; init; }
|
||||||
|
public bool TFAEnabled { get; init; }
|
||||||
|
public IEnumerable<DocumentClientDto>? Documents { get; init; }
|
||||||
|
public EnvelopeSenderDto? User { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sender (user) information embedded in <see cref="EnvelopeClientDto"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record EnvelopeSenderDto
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public string? Username { get; init; }
|
||||||
|
public string? FullName { get; init; }
|
||||||
|
public string? Email { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side model for a document embedded in <see cref="EnvelopeClientDto"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record DocumentClientDto
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int EnvelopeId { get; init; }
|
||||||
|
public DateTime AddedWhen { get; init; }
|
||||||
|
public IEnumerable<SignatureClientDto>? Elements { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side model for a signature/annotation element embedded in <see cref="DocumentClientDto"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record SignatureClientDto
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public int DocumentId { get; init; }
|
||||||
|
public int ReceiverId { get; init; }
|
||||||
|
public int ElementType { get; init; }
|
||||||
|
public double X { get; init; }
|
||||||
|
public double Y { get; init; }
|
||||||
|
public double Width { get; init; }
|
||||||
|
public double Height { get; init; }
|
||||||
|
public int Page { get; init; }
|
||||||
|
public bool Required { get; init; }
|
||||||
|
public string? Tooltip { get; init; }
|
||||||
|
public bool ReadOnly { get; init; }
|
||||||
|
public int AnnotationIndex { get; init; }
|
||||||
|
public DateTime AddedWhen { get; init; }
|
||||||
|
public DateTime? ChangedWhen { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client-side model for the receiver data embedded in <see cref="EnvelopeReceiverDto"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record ReceiverClientDto
|
||||||
|
{
|
||||||
|
public int Id { get; init; }
|
||||||
|
public string? EmailAddress { get; init; }
|
||||||
|
public string? Signature { get; init; }
|
||||||
|
public DateTime AddedWhen { get; init; }
|
||||||
|
public DateTime? TfaRegDeadline { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a captured signature with metadata created by the receiver in the signature popup.
|
||||||
|
/// This model holds the signature image (as base64 data URL) along with signer information
|
||||||
|
/// used for rendering applied signatures on the PDF canvas.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Used in:</b> EnvelopeViewer.razor signature popup workflow
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Creation:</b> User draws/types/uploads signature and fills required fields
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Storage:</b> Session-only (Blazor component state, lost on page refresh)
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Rendering:</b> Applied signatures display: Image + Separator + Name/Position/Place/Date
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record SignatureCaptureDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Base64-encoded data URL of the signature image.
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Format:</b> <c>data:image/png;base64,iVBORw0KG...</c>
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Source:</b> Canvas.toDataURL() from signature pad (draw/text/image tabs)
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Usage:</b> Set as <c>img.src</c> in applied signature overlay
|
||||||
|
/// </summary>
|
||||||
|
public required string DataUrl { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full name of the signer (first and last name).
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Required:</b> Yes (validated in popup)
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Display:</b> Bold text in applied signature block
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Example:</b> "Max Mustermann"
|
||||||
|
/// </summary>
|
||||||
|
public required string FullName { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Job title or position of the signer.
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Required:</b> No (optional field)
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Display:</b> Normal weight text between name and place/date
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Example:</b> "Geschäftsführer" or empty string
|
||||||
|
/// </summary>
|
||||||
|
public string Position { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Location/place where the signature was created.
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Required:</b> Yes (validated in popup)
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Display:</b> Shown with current date in German format (dd.MM.yyyy)
|
||||||
|
/// <br/>
|
||||||
|
/// <b>Example:</b> "Berlin" ? rendered as "Berlin, 26.01.2025"
|
||||||
|
/// </summary>
|
||||||
|
public required string Place { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using EnvelopeGenerator.Server.Client.Models.Constants;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a signature position on a PDF page.
|
||||||
|
/// Coordinates stored in INCHES (GdPicture14 native unit).
|
||||||
|
/// Origin: Top-left corner, X increases right, Y increases down.
|
||||||
|
/// </summary>
|
||||||
|
public class SignatureDto
|
||||||
|
{
|
||||||
|
/// <summary>Unique identifier.</summary>
|
||||||
|
public int Id { get; init; }
|
||||||
|
|
||||||
|
private double _x;
|
||||||
|
private double _y;
|
||||||
|
|
||||||
|
/// <summary>Horizontal position in INCHES from left edge.</summary>
|
||||||
|
public double X
|
||||||
|
{
|
||||||
|
get => _x * Factor;
|
||||||
|
init => _x = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Vertical position in INCHES from top edge.</summary>
|
||||||
|
public double Y
|
||||||
|
{
|
||||||
|
get => _y * Factor;
|
||||||
|
init => _y = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>1-based page number.</summary>
|
||||||
|
public int Page { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Sender application type that created this signature.</summary>
|
||||||
|
public SenderAppType SenderAppType { get; init; }
|
||||||
|
|
||||||
|
private UnitOfLength _unitOfLength;
|
||||||
|
|
||||||
|
public SignatureDto Convert(UnitOfLength unitOfLength)
|
||||||
|
{
|
||||||
|
_unitOfLength = unitOfLength;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double Factor
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (SenderAppType != SenderAppType.LegacyFormApp)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException(
|
||||||
|
$"SenderAppType '{SenderAppType}' is not yet implemented. " +
|
||||||
|
$"Currently, only '{nameof(SenderAppType.LegacyFormApp)}' is supported. " +
|
||||||
|
$"Future implementations will handle '{nameof(SenderAppType.ReceiverUIBlazorApp)}' and other types.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// LegacyFormApp uses GdPicture14 with INCHES
|
||||||
|
return _unitOfLength switch
|
||||||
|
{
|
||||||
|
UnitOfLength.Inch => 1.0, // No conversion needed: INCHES → INCHES
|
||||||
|
UnitOfLength.Point => 72.0, // INCHES → PDF Points: 1 inch = 72 points (PDF standard, NOT pixels!)
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
$"Unknown UnitOfLength: {_unitOfLength}. Expected '{nameof(UnitOfLength.Inch)}' or '{nameof(UnitOfLength.Point)}'.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SignatureDtoExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Converts all signatures in the collection to the specified unit of length.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">Type of the collection (IEnumerable, List, etc.)</typeparam>
|
||||||
|
/// <param name="signatures">Collection of SignatureDto objects to convert.</param>
|
||||||
|
/// <param name="unitOfLength">Target unit of measurement (Inch or Point).</param>
|
||||||
|
/// <returns>The same collection with all signatures converted to the specified unit.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown when signatures collection is null.</exception>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Usage:</b>
|
||||||
|
/// <code>
|
||||||
|
/// var signatures = await SignatureService.GetAsync(envelopeKey);
|
||||||
|
/// var convertedSignatures = signatures.ConvertAll(UnitOfLength.Point);
|
||||||
|
/// </code>
|
||||||
|
/// <b>Note:</b> This method modifies each SignatureDto object in place and returns the same collection.
|
||||||
|
/// </remarks>
|
||||||
|
public static T Convert<T>(this T signatures, UnitOfLength unitOfLength)
|
||||||
|
where T : IEnumerable<SignatureDto>
|
||||||
|
{
|
||||||
|
if (signatures == null)
|
||||||
|
throw new ArgumentNullException(nameof(signatures));
|
||||||
|
|
||||||
|
foreach (var signature in signatures)
|
||||||
|
{
|
||||||
|
signature.Convert(unitOfLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
return signatures;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Options;
|
||||||
|
|
||||||
|
public class ApiOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Api";
|
||||||
|
|
||||||
|
public bool UsePredefinedReports { get; set; } = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Options;
|
||||||
|
|
||||||
|
public class PdfViewerOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "PdfViewer";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base scale for thumbnail rendering (0.2 - 1.5 recommended)
|
||||||
|
/// Higher values = better quality but slower rendering
|
||||||
|
/// Default: 0.75
|
||||||
|
/// </summary>
|
||||||
|
public double ThumbnailBaseScale { get; set; } = 0.75;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enable HiDPI/Retina support for thumbnails
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool ThumbnailEnableHiDPI { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum device pixel ratio multiplier for thumbnails (1.0 - 3.0)
|
||||||
|
/// Caps DPR to avoid excessive memory usage on 4K+ displays
|
||||||
|
/// Default: 2.0
|
||||||
|
/// </summary>
|
||||||
|
public double ThumbnailMaxDPR { get; set; } = 2.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enable HiDPI/Retina support for main PDF canvas
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool MainCanvasEnableHiDPI { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum device pixel ratio multiplier for main canvas (1.0 - 3.0)
|
||||||
|
/// Default: 2.0
|
||||||
|
/// </summary>
|
||||||
|
public double MainCanvasMaxDPR { get; set; } = 2.0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enable smooth zoom transition (fade effect)
|
||||||
|
/// Default: true
|
||||||
|
/// </summary>
|
||||||
|
public bool EnableSmoothZoom { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zoom transition duration in milliseconds (50 - 500)
|
||||||
|
/// Default: 150
|
||||||
|
/// </summary>
|
||||||
|
public int ZoomTransitionDuration { get; set; } = 150;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opacity during rendering (0.0 - 1.0)
|
||||||
|
/// Lower values = more visible fade effect
|
||||||
|
/// Default: 0.85
|
||||||
|
/// </summary>
|
||||||
|
public double RenderingOpacity { get; set; } = 0.85;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Delay between thumbnail renders in milliseconds (10 - 200)
|
||||||
|
/// Higher values = less browser stress, slower initial load
|
||||||
|
/// Default: 50
|
||||||
|
/// </summary>
|
||||||
|
public int ThumbnailRenderDelay { get; set; } = 50;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Zoom step percentage (1 - 50)
|
||||||
|
/// Controls how much zoom changes per click or scroll
|
||||||
|
/// Default: 5 (5% per step)
|
||||||
|
/// </summary>
|
||||||
|
public int ZoomStepPercentage { get; set; } = 5;
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
@page "/sender"
|
||||||
|
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))
|
||||||
|
@using System.Text.Json
|
||||||
|
@using EnvelopeGenerator.Domain.Constants
|
||||||
|
@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 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" />
|
||||||
|
|
||||||
|
<div class="sender-dashboard-layout">
|
||||||
|
<div class="sender-action-bar">
|
||||||
|
<div class="sender-action-bar__inner">
|
||||||
|
<div class="sender-title-section">
|
||||||
|
<div class="sender-logo">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="sender-title">Umschlag-Übersicht</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sender-toolbar">
|
||||||
|
<button class="sender-btn sender-btn--primary" @onclick="CreateEnvelope" title="Neuen Umschlag erstellen">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
|
||||||
|
</svg>
|
||||||
|
Neuer Umschlag
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="sender-btn" @onclick="EditEnvelope" disabled="@(_selectedEnvelope == null || IsEnvelopeSent(_selectedEnvelope))" title="Ausgewählten Umschlag bearbeiten">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
|
||||||
|
</svg>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="sender-btn sender-btn--danger" @onclick="DeleteEnvelope" disabled="@(_selectedEnvelope == null)" title="Ausgewählten Umschlag löschen">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||||||
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||||||
|
</svg>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="sender-btn" @onclick="RefreshEnvelopes" disabled="@_isLoading" title="Aktualisieren">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
|
||||||
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
|
||||||
|
</svg>
|
||||||
|
@if (_isLoading) {
|
||||||
|
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="sender-btn sender-btn--logout" @onclick="LogoutAsync" disabled="@_isLoggingOut" title="Abmelden">
|
||||||
|
@if (_isLoggingOut) {
|
||||||
|
<span class="spinner-border spinner-border-sm" style="width: 14px; height: 14px;"></span>
|
||||||
|
} else {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z"/>
|
||||||
|
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z"/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sender-content">
|
||||||
|
@if (_isLoading && _allEnvelopes == null) {
|
||||||
|
<div class="d-flex justify-content-center align-items-center h-100">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="spinner-border text-white mb-3" style="width: 3.5rem; height: 3.5rem;" role="status">
|
||||||
|
<span class="visually-hidden">Lädt...</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-white fw-semibold">Umschläge werden geladen...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else if (_errorMessage != null) {
|
||||||
|
<div class="error-container">
|
||||||
|
<div class="alert alert-danger shadow-lg">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="me-3 flex-shrink-0" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||||
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-2">Fehler beim Laden der Umschläge</h5>
|
||||||
|
<p class="mb-0">@_errorMessage</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<div class="sender-grid-container">
|
||||||
|
<div class="sender-tabs">
|
||||||
|
<button class="sender-tab @(_activeTab == "active" ? "sender-tab--active" : "")" @onclick='() => _activeTab = "active"'>
|
||||||
|
<span>Aktive Umschläge</span>
|
||||||
|
@if (_activeEnvelopes != null) {
|
||||||
|
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_activeEnvelopes.Count())</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button class="sender-tab @(_activeTab == "completed" ? "sender-tab--active" : "")" @onclick='() => _activeTab = "completed"'>
|
||||||
|
<span>Abgeschlossene Umschläge</span>
|
||||||
|
@if (_completedEnvelopes != null) {
|
||||||
|
<span style="opacity: 0.6; margin-left: 0.5rem;">(@_completedEnvelopes.Count())</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sender-grid-wrapper">
|
||||||
|
@if (_activeTab == "active") {
|
||||||
|
<DxGrid Data="@_activeEnvelopes"
|
||||||
|
@ref="_gridActive"
|
||||||
|
ShowFilterRow="true"
|
||||||
|
ShowSearchBox="true"
|
||||||
|
AllowColumnReorder="true"
|
||||||
|
AllowSort=true
|
||||||
|
ColumnResizeMode="GridColumnResizeMode.ColumnsContainer"
|
||||||
|
PageSize="20"
|
||||||
|
PagerVisible="true"
|
||||||
|
SelectionMode="GridSelectionMode.Single"
|
||||||
|
SelectedDataItem="@_selectedEnvelope"
|
||||||
|
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
|
||||||
|
CustomizeElement="OnCustomizeElement">
|
||||||
|
<Columns>
|
||||||
|
<DxGridDataColumn FieldName="Id" Caption="ID">
|
||||||
|
<CellDisplayTemplate Context="cellContext">
|
||||||
|
@((cellContext.DataItem as EnvelopeDto)?.Id)
|
||||||
|
</CellDisplayTemplate>
|
||||||
|
</DxGridDataColumn>
|
||||||
|
<DxGridDataColumn FieldName="Title" Caption="Titel">
|
||||||
|
<CellDisplayTemplate Context="cellContext">
|
||||||
|
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
|
||||||
|
</CellDisplayTemplate>
|
||||||
|
</DxGridDataColumn>
|
||||||
|
<DxGridDataColumn FieldName="Status" Caption="Status">
|
||||||
|
<CellDisplayTemplate Context="cellContext">
|
||||||
|
@{
|
||||||
|
var envelope = cellContext.DataItem as EnvelopeDto;
|
||||||
|
if (envelope != null) {
|
||||||
|
var statusInfo = GetStatusInfo(envelope.Status);
|
||||||
|
<div class="status-badge status-badge--@statusInfo.CssClass">
|
||||||
|
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
|
||||||
|
@statusInfo.Label
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</CellDisplayTemplate>
|
||||||
|
</DxGridDataColumn>
|
||||||
|
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger">
|
||||||
|
<CellDisplayTemplate Context="cellContext">
|
||||||
|
@{
|
||||||
|
var envelope = cellContext.DataItem as EnvelopeDto;
|
||||||
|
if (envelope != null) {
|
||||||
|
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
|
||||||
|
var signed = receivers.Count(r => r.Signed);
|
||||||
|
var total = receivers.Count;
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 0.875rem; color: #6b7280;">
|
||||||
|
@signed / @total unterschrieben
|
||||||
|
</span>
|
||||||
|
@if (total > 0) {
|
||||||
|
<div style="flex: 1; min-width: 60px; max-width: 120px; height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden;">
|
||||||
|
<div style="height: 100%; background: linear-gradient(90deg, #81c784 0%, #66bb6a 100%); width: @((signed * 100.0 / total).ToString("F0"))%;"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</CellDisplayTemplate>
|
||||||
|
</DxGridDataColumn>
|
||||||
|
</Columns>
|
||||||
|
<DetailRowTemplate Context="detailContext">
|
||||||
|
<div style="padding: 1rem; background: #f9fafb;">
|
||||||
|
<h6 style="font-weight: 600; color: #374151; margin-bottom: 0.75rem;">Empfänger</h6>
|
||||||
|
@{
|
||||||
|
var envelope = detailContext.DataItem as EnvelopeDto;
|
||||||
|
if (envelope?.EnvelopeReceivers?.Any() == true) {
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
|
@foreach (var receiver in envelope.EnvelopeReceivers) {
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
|
||||||
|
<span class="receiver-badge receiver-badge--@(receiver.Signed ? "signed" : "unsigned")" style="min-width: 100px;">
|
||||||
|
@if (receiver.Signed) {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Unterschrieben</span>
|
||||||
|
} else {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||||
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Ausstehend</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<div style="flex: 1; font-size: 0.875rem;">
|
||||||
|
<strong style="color: #1f2937;">@receiver.Name</strong>
|
||||||
|
<span style="color: #6b7280; margin-left: 0.5rem;">@receiver.Receiver?.EmailAddress</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">Keine Empfänger</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</DetailRowTemplate>
|
||||||
|
</DxGrid>
|
||||||
|
} else {
|
||||||
|
<DxGrid Data="@_completedEnvelopes"
|
||||||
|
@ref="_gridCompleted"
|
||||||
|
ShowFilterRow="true"
|
||||||
|
ShowSearchBox="true"
|
||||||
|
PageSize="20"
|
||||||
|
PagerVisible="true"
|
||||||
|
SelectionMode="GridSelectionMode.Single"
|
||||||
|
SelectedDataItem="@_selectedEnvelope"
|
||||||
|
SelectedDataItemChanged="@OnSelectedEnvelopeChanged"
|
||||||
|
CustomizeElement="OnCustomizeElement">
|
||||||
|
<Columns>
|
||||||
|
<DxGridDataColumn FieldName="Id" Caption="ID">
|
||||||
|
<CellDisplayTemplate Context="cellContext">
|
||||||
|
@((cellContext.DataItem as EnvelopeDto)?.Id)
|
||||||
|
</CellDisplayTemplate>
|
||||||
|
</DxGridDataColumn>
|
||||||
|
<DxGridDataColumn FieldName="Title" Caption="Titel">
|
||||||
|
<CellDisplayTemplate Context="cellContext">
|
||||||
|
<strong>@((cellContext.DataItem as EnvelopeDto)?.Title)</strong>
|
||||||
|
</CellDisplayTemplate>
|
||||||
|
</DxGridDataColumn>
|
||||||
|
<DxGridDataColumn FieldName="Status" Caption="Status">
|
||||||
|
<CellDisplayTemplate Context="cellContext">
|
||||||
|
@{
|
||||||
|
var envelope = cellContext.DataItem as EnvelopeDto;
|
||||||
|
if (envelope != null) {
|
||||||
|
var statusInfo = GetStatusInfo(envelope.Status);
|
||||||
|
<div class="status-badge status-badge--@statusInfo.CssClass">
|
||||||
|
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
|
||||||
|
@statusInfo.Label
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</CellDisplayTemplate>
|
||||||
|
</DxGridDataColumn>
|
||||||
|
<DxGridDataColumn FieldName="EnvelopeReceivers" Caption="Empfänger">
|
||||||
|
<CellDisplayTemplate Context="cellContext">
|
||||||
|
@{
|
||||||
|
var envelope = cellContext.DataItem as EnvelopeDto;
|
||||||
|
if (envelope != null) {
|
||||||
|
var receivers = envelope.EnvelopeReceivers?.ToList() ?? [];
|
||||||
|
var signed = receivers.Count(r => r.Signed);
|
||||||
|
var total = receivers.Count;
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<span style="font-size: 0.875rem; color: #6b7280;">
|
||||||
|
@signed / @total unterschrieben
|
||||||
|
</span>
|
||||||
|
@if (total > 0) {
|
||||||
|
<div style="flex: 1; min-width: 60px; max-width: 120px; height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden;">
|
||||||
|
<div style="height: 100%; background: linear-gradient(90deg, #81c784 0%, #66bb6a 100%); width: @((signed * 100.0 / total).ToString("F0"))%;"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</CellDisplayTemplate>
|
||||||
|
</DxGridDataColumn>
|
||||||
|
</Columns>
|
||||||
|
<DetailRowTemplate Context="detailContext">
|
||||||
|
<div style="padding: 1rem; background: #f9fafb;">
|
||||||
|
<h6 style="font-weight: 600; color: #374151; margin-bottom: 0.75rem;">Empfänger</h6>
|
||||||
|
@{
|
||||||
|
var envelope = detailContext.DataItem as EnvelopeDto;
|
||||||
|
if (envelope?.EnvelopeReceivers?.Any() == true) {
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
|
@foreach (var receiver in envelope.EnvelopeReceivers) {
|
||||||
|
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
|
||||||
|
<span class="receiver-badge receiver-badge--@(receiver.Signed ? "signed" : "unsigned")" style="min-width: 100px;">
|
||||||
|
@if (receiver.Signed) {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Unterschrieben</span>
|
||||||
|
} else {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||||
|
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Ausstehend</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<div style="flex: 1; font-size: 0.875rem;">
|
||||||
|
<strong style="color: #1f2937;">@receiver.Name</strong>
|
||||||
|
<span style="color: #6b7280; margin-left: 0.5rem;">@receiver.Receiver?.EmailAddress</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<p style="color: #9ca3af; font-size: 0.875rem; margin: 0;">Keine Empfänger</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</DetailRowTemplate>
|
||||||
|
</DxGrid>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private IEnumerable<EnvelopeDto>? _allEnvelopes;
|
||||||
|
private IEnumerable<EnvelopeDto>? _activeEnvelopes;
|
||||||
|
private IEnumerable<EnvelopeDto>? _completedEnvelopes;
|
||||||
|
private EnvelopeDto? _selectedEnvelope;
|
||||||
|
private string _activeTab = "active";
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private bool _isLoggingOut = false;
|
||||||
|
private string? _errorMessage;
|
||||||
|
private DxGrid? _gridActive;
|
||||||
|
private DxGrid? _gridCompleted;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
var hasAccess = await AuthService.CheckSenderAccessAsync();
|
||||||
|
if (!hasAccess)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo($"/sender/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await LoadEnvelopesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task LoadEnvelopesAsync()
|
||||||
|
{
|
||||||
|
_isLoading = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
|
||||||
|
|
||||||
|
// Split into active and completed based on status
|
||||||
|
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
|
||||||
|
{
|
||||||
|
_isLoading = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RefreshEnvelopes()
|
||||||
|
{
|
||||||
|
await LoadEnvelopesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CreateEnvelope()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo("/sender/editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
void EditEnvelope()
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
await AuthService.LogoutSenderAsync();
|
||||||
|
Navigation.NavigateTo("/sender/login", forceLoad: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsEnvelopeSent(EnvelopeDto envelope)
|
||||||
|
{
|
||||||
|
var status = (EnvelopeStatus)envelope.Status;
|
||||||
|
return status >= EnvelopeStatus.EnvelopeQueued;
|
||||||
|
}
|
||||||
|
|
||||||
|
(string Label, string CssClass, string DotColor) GetStatusInfo(EnvelopeStatus status)
|
||||||
|
{
|
||||||
|
return status switch
|
||||||
|
{
|
||||||
|
EnvelopeStatus.EnvelopePartlySigned => ("Teilweise unterschrieben", "partly-signed", "green"),
|
||||||
|
EnvelopeStatus.EnvelopeQueued => ("In Warteschlange", "queued", "orange"),
|
||||||
|
EnvelopeStatus.EnvelopeSent => ("Gesendet", "sent", "orange"),
|
||||||
|
EnvelopeStatus.EnvelopeCompletelySigned => ("Vollständig unterschrieben", "completed", "green"),
|
||||||
|
EnvelopeStatus.EnvelopeDeleted => ("Gelöscht", "deleted", "red"),
|
||||||
|
EnvelopeStatus.EnvelopeRejected => ("Abgelehnt", "rejected", "red"),
|
||||||
|
EnvelopeStatus.EnvelopeWithdrawn => ("Zurückgezogen", "withdrawn", "red"),
|
||||||
|
EnvelopeStatus.EnvelopeCreated => ("Erstellt", "created", "blue"),
|
||||||
|
EnvelopeStatus.EnvelopeSaved => ("Gespeichert", "saved", "blue"),
|
||||||
|
_ => ("Unbekannt", "unknown", "blue")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnCustomizeElement(GridCustomizeElementEventArgs e)
|
||||||
|
{
|
||||||
|
// Future: Add custom row coloring based on status if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnSelectedEnvelopeChanged(object envelope)
|
||||||
|
{
|
||||||
|
_selectedEnvelope = envelope as EnvelopeDto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
@page "/"
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
@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">
|
||||||
|
<div class="home-hero-header__inner">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="home-hero-header__icon" viewBox="0 0 16 16">
|
||||||
|
<path d="M0 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V4Zm2-1a1 1 0 0 0-1 1v.217l7 4.2 7-4.2V4a1 1 0 0 0-1-1H2Zm13 2.383-4.708 2.825L15 11.105V5.383Zm-.034 6.876-5.64-3.471L8 9.583l-1.326-.795-5.64 3.47A1 1 0 0 0 2 13h12a1 1 0 0 0 .966-.741ZM1 11.105l4.708-2.897L1 5.383v5.722Z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h1 class="home-hero-header__title">SignFlow</h1>
|
||||||
|
<p class="home-hero-header__subtitle">Willkommen im eSign-Portal</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-content">
|
||||||
|
<div class="home-card card shadow border-0">
|
||||||
|
<div class="card-body p-4 p-md-5">
|
||||||
|
|
||||||
|
<p class="text-muted mb-4" style="font-size: 0.92rem; line-height: 1.7; text-align: justify; text-align-last: left; min-height: calc(0.92rem * 1.7 * 9);">
|
||||||
|
<span id="home-description"></span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 pt-3 border-top">
|
||||||
|
<div class="d-flex flex-wrap justify-content-center gap-3">
|
||||||
|
<div class="home-feature-badge">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" 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>
|
||||||
|
Sicherer Zugang
|
||||||
|
</div>
|
||||||
|
<div class="home-feature-badge">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
|
||||||
|
</svg>
|
||||||
|
Digitale Unterschrift
|
||||||
|
</div>
|
||||||
|
<div class="home-feature-badge">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z"/>
|
||||||
|
<path d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3z"/>
|
||||||
|
</svg>
|
||||||
|
PDF-Export
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private const string HomePageDescription =
|
||||||
|
"Das digitale Unterschriftenportal ist eine Plattform, die entwickelt wurde, um Ihre Dokumente sicher zu unterschreiben und zu verwalten. " +
|
||||||
|
"Mit seiner benutzerfreundlichen Oberfläche können Sie Ihre Dokumente schnell hochladen, die Unterschriftsprozesse verfolgen und Ihre digitalen Unterschriftenanwendungen einfach durchführen. " +
|
||||||
|
"Dieses Portal beschleunigt Ihren Arbeitsablauf mit rechtlich gültigen Unterschriften und erhöht gleichzeitig die Sicherheit Ihrer Dokumente.";
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (firstRender)
|
||||||
|
{
|
||||||
|
await JS.InvokeVoidAsync("receiverSignature.startTyped", "home-description", HomePageDescription, 15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
@page "/envelope/login/{EnvelopeKey}"
|
||||||
|
@rendermode InteractiveWebAssembly
|
||||||
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
|
@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%;">
|
||||||
|
|
||||||
|
<div class="card-header text-white text-center py-4 border-0" style="background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%); border-radius: calc(0.375rem - 1px) calc(0.375rem - 1px) 0 0;">
|
||||||
|
<div class="mb-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="currentColor" 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>
|
||||||
|
</div>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (LoginResult == EnvelopeLoginResult.NotFound)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning d-flex align-items-start gap-2 py-2" role="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (LoginResult == EnvelopeLoginResult.InvalidCode)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger d-flex align-items-start gap-2 py-2" role="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||||
|
<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 />
|
||||||
|
<span style="font-size:0.85rem;">Der eingegebene Code ist falsch. Bitte versuchen Sie es erneut.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (LoginResult == EnvelopeLoginResult.Error)
|
||||||
|
{
|
||||||
|
<div class="alert alert-secondary d-flex align-items-start gap-2 py-2" role="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||||
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-medium" for="login-access-code">
|
||||||
|
Zugangscode
|
||||||
|
<span class="text-danger ms-1">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-light border-end-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#6c757d" viewBox="0 0 16 16">
|
||||||
|
<path d="M3.5 11.5a3.5 3.5 0 1 1 3.163-5H14L15.5 8 14 9.5l-1-1-1 1-1-1-1 1-1-1-1.837 1.337A3.5 3.5 0 0 1 3.5 11.5zm0-1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input id="login-access-code"
|
||||||
|
type="@(ShowCode ? "text" : "password")"
|
||||||
|
class="form-control border-start-0 border-end-0 @(LoginResult == EnvelopeLoginResult.InvalidCode ? "is-invalid" : null)"
|
||||||
|
placeholder="Zugangscode eingeben"
|
||||||
|
@bind="AccessCode"
|
||||||
|
@bind:event="oninput"
|
||||||
|
@onkeydown="OnKeyDownAsync"
|
||||||
|
disabled="@IsLoading"
|
||||||
|
autocomplete="one-time-code" />
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-secondary border-start-0"
|
||||||
|
style="border-left: none;"
|
||||||
|
tabindex="-1"
|
||||||
|
@onclick="() => ShowCode = !ShowCode">
|
||||||
|
@if (ShowCode)
|
||||||
|
{
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z" />
|
||||||
|
<path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z" />
|
||||||
|
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709z" />
|
||||||
|
<path fill-rule="evenodd" d="M13.646 14.354l-12-12 .708-.708 12 12-.708.708z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z" />
|
||||||
|
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary w-100 py-2 fw-medium"
|
||||||
|
style="background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%); border: none;"
|
||||||
|
@onclick="SubmitAsync"
|
||||||
|
disabled="@(IsLoading || string.IsNullOrWhiteSpace(AccessCode))">
|
||||||
|
@if (IsLoading)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></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>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer text-center text-muted py-3 border-0 bg-transparent" style="font-size: 0.78rem;">
|
||||||
|
Bei Problemen wenden Sie sich bitte an den Absender des Dokuments.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string EnvelopeKey { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
string AccessCode = string.Empty;
|
||||||
|
bool ShowCode;
|
||||||
|
bool IsLoading;
|
||||||
|
EnvelopeLoginResult? LoginResult;
|
||||||
|
|
||||||
|
async Task OnKeyDownAsync(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == "Enter")
|
||||||
|
await SubmitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task SubmitAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(AccessCode) || IsLoading) return;
|
||||||
|
|
||||||
|
IsLoading = true;
|
||||||
|
LoginResult = null;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
var result = await AuthService.LoginEnvelopeReceiverAsync(EnvelopeKey, AccessCode.Trim());
|
||||||
|
|
||||||
|
if (result == EnvelopeLoginResult.Success)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/report", forceLoad: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginResult = result;
|
||||||
|
IsLoading = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
@page "/sender/login"
|
||||||
|
@rendermode InteractiveWebAssembly
|
||||||
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
|
@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%;">
|
||||||
|
|
||||||
|
<div class="card-header text-white text-center py-4 border-0" style="background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%); border-radius: calc(0.375rem - 1px) calc(0.375rem - 1px) 0 0;">
|
||||||
|
<div class="mb-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||||
|
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h5 class="mb-0 fw-semibold">Sender Anmeldung</h5>
|
||||||
|
<p class="mb-0 mt-1 opacity-75" style="font-size: 0.85rem;">Sicherer Zugang zum Sender-Dashboard</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-4">
|
||||||
|
|
||||||
|
<p class="text-muted mb-4" style="font-size: 0.875rem; line-height: 1.5;">
|
||||||
|
Bitte melden Sie sich mit Ihren Zugangsdaten an, um auf das Sender-Dashboard zuzugreifen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@if (LoginResult == SenderLoginResult.InvalidCredentials) {
|
||||||
|
<div class="alert alert-danger d-flex align-items-start gap-2 py-2" role="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||||
|
<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 />
|
||||||
|
<span style="font-size:0.85rem;">Benutzername oder Passwort ist falsch. Bitte versuchen Sie es erneut.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else if (LoginResult == SenderLoginResult.Error) {
|
||||||
|
<div class="alert alert-secondary d-flex align-items-start gap-2 py-2" role="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="flex-shrink-0 mt-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||||
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z"/>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-medium" for="login-username">
|
||||||
|
Benutzername
|
||||||
|
<span class="text-danger ms-1">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-light">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#6c757d" viewBox="0 0 16 16">
|
||||||
|
<path d="M3 14s-1 0-1-1 1-4 6-4 6 3 6 4-1 1-1 1H3Zm5-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input id="login-username"
|
||||||
|
type="text"
|
||||||
|
class="form-control @(LoginResult == SenderLoginResult.InvalidCredentials ? "is-invalid" : null)"
|
||||||
|
placeholder="Benutzername eingeben"
|
||||||
|
@bind="Username"
|
||||||
|
@bind:event="oninput"
|
||||||
|
@onkeydown="OnKeyDownAsync"
|
||||||
|
disabled="@IsLoading"
|
||||||
|
autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label fw-medium" for="login-password">
|
||||||
|
Passwort
|
||||||
|
<span class="text-danger ms-1">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-light border-end-0">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#6c757d" 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>
|
||||||
|
<input id="login-password"
|
||||||
|
type="@(ShowPassword ? "text" : "password")"
|
||||||
|
class="form-control border-start-0 border-end-0 @(LoginResult == SenderLoginResult.InvalidCredentials ? "is-invalid" : null)"
|
||||||
|
placeholder="Passwort eingeben"
|
||||||
|
@bind="Password"
|
||||||
|
@bind:event="oninput"
|
||||||
|
@onkeydown="OnKeyDownAsync"
|
||||||
|
disabled="@IsLoading"
|
||||||
|
autocomplete="current-password" />
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-outline-secondary border-start-0"
|
||||||
|
style="border-left: none;"
|
||||||
|
tabindex="-1"
|
||||||
|
@onclick="() => ShowPassword = !ShowPassword">
|
||||||
|
@if (ShowPassword) {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/>
|
||||||
|
<path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/>
|
||||||
|
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709z"/>
|
||||||
|
<path fill-rule="evenodd" d="M13.646 14.354l-12-12 .708-.708 12 12-.708.708z"/>
|
||||||
|
</svg>
|
||||||
|
} else {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
|
||||||
|
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary w-100 py-2 fw-medium"
|
||||||
|
style="background: linear-gradient(135deg, #2c3e50 0%, #3498db 100%); border: none;"
|
||||||
|
@onclick="SubmitAsync"
|
||||||
|
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>
|
||||||
|
} 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"/>
|
||||||
|
<path fill-rule="evenodd" d="M4.146 8.354a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L5.707 7.5H14.5a.5.5 0 0 1 0 1H5.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Anmelden</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer text-center text-muted py-3 border-0 bg-transparent" style="font-size: 0.78rem;">
|
||||||
|
Bei Problemen wenden Sie sich bitte an den Administrator.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
string Username = string.Empty;
|
||||||
|
string Password = string.Empty;
|
||||||
|
bool ShowPassword;
|
||||||
|
bool IsLoading;
|
||||||
|
SenderLoginResult? LoginResult;
|
||||||
|
|
||||||
|
async Task OnKeyDownAsync(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e) {
|
||||||
|
if (e.Key == "Enter")
|
||||||
|
await SubmitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task SubmitAsync() {
|
||||||
|
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password) || IsLoading) return;
|
||||||
|
|
||||||
|
IsLoading = true;
|
||||||
|
LoginResult = null;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
var result = await AuthService.LoginSenderAsync(Username.Trim(), Password.Trim());
|
||||||
|
|
||||||
|
if (result == SenderLoginResult.Success) {
|
||||||
|
Navigation.NavigateTo("/sender", forceLoad: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginResult = result;
|
||||||
|
IsLoading = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<root>
|
||||||
|
<!--
|
||||||
|
Microsoft ResX Schema
|
||||||
|
|
||||||
|
Version 2.0
|
||||||
|
|
||||||
|
The primary goals of this format is to allow a simple XML format
|
||||||
|
that is mostly human readable. The generation and parsing of the
|
||||||
|
various data types are done through the TypeConverter classes
|
||||||
|
associated with the data types.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
... ado.net/XML headers & schema ...
|
||||||
|
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||||
|
<resheader name="version">2.0</resheader>
|
||||||
|
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||||
|
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||||
|
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||||
|
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||||
|
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||||
|
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||||
|
</data>
|
||||||
|
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||||
|
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||||
|
<comment>This is a comment</comment>
|
||||||
|
</data>
|
||||||
|
|
||||||
|
There are any number of "resheader" rows that contain simple
|
||||||
|
name/value pairs.
|
||||||
|
|
||||||
|
Each data row contains a name, and value. The row also contains a
|
||||||
|
type or mimetype. Type corresponds to a .NET class that support
|
||||||
|
text/value conversion through the TypeConverter architecture.
|
||||||
|
Classes that don't support this are serialized and stored with the
|
||||||
|
mimetype set.
|
||||||
|
|
||||||
|
The mimetype is used for serialized objects, and tells the
|
||||||
|
ResXResourceReader how to depersist the object. This is currently not
|
||||||
|
extensible. For a given mimetype the value must be set accordingly:
|
||||||
|
|
||||||
|
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||||
|
that the ResXResourceWriter will generate, however the reader can
|
||||||
|
read any of the formats listed below.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.binary.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.soap.base64
|
||||||
|
value : The object must be serialized with
|
||||||
|
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
|
||||||
|
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||||
|
value : The object must be serialized into a byte array
|
||||||
|
: using a System.ComponentModel.TypeConverter
|
||||||
|
: and then encoded with base64 encoding.
|
||||||
|
-->
|
||||||
|
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||||
|
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||||
|
<xsd:element name="root" msdata:IsDataSet="true">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:choice maxOccurs="unbounded">
|
||||||
|
<xsd:element name="metadata">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="assembly">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:attribute name="alias" type="xsd:string" />
|
||||||
|
<xsd:attribute name="name" type="xsd:string" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="data">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||||
|
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||||
|
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||||
|
<xsd:attribute ref="xml:space" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
<xsd:element name="resheader">
|
||||||
|
<xsd:complexType>
|
||||||
|
<xsd:sequence>
|
||||||
|
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||||
|
</xsd:sequence>
|
||||||
|
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:choice>
|
||||||
|
</xsd:complexType>
|
||||||
|
</xsd:element>
|
||||||
|
</xsd:schema>
|
||||||
|
<resheader name="resmimetype">
|
||||||
|
<value>text/microsoft-resx</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="version">
|
||||||
|
<value>2.0</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="reader">
|
||||||
|
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<resheader name="writer">
|
||||||
|
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||||
|
</resheader>
|
||||||
|
<metadata name="objectDataSource1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||||
|
<value>17, 17</value>
|
||||||
|
</metadata>
|
||||||
|
</root>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using DevExpress.XtraReports.UI;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.PredefinedReports {
|
||||||
|
public static class ReportsFactory
|
||||||
|
{
|
||||||
|
public static readonly Dictionary<string, Func<XtraReport>> Reports = new() {
|
||||||
|
["LargeDatasetReport"] = () => new PredefinedReports.Report()
|
||||||
|
};
|
||||||
|
|
||||||
|
public static XtraReport GetReport(string reportName) {
|
||||||
|
return Reports[reportName]();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
using EnvelopeGenerator.Server.Client.Services;
|
||||||
|
using EnvelopeGenerator.Server.Client.Options;
|
||||||
|
using DevExpress.Blazor.Reporting;
|
||||||
|
using DevExpress.XtraReports.Web.Extensions;
|
||||||
|
using DevExpress.DataAccess.Web;
|
||||||
|
using DevExpress.XtraReports.Services;
|
||||||
|
|
||||||
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
|
||||||
|
// Named HttpClient for API calls (both for services and DevExpress components)
|
||||||
|
builder.Services.AddHttpClient("EnvelopeGenerator.Server", client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default HttpClient (DevExpress PdfViewer requires this)
|
||||||
|
builder.Services.AddScoped(sp => new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configuration Options
|
||||||
|
builder.Services.Configure<PdfViewerOptions>(opts =>
|
||||||
|
builder.Configuration.GetSection(PdfViewerOptions.SectionName).Bind(opts));
|
||||||
|
|
||||||
|
// Business Services
|
||||||
|
builder.Services.AddScoped<DocumentService>();
|
||||||
|
builder.Services.AddScoped<AuthService>();
|
||||||
|
builder.Services.AddScoped<AnnotationService>();
|
||||||
|
builder.Services.AddScoped<EnvelopeReceiverService>();
|
||||||
|
builder.Services.AddScoped<SignatureService>();
|
||||||
|
builder.Services.AddScoped<SignatureCacheService>();
|
||||||
|
builder.Services.AddSingleton<AppVersionService>();
|
||||||
|
builder.Services.AddScoped<EnvelopeService>();
|
||||||
|
|
||||||
|
// DevExpress WASM
|
||||||
|
builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer();
|
||||||
|
builder.Services.AddDevExpressWebAssemblyBlazorReportViewer();
|
||||||
|
|
||||||
|
builder.Services.AddDevExpressBlazorReportingWebAssembly(configure => {
|
||||||
|
configure.UseDevelopmentMode();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reporting Services
|
||||||
|
builder.Services.AddScoped<IDataSourceWizardJsonConnectionStorage, CustomDataSourceWizardJsonDataConnectionStorage>();
|
||||||
|
builder.Services.AddScoped<IJsonDataConnectionProviderFactory, CustomJsonDataConnectionProviderFactory>();
|
||||||
|
builder.Services.AddScoped<IObjectDataSourceWizardTypeProvider, ObjectDataSourceWizardCustomTypeProvider>();
|
||||||
|
|
||||||
|
DevExpress.Utils.DeserializationSettings.RegisterTrustedClass(typeof(EnvelopeGenerator.Server.Client.Data.DataItemList));
|
||||||
|
DevExpress.Utils.DeserializationSettings.RegisterTrustedClass(typeof(EnvelopeGenerator.Server.Client.PredefinedReports.Report));
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<InMemoryReportStorageWebExtension>();
|
||||||
|
builder.Services.AddSingleton<ReportStorageWebExtension>(sp => sp.GetRequiredService<InMemoryReportStorageWebExtension>());
|
||||||
|
builder.Services.AddScoped<IReportProviderAsync, CustomReportProvider>();
|
||||||
|
|
||||||
|
ReportStorageWebExtension.RegisterExtensionGlobal(new InMemoryReportStorageWebExtension());
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
await FontLoader.LoadFonts(host.Services.GetRequiredService<IHttpClientFactory>(), new List<string> { "opensans.ttf" });
|
||||||
|
await host.RunAsync();
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<Router AppAssembly="typeof(Program).Assembly">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||||
|
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves annotation positions from the API.
|
||||||
|
/// Uses relative paths (/api/Annotation/{envelopeKey}).
|
||||||
|
/// </summary>
|
||||||
|
[Obsolete("Use SignatureService.")]
|
||||||
|
public class AnnotationService(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<AnnotationDto>> GetAnnotationsAsync(string envelopeKey, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
var url = $"/api/Annotation/{Uri.EscapeDataString(envelopeKey)}";
|
||||||
|
var response = await http.GetAsync(url, cancel);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<List<AnnotationDto>>(_jsonOptions, cancel);
|
||||||
|
return result ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides application version for cache busting static assets.
|
||||||
|
/// Version is automatically incremented on each build via AssemblyVersion.
|
||||||
|
/// </summary>
|
||||||
|
public class AppVersionService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Current application version (e.g., "1.0.0.0")
|
||||||
|
/// </summary>
|
||||||
|
public string Version { get; }
|
||||||
|
|
||||||
|
public AppVersionService()
|
||||||
|
{
|
||||||
|
// Get version from assembly metadata
|
||||||
|
Version = typeof(AppVersionService).Assembly.GetName().Version?.ToString() ?? "1.0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates versioned URL for static assets (cache busting)
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Asset path (e.g., "css/envelope-viewer.css")</param>
|
||||||
|
/// <returns>Versioned URL (e.g., "css/envelope-viewer.css?v=1.0.0.0")</returns>
|
||||||
|
public string GetVersionedUrl(string path) => $"{path}?v={Version}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
public enum EnvelopeLoginResult { Success, InvalidCode, NotFound, Error }
|
||||||
|
|
||||||
|
public enum SenderLoginResult { Success, InvalidCredentials, Error }
|
||||||
|
|
||||||
|
public class AuthService(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
private HttpClient CreateDefaultClient() => httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether the current user holds a valid receiver token for the given envelope key.
|
||||||
|
/// Calls GET /api/auth/check/envelope/{envelopeKey}.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> CheckEnvelopeAccessAsync(string envelopeKey, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = CreateDefaultClient();
|
||||||
|
var response = await http.GetAsync($"/api/auth/check/envelope/{Uri.EscapeDataString(envelopeKey)}", cancel);
|
||||||
|
return response.StatusCode == HttpStatusCode.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether the current user holds a valid receiver token for the given envelope key.
|
||||||
|
/// Calls GET /api/auth/check/envelope/{envelopeKey}.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> CheckSenderAccessAsync(CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = CreateDefaultClient();
|
||||||
|
var response = await http.GetAsync($"/api/auth/check", cancel);
|
||||||
|
return response.StatusCode == HttpStatusCode.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Submits the access code for the given envelope key.
|
||||||
|
/// Calls POST /api/Auth/envelope-receiver/{key} with multipart/form-data.
|
||||||
|
/// On success the API sets an authentication cookie automatically.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<EnvelopeLoginResult> LoginEnvelopeReceiverAsync(string envelopeKey, string accessCode, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = CreateDefaultClient();
|
||||||
|
var form = new MultipartFormDataContent
|
||||||
|
{
|
||||||
|
{ new StringContent(accessCode), "AccessCode" }
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
$"/api/Auth/envelope-receiver/{Uri.EscapeDataString(envelopeKey)}",
|
||||||
|
form, cancel);
|
||||||
|
|
||||||
|
return response.StatusCode switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.OK => EnvelopeLoginResult.Success,
|
||||||
|
HttpStatusCode.Unauthorized => EnvelopeLoginResult.InvalidCode,
|
||||||
|
HttpStatusCode.NotFound => EnvelopeLoginResult.NotFound,
|
||||||
|
_ => EnvelopeLoginResult.Error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the per-envelope receiver cookie for the given envelope key.
|
||||||
|
/// Calls POST /api/auth/logout/envelope/{envelopeKey}.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> LogoutEnvelopeReceiverAsync(string envelopeKey, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = CreateDefaultClient();
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
$"/api/auth/logout/envelope/{Uri.EscapeDataString(envelopeKey)}",
|
||||||
|
null, cancel);
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the per-envelope receiver cookie for the given envelope key.
|
||||||
|
/// Calls POST /api/auth/logout/envelope/{envelopeKey}.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> LogoutSenderAsync(CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = CreateDefaultClient();
|
||||||
|
var response = await http.PostAsync(
|
||||||
|
$"/api/auth/logout",
|
||||||
|
null, cancel);
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates a sender user with username and password.
|
||||||
|
/// Calls POST /api/auth?cookie=true with JSON body.
|
||||||
|
/// On success the API sets an authentication cookie automatically.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<SenderLoginResult> LoginSenderAsync(string username, string password, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = CreateDefaultClient();
|
||||||
|
var requestBody = new { username, password };
|
||||||
|
|
||||||
|
var response = await http.PostAsJsonAsync(
|
||||||
|
$"/api/auth?cookie=true",
|
||||||
|
requestBody, cancel);
|
||||||
|
|
||||||
|
return response.StatusCode switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.OK => SenderLoginResult.Success,
|
||||||
|
HttpStatusCode.Unauthorized => SenderLoginResult.InvalidCredentials,
|
||||||
|
_ => SenderLoginResult.Error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for managing application culture/localization.
|
||||||
|
/// </summary>
|
||||||
|
public class CultureService
|
||||||
|
{
|
||||||
|
private readonly IJSRuntime _jsRuntime;
|
||||||
|
private const string CULTURE_KEY = "AppCulture";
|
||||||
|
|
||||||
|
public CultureService(IJSRuntime jsRuntime)
|
||||||
|
{
|
||||||
|
_jsRuntime = jsRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the list of supported cultures.
|
||||||
|
/// </summary>
|
||||||
|
public static CultureInfo[] SupportedCultures { get; } = new[]
|
||||||
|
{
|
||||||
|
new CultureInfo("de-DE"),
|
||||||
|
new CultureInfo("en-US"),
|
||||||
|
new CultureInfo("fr-FR")
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets the application culture and stores it in localStorage.
|
||||||
|
/// </summary>
|
||||||
|
public async Task SetCultureAsync(string culture)
|
||||||
|
{
|
||||||
|
if (!SupportedCultures.Any(c => c.Name == culture))
|
||||||
|
throw new ArgumentException($"Culture '{culture}' is not supported.", nameof(culture));
|
||||||
|
|
||||||
|
await _jsRuntime.InvokeVoidAsync("localStorage.setItem", CULTURE_KEY, culture);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the stored culture from localStorage.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> GetCultureAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _jsRuntime.InvokeAsync<string?>("localStorage.getItem", CULTURE_KEY);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes the culture from localStorage or browser settings.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CultureInfo> InitializeCultureAsync()
|
||||||
|
{
|
||||||
|
var storedCulture = await GetCultureAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(storedCulture) &&
|
||||||
|
SupportedCultures.Any(c => c.Name == storedCulture))
|
||||||
|
{
|
||||||
|
return new CultureInfo(storedCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to browser culture or default
|
||||||
|
var browserCulture = CultureInfo.CurrentCulture.Name;
|
||||||
|
var matchedCulture = SupportedCultures.FirstOrDefault(c => c.Name == browserCulture);
|
||||||
|
|
||||||
|
return matchedCulture ?? SupportedCultures[0]; // Default to German
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using DevExpress.DataAccess.Json;
|
||||||
|
using DevExpress.DataAccess.Web;
|
||||||
|
using DevExpress.DataAccess.Wizard.Services;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
public class CustomDataSourceWizardJsonDataConnectionStorage : IDataSourceWizardJsonConnectionStorage
|
||||||
|
{
|
||||||
|
public static JsonDataConnection GetDefaultConnection() {
|
||||||
|
var uriJsonSource = new UriJsonSource() {
|
||||||
|
Uri = new Uri(@"https://raw.githubusercontent.com/DevExpress-Examples/DataSources/master/JSON/customers.json"),
|
||||||
|
};
|
||||||
|
return new JsonDataConnection(uriJsonSource) { StoreConnectionNameOnly = true, Name = "NWindProductsJson" };
|
||||||
|
}
|
||||||
|
public static List<JsonDataConnection> GetConnections() {
|
||||||
|
var connections = new List<JsonDataConnection> {
|
||||||
|
GetDefaultConnection()
|
||||||
|
};
|
||||||
|
return connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IJsonConnectionStorageService.CanSaveConnection => false;
|
||||||
|
bool IJsonConnectionStorageService.ContainsConnection(string connectionName) {
|
||||||
|
return GetConnections().Any(x => x.Name == connectionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<JsonDataConnection> IJsonConnectionStorageService.GetConnections() {
|
||||||
|
return GetConnections();
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonDataConnection IJsonDataConnectionProviderService.GetJsonDataConnection(string name) {
|
||||||
|
var connection = GetConnections().FirstOrDefault(x => x.Name == name);
|
||||||
|
if(connection == null)
|
||||||
|
throw new InvalidOperationException();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
void IJsonConnectionStorageService.SaveConnection(string connectionName, JsonDataConnection connection, bool saveCredentials) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using DevExpress.DataAccess.Json;
|
||||||
|
using DevExpress.DataAccess.Web;
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
public class CustomJsonDataConnectionProviderFactory : IJsonDataConnectionProviderFactory {
|
||||||
|
public IJsonDataConnectionProviderService Create() {
|
||||||
|
return new WebDocumentViewerJsonDataConnectionProvider(CustomDataSourceWizardJsonDataConnectionStorage.GetConnections());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WebDocumentViewerJsonDataConnectionProvider : IJsonDataConnectionProviderService
|
||||||
|
{
|
||||||
|
readonly List<JsonDataConnection> jsonDataConnections;
|
||||||
|
public WebDocumentViewerJsonDataConnectionProvider(List<JsonDataConnection> jsonDataConnections) {
|
||||||
|
this.jsonDataConnections = jsonDataConnections;
|
||||||
|
}
|
||||||
|
public JsonDataConnection GetJsonDataConnection(string name) {
|
||||||
|
var connection = jsonDataConnections.FirstOrDefault(x => x.Name == name);
|
||||||
|
if(connection == null)
|
||||||
|
throw new InvalidOperationException();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using DevExpress.XtraReports.UI;
|
||||||
|
using DevExpress.XtraReports.Services;
|
||||||
|
using EnvelopeGenerator.Server.Client.PredefinedReports;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
public class CustomReportProvider : IReportProviderAsync {
|
||||||
|
private readonly InMemoryReportStorageWebExtension reportStorage;
|
||||||
|
|
||||||
|
public CustomReportProvider(InMemoryReportStorageWebExtension reportStorage) {
|
||||||
|
this.reportStorage = reportStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<XtraReport> GetReportAsync(string id, ReportProviderContext context) {
|
||||||
|
if(reportStorage.TryGetReport(id, out var savedReport))
|
||||||
|
return Task.FromResult(savedReport);
|
||||||
|
|
||||||
|
return Task.FromResult(ReportsFactory.GetReport(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
public class DocReceiverElementService(IHttpClientFactory clientFactory)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SignatureDto>> GetAsync(string envelopeKey, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
var url = $"/api/DocReceiverElement/{Uri.EscapeDataString(envelopeKey)}";
|
||||||
|
|
||||||
|
var http = clientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
var response = await http.GetAsync(url, cancel);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new HttpRequestException($"Failed to retrieve signatures for envelope {envelopeKey}: {response.StatusCode} {response.ReasonPhrase}");
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
|
||||||
|
return result ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
public class DocumentService(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches the PDF bytes for the given envelope key from the API.
|
||||||
|
/// Throws HttpRequestException on failure with appropriate status code.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="HttpRequestException">Thrown when the API request fails.</exception>
|
||||||
|
public async Task<byte[]?> GetDocumentAsync(string envelopeKey, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
var response = await http.GetAsync($"/api/Document/{Uri.EscapeDataString(envelopeKey)}", cancel);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var statusCode = (int)response.StatusCode;
|
||||||
|
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Failed to load document. Status: {statusCode} ({reasonPhrase})",
|
||||||
|
null,
|
||||||
|
response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = await response.Content.ReadAsByteArrayAsync(cancel);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the <see cref="EnvelopeReceiverDto"/> for the authenticated receiver
|
||||||
|
/// from <c>GET /api/EnvelopeReceiver/{envelopeKey}</c>.
|
||||||
|
/// </summary>
|
||||||
|
public class EnvelopeReceiverService(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches the envelope receiver data for the given envelope key from the API.
|
||||||
|
/// Throws HttpRequestException on failure with appropriate status code.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="HttpRequestException">Thrown when the API request fails.</exception>
|
||||||
|
public async Task<EnvelopeReceiverDto?> GetAsync(string envelopeKey, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
var url = $"/api/EnvelopeReceiver/{Uri.EscapeDataString(envelopeKey)}";
|
||||||
|
var response = await http.GetAsync(url, cancel);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var statusCode = (int)response.StatusCode;
|
||||||
|
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Failed to load envelope receiver data. Status: {statusCode} ({reasonPhrase})",
|
||||||
|
null,
|
||||||
|
response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<EnvelopeReceiverDto>(_jsonOptions, cancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves <see cref="EnvelopeDto"/>s from the API.
|
||||||
|
/// </summary>
|
||||||
|
public class EnvelopeService(IHttpClientFactory clientFactory)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches envelopes from the API with optional filters.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="HttpRequestException">Thrown when the API request fails.</exception>
|
||||||
|
public async Task<IEnumerable<EnvelopeDto>?> GetAsync(
|
||||||
|
int? id = null,
|
||||||
|
string? uuid = null,
|
||||||
|
bool? onlyActive = null,
|
||||||
|
bool? onlyCompleted = null,
|
||||||
|
CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
var baseUrl = $"/api/Envelope";
|
||||||
|
var queryParams = new Dictionary<string, string?>();
|
||||||
|
|
||||||
|
if (id.HasValue)
|
||||||
|
{
|
||||||
|
queryParams["Id"] = id.Value.ToString();
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrEmpty(uuid))
|
||||||
|
{
|
||||||
|
queryParams["Uuid"] = uuid;
|
||||||
|
}
|
||||||
|
if (onlyActive.HasValue)
|
||||||
|
{
|
||||||
|
queryParams["OnlyActive"] = onlyActive.Value.ToString();
|
||||||
|
}
|
||||||
|
if (onlyCompleted.HasValue)
|
||||||
|
{
|
||||||
|
queryParams["OnlyCompleted"] = onlyCompleted.Value.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = QueryHelpers.AddQueryString(baseUrl, queryParams);
|
||||||
|
|
||||||
|
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
var response = await httpClient.GetAsync(url, cancel);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var statusCode = (int)response.StatusCode;
|
||||||
|
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Failed to load envelopes. Status: {statusCode} ({reasonPhrase})",
|
||||||
|
null,
|
||||||
|
response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<IEnumerable<EnvelopeDto>>(_jsonOptions, cancel);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using DevExpress.Drawing;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
public static class FontLoader
|
||||||
|
{
|
||||||
|
public static async Task LoadFonts(IHttpClientFactory httpClientFactory, List<string> fontNames)
|
||||||
|
{
|
||||||
|
using var httpClient = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
|
||||||
|
foreach (var fontName in fontNames)
|
||||||
|
{
|
||||||
|
var fontBytes = await httpClient.GetByteArrayAsync($"/fonts/{fontName}");
|
||||||
|
DXFontRepository.Instance.AddFont(fontBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using DevExpress.XtraReports.UI;
|
||||||
|
using DevExpress.XtraReports.Web.Extensions;
|
||||||
|
using EnvelopeGenerator.Server.Client.PredefinedReports;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
public class InMemoryReportStorageWebExtension : ReportStorageWebExtension
|
||||||
|
{
|
||||||
|
private const string DefaultReportName = "LargeDatasetReport";
|
||||||
|
private static readonly Dictionary<string, byte[]> Reports = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public override bool CanSetData(string url) => IsValidUrl(url);
|
||||||
|
|
||||||
|
public override byte[] GetData(string url)
|
||||||
|
{
|
||||||
|
url = NormalizeUrl(url);
|
||||||
|
|
||||||
|
if (Reports.TryGetValue(url, out var reportLayout))
|
||||||
|
return reportLayout;
|
||||||
|
|
||||||
|
if (ReportsFactory.Reports.TryGetValue(url, out var reportFactory))
|
||||||
|
return SaveReport(reportFactory());
|
||||||
|
|
||||||
|
throw new DevExpress.XtraReports.Web.ClientControls.FaultException($"Report '{url}' was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Dictionary<string, string> GetUrls()
|
||||||
|
{
|
||||||
|
var urls = ReportsFactory.Reports.Keys
|
||||||
|
.Concat(Reports.Keys)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(name => name, name => name, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool IsValidUrl(string url)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(url)
|
||||||
|
&& url.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void SetData(XtraReport report, string url)
|
||||||
|
{
|
||||||
|
url = NormalizeUrl(url);
|
||||||
|
Reports[url] = SaveReport(report);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string SetNewData(XtraReport report, string defaultUrl)
|
||||||
|
{
|
||||||
|
var url = NormalizeUrl(defaultUrl);
|
||||||
|
Reports[url] = SaveReport(report);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetReport(string url, out XtraReport report)
|
||||||
|
{
|
||||||
|
url = NormalizeUrl(url);
|
||||||
|
|
||||||
|
if (!Reports.ContainsKey(url))
|
||||||
|
{
|
||||||
|
report = null!;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(Reports[url]);
|
||||||
|
report = XtraReport.FromXmlStream(stream, true);
|
||||||
|
report.Name = url;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeUrl(string url)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(url) ? DefaultReportName : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] SaveReport(XtraReport report)
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
report.SaveLayoutToXml(stream);
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using DevExpress.DataAccess.Web;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
public class ObjectDataSourceWizardCustomTypeProvider : IObjectDataSourceWizardTypeProvider {
|
||||||
|
public IEnumerable<Type> GetAvailableTypes(string context) {
|
||||||
|
return new[] { typeof(Data.DataItemList) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Client service for managing cached signatures via API.
|
||||||
|
/// </summary>
|
||||||
|
public class SignatureCacheService(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
|
||||||
|
public async Task SaveSignatureAsync(
|
||||||
|
string envelopeKey,
|
||||||
|
SignatureCaptureDto signature,
|
||||||
|
CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
var response = await http.PostAsJsonAsync(
|
||||||
|
$"/api/Cache/SignatureCapture/{Uri.EscapeDataString(envelopeKey)}",
|
||||||
|
signature,
|
||||||
|
cancel);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var error = await response.Content.ReadAsStringAsync(cancel);
|
||||||
|
throw new HttpRequestException($"Failed to cache signature: {response.StatusCode} - {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SignatureCaptureDto?> GetSignatureAsync(
|
||||||
|
string envelopeKey,
|
||||||
|
CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
var response = await http.GetAsync(
|
||||||
|
$"/api/Cache/SignatureCapture/{Uri.EscapeDataString(envelopeKey)}",
|
||||||
|
cancel);
|
||||||
|
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var error = await response.Content.ReadAsStringAsync(cancel);
|
||||||
|
throw new HttpRequestException($"Failed to retrieve signature: {response.StatusCode} - {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<SignatureCaptureDto>(cancellationToken: cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteSignatureAsync(
|
||||||
|
string envelopeKey,
|
||||||
|
CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
var response = await http.DeleteAsync(
|
||||||
|
$"/api/Cache/SignatureCapture/{Uri.EscapeDataString(envelopeKey)}",
|
||||||
|
cancel);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var error = await response.Content.ReadAsStringAsync(cancel);
|
||||||
|
throw new HttpRequestException($"Failed to delete signature: {response.StatusCode} - {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using EnvelopeGenerator.Server.Client.Models;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Client.Services;
|
||||||
|
|
||||||
|
public class SignatureService(IHttpClientFactory httpClientFactory)
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<SignatureDto>> GetAsync(string envelopeKey, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||||
|
var url = $"/api/Signature/{Uri.EscapeDataString(envelopeKey)}";
|
||||||
|
var response = await http.GetAsync(url, cancel);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new HttpRequestException($"Failed to retrieve signatures for envelope {envelopeKey}: {response.StatusCode} {response.ReasonPhrase}");
|
||||||
|
|
||||||
|
var result = await response.Content.ReadFromJsonAsync<List<SignatureDto>>(_jsonOptions, cancel);
|
||||||
|
return result ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@using System.Net.Http
|
||||||
|
@using System.Net.Http.Json
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
|
@using Microsoft.AspNetCore.Components.WebAssembly.Http
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using EnvelopeGenerator.Server.Client
|
||||||
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
|
@using EnvelopeGenerator.Server.Client.Models
|
||||||
|
@using EnvelopeGenerator.Server.Client.Options
|
||||||
|
@using DevExpress.Blazor
|
||||||
|
@using DevExpress.Blazor.PdfViewer
|
||||||
|
@using DevExpress.Blazor.Reporting
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace EnvelopeGenerator.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authentication scheme names for envelope generator.
|
||||||
|
/// </summary>
|
||||||
|
public static class AuthScheme
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Scheme name used for per-envelope receiver JWT authentication.
|
||||||
|
/// </summary>
|
||||||
|
public const string Receiver = "EnvelopeGenerator.Server.ReceiverJWT";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scheme name used for per-envelope sender JWT authentication.
|
||||||
|
/// </summary>
|
||||||
|
public const string Sender = "EnvelopeGenerator.Server.SenderJWT";
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<base href="/" />
|
||||||
|
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
|
||||||
|
<link rel="stylesheet" href="css/app.css" />
|
||||||
|
<link rel="stylesheet" href="css/envelope-viewer.css" />
|
||||||
|
<link rel="stylesheet" href="EnvelopeGenerator.Server.styles.css" />
|
||||||
|
<HeadOutlet />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<Routes />
|
||||||
|
<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="_framework/blazor.web.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
|||||||
|
@page "/envelope/DxPdfViewer"
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@using System.IO
|
||||||
|
@using DevExpress.Blazor
|
||||||
|
@using System.Reflection
|
||||||
|
@using DevExpress.Blazor.PdfViewer
|
||||||
|
|
||||||
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.custom-drop-zone {
|
||||||
|
padding: 0 !important;
|
||||||
|
border-style: dashed;
|
||||||
|
border-width: 2px !important;
|
||||||
|
height: 230px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(183, 183, 183, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-drop-zone.custom-drop-zone-hover {
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-drop-zone svg {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-drop-zone > *:not(#overviewDemoSelectButton) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-viewer {
|
||||||
|
height: 800px !important;
|
||||||
|
min-height: 800px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-viewer .dxbrv-surface-wrapper,
|
||||||
|
.pdf-viewer .dxbrv-document-surface {
|
||||||
|
height: 100% !important;
|
||||||
|
min-height: 750px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-viewer .dxbrv-report-preview-content {
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
min-width: 200px !important;
|
||||||
|
min-height: 200px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="overviewDemoDropZone" class="card custom-drop-zone rounded-3 w-100 m-0">
|
||||||
|
<span class="drop-file-icon mb-3"></span>
|
||||||
|
<span class="drop-file-label">Drag and Drop File Here</span><span class="m-1">or</span>
|
||||||
|
<DxButton Id="overviewDemoSelectButton"
|
||||||
|
CssClass="m-1"
|
||||||
|
RenderStyle="ButtonRenderStyle.Primary"
|
||||||
|
Text="Select File" />
|
||||||
|
</div>
|
||||||
|
<DxFileInput @ref="fileInput"
|
||||||
|
AcceptedFileTypes="@ALLOWED_FILE_TYPES"
|
||||||
|
AllowedFileExtensions="@ALLOWED_FILE_TYPES"
|
||||||
|
CssClass="w-100"
|
||||||
|
ExternalDropZoneCssSelector="#overviewDemoDropZone"
|
||||||
|
ExternalDropZoneDragOverCssClass="custom-drop-zone-hover"
|
||||||
|
ExternalSelectButtonCssSelector="#overviewDemoSelectButton"
|
||||||
|
FilesUploading="OnFilesUploading"
|
||||||
|
MaxFileSize="2000000">
|
||||||
|
</DxFileInput>
|
||||||
|
|
||||||
|
@if (DocumentContent != null && DocumentContent.Length > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success mt-3">
|
||||||
|
PDF loaded: @DocumentContent.Length bytes
|
||||||
|
</div>
|
||||||
|
<DxPdfViewer CssClass="w-100 pdf-viewer" DocumentContent="@DocumentContent" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-info mt-3">
|
||||||
|
Please upload a PDF file to view it.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
readonly List<string> ALLOWED_FILE_TYPES = new List<string> { ".pdf" };
|
||||||
|
DxFileInput fileInput;
|
||||||
|
byte[] DocumentContent { get; set; }
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Assembly assembly = Assembly.GetExecutingAssembly();
|
||||||
|
Stream stream = assembly.GetManifestResourceStream("EnvelopeGenerator.Server.Resources.Invoice.pdf");
|
||||||
|
if (stream != null)
|
||||||
|
{
|
||||||
|
using (stream)
|
||||||
|
using (var binaryReader = new BinaryReader(stream))
|
||||||
|
DocumentContent = binaryReader.ReadBytes((int)stream.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
protected async Task OnFilesUploading(FilesUploadingEventArgs args)
|
||||||
|
{
|
||||||
|
using (MemoryStream stream = new MemoryStream())
|
||||||
|
{
|
||||||
|
IFileInputSelectedFile file = args.Files[0];
|
||||||
|
await file.OpenReadStream(file.Size).CopyToAsync(stream);
|
||||||
|
DocumentContent = stream.ToArray();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
@page "/envelope/{EnvelopeKey}/DxReportViewer"
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@using XtraReport = DevExpress.XtraReports.UI.XtraReport
|
||||||
|
@using DevExpress.Blazor.Reporting
|
||||||
|
@using Microsoft.Extensions.Options
|
||||||
|
@using EnvelopeGenerator.Server.Client.Options
|
||||||
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
|
@inject InMemoryReportStorageWebExtension ReportStorage
|
||||||
|
@inject DocumentService DocumentService
|
||||||
|
@inject IOptions<ApiOptions> AppOptions
|
||||||
|
|
||||||
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
|
<link href="_content/DevExpress.Blazor.Reporting.Viewer/css/dx-blazor-reporting-components.bs5.css" rel="stylesheet" />
|
||||||
|
|
||||||
|
|
||||||
|
@if (_report is not null) {
|
||||||
|
<DxReportViewer Report="_report" RootCssClasses="w-100 h-100" Zoom="1.3" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string EnvelopeKey { get; init; } = null!;
|
||||||
|
|
||||||
|
XtraReport? _report = null;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_report = await CreateReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<XtraReport> CreateReport()
|
||||||
|
{
|
||||||
|
if (AppOptions.Value.UsePredefinedReports)
|
||||||
|
{
|
||||||
|
return Client.PredefinedReports.ReportsFactory.GetReport("LargeDatasetReport");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
|
||||||
|
var pdfBytes = await DocumentService.GetDocumentAsync(EnvelopeKey);
|
||||||
|
if (pdfBytes is null || pdfBytes.Length == 0)
|
||||||
|
throw new InvalidOperationException($"No PDF bytes found for EnvelopeKey: {EnvelopeKey}");
|
||||||
|
|
||||||
|
var report = new XtraReport();
|
||||||
|
var detail = new DevExpress.XtraReports.UI.DetailBand();
|
||||||
|
report.Bands.Add(detail);
|
||||||
|
detail.Controls.Add(new DevExpress.XtraReports.UI.XRPdfContent { Source = pdfBytes, GenerateOwnPages = true });
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
@page "/envelope/Embed"
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@using System.IO
|
||||||
|
@using DevExpress.Blazor
|
||||||
|
@using System.Reflection
|
||||||
|
|
||||||
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.custom-drop-zone {
|
||||||
|
padding: 0 !important;
|
||||||
|
border-style: dashed;
|
||||||
|
border-width: 2px !important;
|
||||||
|
height: 230px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: rgba(183, 183, 183, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-drop-zone.custom-drop-zone-hover {
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-drop-zone svg {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-drop-zone > *:not(#overviewDemoSelectButton) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-viewer {
|
||||||
|
height: 800px !important;
|
||||||
|
min-height: 800px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-viewer .dxbrv-surface-wrapper,
|
||||||
|
.pdf-viewer .dxbrv-document-surface {
|
||||||
|
height: 100% !important;
|
||||||
|
min-height: 750px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-viewer .dxbrv-report-preview-content {
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
|
min-width: 200px !important;
|
||||||
|
min-height: 200px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="overviewDemoDropZone" class="card custom-drop-zone rounded-3 w-100 m-0">
|
||||||
|
<span class="drop-file-icon mb-3"></span>
|
||||||
|
<span class="drop-file-label">Drag and Drop File Here</span><span class="m-1">or</span>
|
||||||
|
<DxButton Id="overviewDemoSelectButton"
|
||||||
|
CssClass="m-1"
|
||||||
|
RenderStyle="ButtonRenderStyle.Primary"
|
||||||
|
Text="Select File" />
|
||||||
|
</div>
|
||||||
|
<DxFileInput @ref="fileInput"
|
||||||
|
AcceptedFileTypes="@ALLOWED_FILE_TYPES"
|
||||||
|
AllowedFileExtensions="@ALLOWED_FILE_TYPES"
|
||||||
|
CssClass="w-100"
|
||||||
|
ExternalDropZoneCssSelector="#overviewDemoDropZone"
|
||||||
|
ExternalDropZoneDragOverCssClass="custom-drop-zone-hover"
|
||||||
|
ExternalSelectButtonCssSelector="#overviewDemoSelectButton"
|
||||||
|
FilesUploading="OnFilesUploading"
|
||||||
|
MaxFileSize="2000000">
|
||||||
|
</DxFileInput>
|
||||||
|
|
||||||
|
@if (DocumentContent != null && DocumentContent.Length > 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success mt-3">
|
||||||
|
PDF loaded: @DocumentContent.Length bytes
|
||||||
|
</div>
|
||||||
|
<embed src="@GetPdfDataUrl()" type="application/pdf" class="w-100 pdf-viewer" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert alert-info mt-3">
|
||||||
|
Please upload a PDF file to view it.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
readonly List<string> ALLOWED_FILE_TYPES = new List<string> { ".pdf" };
|
||||||
|
DxFileInput fileInput;
|
||||||
|
byte[] DocumentContent { get; set; }
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
Assembly assembly = Assembly.GetExecutingAssembly();
|
||||||
|
Stream stream = assembly.GetManifestResourceStream("EnvelopeGenerator.Server.Resources.Invoice.pdf");
|
||||||
|
if (stream != null)
|
||||||
|
{
|
||||||
|
using (stream)
|
||||||
|
using (var binaryReader = new BinaryReader(stream))
|
||||||
|
DocumentContent = binaryReader.ReadBytes((int)stream.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task OnFilesUploading(FilesUploadingEventArgs args)
|
||||||
|
{
|
||||||
|
using (MemoryStream stream = new MemoryStream())
|
||||||
|
{
|
||||||
|
IFileInputSelectedFile file = args.Files[0];
|
||||||
|
await file.OpenReadStream(file.Size).CopyToAsync(stream);
|
||||||
|
DocumentContent = stream.ToArray();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetPdfDataUrl()
|
||||||
|
{
|
||||||
|
if (DocumentContent == null || DocumentContent.Length == 0)
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
string base64 = Convert.ToBase64String(DocumentContent);
|
||||||
|
return $"data:application/pdf;base64,{base64}#toolbar=0&navpanes=0&scrollbar=1";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
@page "/envelope/{EnvelopeKey}/report"
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@using DevExpress.Blazor.Reporting
|
||||||
|
@using DevExpress.XtraReports.UI
|
||||||
|
@using EnvelopeGenerator.Server.Client.Models
|
||||||
|
@using EnvelopeGenerator.Server.Client.Models.Constants
|
||||||
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
|
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using DevExpress.Blazor
|
||||||
|
@using System.Drawing
|
||||||
|
@using System.Security.Claims
|
||||||
|
@using Microsoft.Extensions.Caching.Memory
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||||
|
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
||||||
|
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||||
|
@inject AppVersionService AppVersion
|
||||||
|
@inject IMemoryCache MemoryCache
|
||||||
|
@inject ILogger<EnvelopeReceiverReportPage> Logger
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
|
<link href="_content/DevExpress.Blazor.Reporting.Viewer/css/dx-blazor-reporting-components.bs5.css" rel="stylesheet" />
|
||||||
|
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
|
||||||
|
<script src="@AppVersion.GetVersionedUrl("js/receiver-signature.js")"></script>
|
||||||
|
|
||||||
|
<div class="envelope-viewer-layout">
|
||||||
|
<div class="envelope-action-bar">
|
||||||
|
<div class="envelope-action-bar__inner" style="flex-direction: column; align-items: stretch; padding: 0.35rem 1.5rem; gap: 0.35rem;">
|
||||||
|
@* Row 1: Title + Sender + Badges *@
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem;">
|
||||||
|
@* Left: Title + Sender *@
|
||||||
|
<div style="flex: 0 1 auto; min-width: 0; display: flex; align-items: center; gap: 0.75rem;">
|
||||||
|
@if (_envelopeReceiver is not null)
|
||||||
|
{
|
||||||
|
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||||
|
@(_envelopeReceiver.Envelope?.Title ?? "Dokument")
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName) || !string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.Email))
|
||||||
|
{
|
||||||
|
<span style="font-size: 0.7rem; color: #6b7280; white-space: nowrap;">
|
||||||
|
Von
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName))
|
||||||
|
{
|
||||||
|
<span style="font-weight: 500; color: #374151;">@_envelopeReceiver.Envelope.User.FullName</span>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.Email))
|
||||||
|
{
|
||||||
|
<span><@_envelopeReceiver.Envelope.User.Email></span>
|
||||||
|
}
|
||||||
|
@if (_envelopeReceiver.Envelope?.AddedWhen != null)
|
||||||
|
{
|
||||||
|
<span> · @_envelopeReceiver.Envelope.AddedWhen.ToString("dd.MM.yyyy")</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937;">Dokumentenansicht</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Right: Badges + Signature status *@
|
||||||
|
<div class="d-flex align-items-center" style="gap: 0.75rem; flex: 0 0 auto;">
|
||||||
|
@if (_envelopeReceiver is not null)
|
||||||
|
{
|
||||||
|
<div class="d-flex flex-wrap align-items-center" style="gap: 0.3rem; font-size: 0.7rem;">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Name))
|
||||||
|
{
|
||||||
|
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem; background: #f3f4f6; border-radius: 0.25rem; color: #374151; white-space: nowrap;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0Zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4Z" />
|
||||||
|
</svg>
|
||||||
|
@_envelopeReceiver.Name
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (_signatures.Count > 0)
|
||||||
|
{
|
||||||
|
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem; background: @(_capturedSignature is not null ? "#d1fae5" : "#ede9fe"); border-radius: 0.25rem; color: @(_capturedSignature is not null ? "#065f46" : "#6d28d9"); font-weight: 500; white-space: nowrap;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||||
|
</svg>
|
||||||
|
@_signatures.Count Unterschrift@(_signatures.Count != 1 ? "en" : "")
|
||||||
|
@if (_capturedSignature is not null)
|
||||||
|
{
|
||||||
|
<span class="ms-1">✓</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (_envelopeReceiver.Envelope?.UseAccessCode ?? false)
|
||||||
|
{
|
||||||
|
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem; background: #fef3c7; border-radius: 0.25rem; color: #92400e; font-weight: 500; white-space: nowrap;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="currentColor" class="me-1" 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>
|
||||||
|
Code
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (_envelopeReceiver.Envelope?.TFAEnabled ?? false)
|
||||||
|
{
|
||||||
|
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem; background: #dbeafe; border-radius: 0.25rem; color: #1e40af; font-weight: 500; white-space: nowrap;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="9" height="9" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M5.338 1.59a61.44 61.44 0 0 0-2.837.856.481.481 0 0 0-.328.39c-.554 4.157.726 7.19 2.253 9.188a10.725 10.725 0 0 0 2.287 2.233c.346.244.652.42.893.533.12.057.218.095.293.118a.55.55 0 0 0 .101.025.615.615 0 0 0 .1-.025c.076-.023.174-.061.294-.118.24-.113.547-.29.893-.533a10.726 10.726 0 0 0 2.287-2.233c1.527-1.997 2.807-5.031 2.253-9.188a.48.48 0 0 0-.328-.39c-.651-.213-1.75-.56-2.837-.855C9.552 1.29 8.531 1.067 8 1.067c-.53 0-1.552.223-2.662.524zM5.072.56C6.157.265 7.31 0 8 0s1.843.265 2.928.56c1.11.3 2.229.655 2.887.87a1.54 1.54 0 0 1 1.044 1.262c.596 4.477-.787 7.795-2.465 9.99a11.775 11.775 0 0 1-2.517 2.453 7.159 7.159 0 0 1-1.048.625c-.28.132-.581.24-.829.24s-.548-.108-.829-.24a7.158 7.158 0 0 1-1.048-.625 11.777 11.777 0 0 1-2.517-2.453C1.928 10.487.545 7.169 1.141 2.692A1.54 1.54 0 0 1 2.185 1.43 62.456 62.456 0 0 1 5.072.56z" />
|
||||||
|
<path d="M10.854 5.146a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7.5 7.793l2.646-2.647a.5.5 0 0 1 .708 0z" />
|
||||||
|
</svg>
|
||||||
|
2FA
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Unterschreiben button — visible only when signature fields exist *@
|
||||||
|
@if (_signatures.Count > 0)
|
||||||
|
{
|
||||||
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change pdf-toolbar__btn--signature-change-active"
|
||||||
|
@onclick="OpenSignaturePopup"
|
||||||
|
title="Unterschreiben"
|
||||||
|
style="flex-shrink: 0;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z" />
|
||||||
|
</svg>
|
||||||
|
<span class="pdf-toolbar__btn-text">Unterschreiben</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Row 2: Messages *@
|
||||||
|
@if (_envelopeReceiver is not null && (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.Message) || !string.IsNullOrWhiteSpace(_envelopeReceiver.PrivateMessage)))
|
||||||
|
{
|
||||||
|
<div style="display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.7rem; padding-top: 0.15rem; border-top: 1px solid #e5e7eb;">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.Message))
|
||||||
|
{
|
||||||
|
<div style="flex: 1; min-width: 0; padding: 0.2rem 0.4rem; background: #f9fafb; border-radius: 0.25rem; border-left: 2px solid #9ca3af; display: flex; align-items: flex-start; gap: 0.25rem;">
|
||||||
|
<span style="font-weight: 500; color: #374151; flex-shrink: 0;">📧</span>
|
||||||
|
<span style="color: #6b7280; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@_envelopeReceiver.Envelope.Message</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.PrivateMessage))
|
||||||
|
{
|
||||||
|
<div style="flex: 1; min-width: 0; padding: 0.2rem 0.4rem; background: #fef3c7; border-radius: 0.25rem; border-left: 2px solid #f59e0b; display: flex; align-items: flex-start; gap: 0.25rem;">
|
||||||
|
<span style="font-weight: 500; color: #92400e; flex-shrink: 0;">🔒</span>
|
||||||
|
<span style="color: #92400e; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">@_envelopeReceiver.PrivateMessage</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="envelope-content" style="padding: 0; overflow: hidden;">
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-center align-items-center h-100">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="spinner-border text-white mb-3" style="width: 3.5rem; height: 3.5rem;" role="status">
|
||||||
|
<span class="visually-hidden">Lädt...</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-white fw-semibold">Dokument wird geladen...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_errorMessage is not null)
|
||||||
|
{
|
||||||
|
<div class="error-container">
|
||||||
|
<div class="alert alert-danger shadow-lg">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="me-3 flex-shrink-0" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||||
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-2">Fehler beim Laden des Dokuments</h5>
|
||||||
|
<p class="mb-0">@_errorMessage</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_report is not null)
|
||||||
|
{
|
||||||
|
<DxReportViewer @ref="_reportViewer"
|
||||||
|
Report="_report"
|
||||||
|
RootCssClasses="w-100 h-100" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Signature Popup *@
|
||||||
|
<DxPopup @bind-Visible="_signaturePopupVisible"
|
||||||
|
HeaderText="Unterschrift erstellen"
|
||||||
|
Width="620px"
|
||||||
|
MaxWidth="95vw"
|
||||||
|
ShowFooter="true"
|
||||||
|
CloseOnOutsideClick="false"
|
||||||
|
ShowCloseButton="false"
|
||||||
|
CloseOnEscape="false"
|
||||||
|
Shown="OnPopupShownAsync">
|
||||||
|
<BodyContentTemplate>
|
||||||
|
<ul class="nav nav-tabs mb-3" style="border-bottom: 2px solid #e9ecef;">
|
||||||
|
<li class="nav-item">
|
||||||
|
<button type="button"
|
||||||
|
class="nav-link @(_activeSignatureTab == SignatureTabDraw ? "active" : "")"
|
||||||
|
style="@(_activeSignatureTab == SignatureTabDraw ? "border-bottom: 3px solid #4F46E5; color: #4F46E5; font-weight: 600;" : "color: #6c757d;")"
|
||||||
|
@onclick="() => SetSignatureTabAsync(SignatureTabDraw)">
|
||||||
|
Zeichnen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button type="button"
|
||||||
|
class="nav-link @(_activeSignatureTab == SignatureTabText ? "active" : "")"
|
||||||
|
style="@(_activeSignatureTab == SignatureTabText ? "border-bottom: 3px solid #4F46E5; color: #4F46E5; font-weight: 600;" : "color: #6c757d;")"
|
||||||
|
@onclick="() => SetSignatureTabAsync(SignatureTabText)">
|
||||||
|
Text
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button type="button"
|
||||||
|
class="nav-link @(_activeSignatureTab == SignatureTabImage ? "active" : "")"
|
||||||
|
style="@(_activeSignatureTab == SignatureTabImage ? "border-bottom: 3px solid #4F46E5; color: #4F46E5; font-weight: 600;" : "color: #6c757d;")"
|
||||||
|
@onclick="() => SetSignatureTabAsync(SignatureTabImage)">
|
||||||
|
Bild
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@if (_activeSignatureTab == SignatureTabDraw)
|
||||||
|
{
|
||||||
|
<p style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.75rem;">Bitte unterschreiben Sie im folgenden Feld.</p>
|
||||||
|
<canvas id="rp-signature-pad"
|
||||||
|
width="560"
|
||||||
|
height="180"
|
||||||
|
style="border: 2px solid #e9ecef; border-radius: 8px; background: white; width: 100%; max-width: 560px; touch-action: none; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"></canvas>
|
||||||
|
}
|
||||||
|
else if (_activeSignatureTab == SignatureTabText)
|
||||||
|
{
|
||||||
|
<p style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.75rem;">Geben Sie Ihre Unterschrift als Text ein und wählen Sie eine Schriftart.</p>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-12 col-md-7">
|
||||||
|
<input class="form-control"
|
||||||
|
placeholder="Ihre Unterschrift"
|
||||||
|
value="@_typedSignatureText"
|
||||||
|
@oninput="OnTypedSignatureChanged"
|
||||||
|
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-5">
|
||||||
|
<select class="form-select"
|
||||||
|
value="@_typedSignatureFont"
|
||||||
|
@onchange="OnTypedSignatureFontChanged"
|
||||||
|
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;">
|
||||||
|
@foreach (var font in TypedSignatureFonts)
|
||||||
|
{
|
||||||
|
<option value="@font.Value" style="font-family: @font.Value">@font.Text</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="rp-typed-signature-pad"
|
||||||
|
width="560"
|
||||||
|
height="180"
|
||||||
|
style="border: 2px solid #e9ecef; border-radius: 8px; background: white; width: 100%; max-width: 560px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"></canvas>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p style="color: #6c757d; font-size: 0.875rem; margin-bottom: 0.75rem;">Laden Sie ein Bild Ihrer Unterschrift hoch.</p>
|
||||||
|
<input id="rp-signature-image-input"
|
||||||
|
class="form-control mb-3"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
|
||||||
|
<canvas id="rp-image-signature-pad"
|
||||||
|
width="560"
|
||||||
|
height="180"
|
||||||
|
style="border: 2px solid #e9ecef; border-radius: 8px; background: white; width: 100%; max-width: 560px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"></canvas>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div style="border-top: 2px solid #e9ecef; margin-top: 1.5rem; padding-top: 1.5rem;">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label" for="rp-signer-name" style="font-size: 0.875rem; font-weight: 500; color: #495057;">
|
||||||
|
Vor- und Nachname <span style="color: #dc3545;">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="rp-signer-name"
|
||||||
|
class="form-control"
|
||||||
|
value="@_signerFullName"
|
||||||
|
@oninput="args => _signerFullName = args.Value?.ToString() ?? string.Empty"
|
||||||
|
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label" for="rp-signer-position" style="font-size: 0.875rem; font-weight: 500; color: #495057;">
|
||||||
|
Position <span style="color: #6c757d; font-weight: 400;">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input id="rp-signer-position"
|
||||||
|
class="form-control"
|
||||||
|
value="@_signerPosition"
|
||||||
|
@oninput="args => _signerPosition = args.Value?.ToString() ?? string.Empty"
|
||||||
|
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label class="form-label" for="rp-signature-place" style="font-size: 0.875rem; font-weight: 500; color: #495057;">
|
||||||
|
Ort <span style="color: #dc3545;">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="rp-signature-place"
|
||||||
|
class="form-control"
|
||||||
|
value="@_signaturePlace"
|
||||||
|
@oninput="args => _signaturePlace = args.Value?.ToString() ?? string.Empty"
|
||||||
|
style="border: 2px solid #e9ecef; border-radius: 6px; padding: 0.625rem;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_popupValidationMessage))
|
||||||
|
{
|
||||||
|
<div style="background: #fee; border-left: 4px solid #dc3545; padding: 0.75rem 1rem; margin-top: 1rem; border-radius: 4px;">
|
||||||
|
<span style="color: #dc3545; font-size: 0.875rem; font-weight: 500;">@_popupValidationMessage</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</BodyContentTemplate>
|
||||||
|
<FooterContentTemplate>
|
||||||
|
<div class="d-flex gap-2 justify-content-between w-100" style="padding: 0.5rem 0;">
|
||||||
|
<button class="btn btn-outline-secondary"
|
||||||
|
@onclick="RenewSignatureAsync"
|
||||||
|
style="border-radius: 6px; padding: 0.625rem 1.25rem; font-weight: 500;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z" />
|
||||||
|
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z" />
|
||||||
|
</svg>
|
||||||
|
Erneuern
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
@onclick="SaveSignatureAsync"
|
||||||
|
style="background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%); border: none; border-radius: 6px; padding: 0.625rem 2rem; font-weight: 600; box-shadow: 0 2px 4px rgba(79, 70, 229, 0.3);">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
||||||
|
</svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FooterContentTemplate>
|
||||||
|
</DxPopup>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// ----- Constants -----
|
||||||
|
const string SignatureTabDraw = "draw";
|
||||||
|
const string SignatureTabText = "text";
|
||||||
|
const string SignatureTabImage = "image";
|
||||||
|
const string DrawCanvasId = "rp-signature-pad";
|
||||||
|
const string TypedCanvasId = "rp-typed-signature-pad";
|
||||||
|
const string ImageInputId = "rp-signature-image-input";
|
||||||
|
const string ImageCanvasId = "rp-image-signature-pad";
|
||||||
|
|
||||||
|
readonly (string Text, string Value)[] TypedSignatureFonts =
|
||||||
|
[
|
||||||
|
("Brush Script", "'Brush Script MT', cursive"),
|
||||||
|
("Segoe Script", "'Segoe Script', cursive"),
|
||||||
|
("Lucida Handwriting", "'Lucida Handwriting', cursive"),
|
||||||
|
("Comic Sans", "'Comic Sans MS', cursive"),
|
||||||
|
("Cursive", "cursive"),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ----- Parameters -----
|
||||||
|
[Parameter] public string? EnvelopeKey { get; set; }
|
||||||
|
|
||||||
|
// ----- Page state -----
|
||||||
|
bool _isLoading = true;
|
||||||
|
string? _errorMessage;
|
||||||
|
byte[]? _pdfBytes;
|
||||||
|
IReadOnlyList<SignatureDto> _signatures = [];
|
||||||
|
EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto? _envelopeReceiver;
|
||||||
|
ClaimsPrincipal? _receiverUser;
|
||||||
|
|
||||||
|
// ----- Report viewer -----
|
||||||
|
DxReportViewer? _reportViewer;
|
||||||
|
XtraReport? _report;
|
||||||
|
|
||||||
|
// ----- Signature popup state -----
|
||||||
|
SignatureCaptureDto? _capturedSignature;
|
||||||
|
bool _signaturePopupVisible = false;
|
||||||
|
string? _popupValidationMessage;
|
||||||
|
string _activeSignatureTab = SignatureTabDraw;
|
||||||
|
string _typedSignatureText = string.Empty;
|
||||||
|
string _typedSignatureFont = "'Brush Script MT', cursive";
|
||||||
|
string _signerFullName = string.Empty;
|
||||||
|
string _signerPosition = string.Empty;
|
||||||
|
string _signaturePlace = string.Empty;
|
||||||
|
|
||||||
|
// ----- Lifecycle -----
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||||
|
{
|
||||||
|
_errorMessage = "Envelope-Schlüssel fehlt.";
|
||||||
|
_isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization — same pattern as EnvelopeReceiverPage
|
||||||
|
_receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
|
||||||
|
if (_receiverUser is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Load PDF bytes via MediatR (uses authenticated user's claims)
|
||||||
|
_pdfBytes = await PageDataService.GetDocumentAsync(_receiverUser);
|
||||||
|
if (_pdfBytes is not { Length: > 0 })
|
||||||
|
{
|
||||||
|
_errorMessage = "Dokument konnte nicht geladen werden: Keine Daten empfangen.";
|
||||||
|
_isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load signature fields for this receiver
|
||||||
|
_signatures = await PageDataService.GetSignaturesAsync(_receiverUser);
|
||||||
|
|
||||||
|
// Load envelope receiver metadata
|
||||||
|
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
|
||||||
|
if (_envelopeReceiver is null)
|
||||||
|
Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey);
|
||||||
|
|
||||||
|
// Build initial report (no signature image yet)
|
||||||
|
_report = BuildReport(_pdfBytes, _signatures, capturedSignature: null);
|
||||||
|
|
||||||
|
// Try to restore cached signature
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cachedSignature = await PageDataService.GetCachedSignatureAsync(_receiverUser);
|
||||||
|
if (cachedSignature is not null)
|
||||||
|
{
|
||||||
|
_capturedSignature = cachedSignature;
|
||||||
|
_signerFullName = cachedSignature.FullName;
|
||||||
|
_signerPosition = cachedSignature.Position;
|
||||||
|
_signaturePlace = cachedSignature.Place;
|
||||||
|
_signaturePopupVisible = false;
|
||||||
|
|
||||||
|
// Rebuild with cached signature overlaid
|
||||||
|
_report = BuildReport(_pdfBytes, _signatures, _capturedSignature);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_activeSignatureTab = SignatureTabDraw;
|
||||||
|
_signaturePopupVisible = false;
|
||||||
|
_popupValidationMessage = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Failed to load cached signature for {EnvelopeKey}", EnvelopeKey);
|
||||||
|
_activeSignatureTab = SignatureTabDraw;
|
||||||
|
_signaturePopupVisible = false;
|
||||||
|
_popupValidationMessage = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Fehler beim Laden des Dokuments: {ex.Message}";
|
||||||
|
Logger.LogError(ex, "Unexpected error for {EnvelopeKey}", EnvelopeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Report builder -----
|
||||||
|
/// <summary>
|
||||||
|
/// Builds an XtraReport wrapping the PDF bytes.
|
||||||
|
/// If a signature is captured and there are signature fields, the signature image is
|
||||||
|
/// first burned into the PDF via DevExpress PdfDocumentProcessor, then the modified
|
||||||
|
/// PDF is handed to XRPdfContent with GenerateOwnPages = true so that all pages appear.
|
||||||
|
/// </summary>
|
||||||
|
static XtraReport BuildReport(
|
||||||
|
byte[] pdfBytes,
|
||||||
|
IReadOnlyList<SignatureDto> signatures,
|
||||||
|
SignatureCaptureDto? capturedSignature)
|
||||||
|
{
|
||||||
|
// Always draw placeholder boxes on signature fields so the user knows where to sign.
|
||||||
|
// When a captured signature exists, it will be applied in the Signed page instead.
|
||||||
|
byte[] sourcePdf = pdfBytes;
|
||||||
|
if (signatures.Count > 0)
|
||||||
|
{
|
||||||
|
sourcePdf = DrawSignaturePlaceholders(pdfBytes, signatures);
|
||||||
|
}
|
||||||
|
|
||||||
|
var report = new XtraReport
|
||||||
|
{
|
||||||
|
PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4,
|
||||||
|
Landscape = false,
|
||||||
|
Margins = new System.Drawing.Printing.Margins(0, 0, 0, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
var detail = new DetailBand { HeightF = 0f };
|
||||||
|
report.Bands.Add(detail);
|
||||||
|
|
||||||
|
detail.Controls.Add(new XRPdfContent
|
||||||
|
{
|
||||||
|
Source = sourcePdf,
|
||||||
|
GenerateOwnPages = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uses PdfSharp to draw a visible signature placeholder box on every signature field.
|
||||||
|
/// sig.X / sig.Y come from GetSignaturesAsync(UnitOfLength.Point) → already in PDF points.
|
||||||
|
/// PdfSharp coordinate origin: bottom-left, Y up. Conversion: pdfY = pageH - sigY - sigH
|
||||||
|
/// Signature field size (fixed): 1.77" × 1.96" = 127.44pt × 141.12pt
|
||||||
|
/// </summary>
|
||||||
|
static byte[] DrawSignaturePlaceholders(
|
||||||
|
byte[] pdfBytes,
|
||||||
|
IReadOnlyList<SignatureDto> signatures)
|
||||||
|
{
|
||||||
|
if (signatures.Count == 0) return pdfBytes;
|
||||||
|
|
||||||
|
using var inputMs = new System.IO.MemoryStream(pdfBytes);
|
||||||
|
using var outputMs = new System.IO.MemoryStream();
|
||||||
|
|
||||||
|
var document = PdfSharp.Pdf.IO.PdfReader.Open(
|
||||||
|
inputMs,
|
||||||
|
PdfSharp.Pdf.IO.PdfDocumentOpenMode.Modify);
|
||||||
|
|
||||||
|
const double sigW = 1.77 * 72; // 127.44 pt
|
||||||
|
const double sigH = 1.96 * 72; // 141.12 pt
|
||||||
|
|
||||||
|
foreach (var sig in signatures)
|
||||||
|
{
|
||||||
|
int pageIndex = sig.Page - 1;
|
||||||
|
if (pageIndex < 0 || pageIndex >= document.PageCount) continue;
|
||||||
|
|
||||||
|
var page = document.Pages[pageIndex];
|
||||||
|
|
||||||
|
// PdfSharp XGraphics uses top-left origin, Y down — same as sig.X/sig.Y
|
||||||
|
// No coordinate conversion needed.
|
||||||
|
using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page);
|
||||||
|
|
||||||
|
var rect = new PdfSharp.Drawing.XRect(sig.X, sig.Y, sigW, sigH);
|
||||||
|
|
||||||
|
// Filled semi-transparent rectangle
|
||||||
|
var fillBrush = new PdfSharp.Drawing.XSolidBrush(
|
||||||
|
PdfSharp.Drawing.XColor.FromArgb(40, 60, 80, 160));
|
||||||
|
var borderPen = new PdfSharp.Drawing.XPen(
|
||||||
|
PdfSharp.Drawing.XColor.FromArgb(200, 60, 80, 200), 1.5);
|
||||||
|
|
||||||
|
gfx.DrawRectangle(fillBrush, rect);
|
||||||
|
gfx.DrawRectangle(borderPen, rect);
|
||||||
|
|
||||||
|
// "UNTERSCHRIFT" label centred in the box
|
||||||
|
var font = new PdfSharp.Drawing.XFont("Arial", 9,
|
||||||
|
PdfSharp.Drawing.XFontStyleEx.Bold);
|
||||||
|
var textBrush = new PdfSharp.Drawing.XSolidBrush(
|
||||||
|
PdfSharp.Drawing.XColor.FromArgb(200, 40, 60, 140));
|
||||||
|
|
||||||
|
var textFmt = new PdfSharp.Drawing.XStringFormat
|
||||||
|
{
|
||||||
|
Alignment = PdfSharp.Drawing.XStringAlignment.Center,
|
||||||
|
LineAlignment = PdfSharp.Drawing.XLineAlignment.Center,
|
||||||
|
};
|
||||||
|
gfx.DrawString("UNTERSCHRIFT", font, textBrush, rect, textFmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.Save(outputMs);
|
||||||
|
return outputMs.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Converts a base64 data URL (data:image/...;base64,...) to raw bytes.</summary>
|
||||||
|
static byte[]? DataUrlToBytes(string dataUrl)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var commaIndex = dataUrl.IndexOf(',');
|
||||||
|
if (commaIndex < 0) return null;
|
||||||
|
return Convert.FromBase64String(dataUrl[(commaIndex + 1)..]);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Signature popup handlers -----
|
||||||
|
void OpenSignaturePopup()
|
||||||
|
{
|
||||||
|
_activeSignatureTab = SignatureTabDraw;
|
||||||
|
_signaturePopupVisible = true;
|
||||||
|
_popupValidationMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task OnPopupShownAsync()
|
||||||
|
{
|
||||||
|
await InitializeActiveSignatureTabAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task SetSignatureTabAsync(string tab)
|
||||||
|
{
|
||||||
|
_activeSignatureTab = tab;
|
||||||
|
_popupValidationMessage = null;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
await Task.Delay(50);
|
||||||
|
await InitializeActiveSignatureTabAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task InitializeActiveSignatureTabAsync()
|
||||||
|
{
|
||||||
|
if (_activeSignatureTab == SignatureTabDraw)
|
||||||
|
await JSRuntime.InvokeVoidAsync("receiverSignature.initialize", DrawCanvasId);
|
||||||
|
else if (_activeSignatureTab == SignatureTabText)
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("receiverSignature.initializeTyped", TypedCanvasId);
|
||||||
|
await RenderTypedSignatureAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await JSRuntime.InvokeVoidAsync("receiverSignature.initializeImage", ImageInputId, ImageCanvasId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RenewSignatureAsync()
|
||||||
|
{
|
||||||
|
_popupValidationMessage = null;
|
||||||
|
if (_activeSignatureTab == SignatureTabDraw)
|
||||||
|
await JSRuntime.InvokeVoidAsync("receiverSignature.clear", DrawCanvasId);
|
||||||
|
else if (_activeSignatureTab == SignatureTabText)
|
||||||
|
{
|
||||||
|
_typedSignatureText = string.Empty;
|
||||||
|
await JSRuntime.InvokeVoidAsync("receiverSignature.clearTyped", TypedCanvasId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
await JSRuntime.InvokeVoidAsync("receiverSignature.clearImage", ImageInputId, ImageCanvasId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task OnTypedSignatureChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args)
|
||||||
|
{
|
||||||
|
_typedSignatureText = args.Value?.ToString() ?? string.Empty;
|
||||||
|
await RenderTypedSignatureAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task OnTypedSignatureFontChanged(Microsoft.AspNetCore.Components.ChangeEventArgs args)
|
||||||
|
{
|
||||||
|
_typedSignatureFont = args.Value?.ToString() ?? _typedSignatureFont;
|
||||||
|
await RenderTypedSignatureAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task RenderTypedSignatureAsync()
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("receiverSignature.renderTypedSignature",
|
||||||
|
TypedCanvasId, _typedSignatureText, _typedSignatureFont);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task SaveSignatureAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_signerFullName))
|
||||||
|
{
|
||||||
|
_popupValidationMessage = "Bitte geben Sie Vor- und Nachname ein.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(_signaturePlace))
|
||||||
|
{
|
||||||
|
_popupValidationMessage = "Bitte geben Sie den Ort ein.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var signatureDataUrl = await GetActiveSignatureDataUrlAsync();
|
||||||
|
if (string.IsNullOrWhiteSpace(signatureDataUrl))
|
||||||
|
{
|
||||||
|
_popupValidationMessage = "Die Unterschrift ist erforderlich.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_popupValidationMessage = null;
|
||||||
|
_capturedSignature = new SignatureCaptureDto
|
||||||
|
{
|
||||||
|
DataUrl = signatureDataUrl,
|
||||||
|
FullName = _signerFullName.Trim(),
|
||||||
|
Position = _signerPosition.Trim(),
|
||||||
|
Place = _signaturePlace.Trim(),
|
||||||
|
};
|
||||||
|
_signaturePopupVisible = false;
|
||||||
|
|
||||||
|
// Store signature in IMemoryCache with a Guid key (1 minute TTL)
|
||||||
|
var sid = Guid.NewGuid().ToString("N");
|
||||||
|
MemoryCache.Set(
|
||||||
|
sid,
|
||||||
|
_capturedSignature,
|
||||||
|
TimeSpan.FromMinutes(1));
|
||||||
|
|
||||||
|
Logger.LogInformation(
|
||||||
|
"Signature cached with sid={Sid} for envelope {EnvelopeKey}", sid, EnvelopeKey);
|
||||||
|
|
||||||
|
// Null the report → DxReportViewer removed from DOM → no crash on dispose
|
||||||
|
_report = null;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
await Task.Delay(50);
|
||||||
|
|
||||||
|
// Navigate — forceLoad:true for clean circuit teardown
|
||||||
|
Navigation.NavigateTo(
|
||||||
|
$"/envelope/{Uri.EscapeDataString(EnvelopeKey!)}/signed?sid={sid}",
|
||||||
|
forceLoad: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<string?> GetActiveSignatureDataUrlAsync()
|
||||||
|
{
|
||||||
|
if (_activeSignatureTab == SignatureTabDraw)
|
||||||
|
return await JSRuntime.InvokeAsync<string?>("receiverSignature.getDataUrl", DrawCanvasId);
|
||||||
|
|
||||||
|
if (_activeSignatureTab == SignatureTabText)
|
||||||
|
{
|
||||||
|
await RenderTypedSignatureAsync();
|
||||||
|
return await JSRuntime.InvokeAsync<string?>("receiverSignature.getTypedDataUrl", TypedCanvasId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await JSRuntime.InvokeAsync<string?>("receiverSignature.getImageDataUrl", ImageCanvasId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Disposal -----
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_report?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
@page "/envelope/{EnvelopeKey}/signed"
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@using DevExpress.Blazor.Reporting
|
||||||
|
@using DevExpress.XtraReports.UI
|
||||||
|
@using EnvelopeGenerator.Server.Client.Models
|
||||||
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
|
@using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver
|
||||||
|
@using Microsoft.Extensions.Caching.Memory
|
||||||
|
@using System.Security.Claims
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||||
|
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
||||||
|
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||||
|
@inject AppVersionService AppVersion
|
||||||
|
@inject IMemoryCache MemoryCache
|
||||||
|
@inject ILogger<EnvelopeReceiverReportSignedPage> Logger
|
||||||
|
@implements IDisposable
|
||||||
|
|
||||||
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
|
<link href="_content/DevExpress.Blazor.Reporting.Viewer/css/dx-blazor-reporting-components.bs5.css" rel="stylesheet" />
|
||||||
|
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
|
||||||
|
|
||||||
|
<div class="envelope-viewer-layout">
|
||||||
|
<div class="envelope-action-bar">
|
||||||
|
<div class="envelope-action-bar__inner" style="flex-direction: column; align-items: stretch; padding: 0.35rem 1.5rem; gap: 0.35rem;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; gap: 1rem;">
|
||||||
|
<div style="flex: 0 1 auto; min-width: 0; display: flex; align-items: center; gap: 0.75rem;">
|
||||||
|
@if (_envelopeReceiver is not null)
|
||||||
|
{
|
||||||
|
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||||
|
@(_envelopeReceiver.Envelope?.Title ?? "Dokument")
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_envelopeReceiver.Envelope?.User?.FullName))
|
||||||
|
{
|
||||||
|
<span style="font-size: 0.7rem; color: #6b7280; white-space: nowrap;">
|
||||||
|
Von <span style="font-weight: 500; color: #374151;">@_envelopeReceiver.Envelope.User.FullName</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937;">Signiertes Dokument</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Right: Submit button *@
|
||||||
|
<div style="flex: 0 0 auto;">
|
||||||
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change pdf-toolbar__btn--signature-change-active"
|
||||||
|
@onclick="OpenSubmitConfirmPopup"
|
||||||
|
disabled="@_isLoggingOut"
|
||||||
|
title="Abschließen"
|
||||||
|
style="flex-shrink: 0;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="pdf-toolbar__btn-text">Abschließen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="envelope-content" style="padding: 0; overflow: hidden;">
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-center align-items-center h-100">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="spinner-border text-white mb-3" style="width: 3.5rem; height: 3.5rem;" role="status">
|
||||||
|
<span class="visually-hidden">Lädt...</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-white fw-semibold">Dokument wird geladen...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_errorMessage is not null)
|
||||||
|
{
|
||||||
|
<div class="error-container">
|
||||||
|
<div class="alert alert-danger shadow-lg">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" class="me-3 flex-shrink-0" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
|
||||||
|
<path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 4.995z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-2">Fehler</h5>
|
||||||
|
<p class="mb-0">@_errorMessage</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_report is not null)
|
||||||
|
{
|
||||||
|
<DxReportViewer Report="_report" RootCssClasses="w-100 h-100" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Submit confirmation popup *@
|
||||||
|
<DxPopup @bind-Visible="_submitConfirmVisible"
|
||||||
|
HeaderText="Unterschrift bestätigen"
|
||||||
|
Width="440px"
|
||||||
|
MaxWidth="95vw"
|
||||||
|
ShowFooter="true"
|
||||||
|
CloseOnOutsideClick="false"
|
||||||
|
ShowCloseButton="false"
|
||||||
|
CloseOnEscape="false">
|
||||||
|
<BodyContentTemplate>
|
||||||
|
<div style="display: flex; align-items: flex-start; gap: 1rem; padding: 0.5rem 0;">
|
||||||
|
<div style="flex-shrink: 0; width: 40px; height: 40px; background: #d1fae5; border-radius: 50%; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#065f46" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p style="margin: 0 0 0.4rem; font-weight: 600; color: #1f2937; font-size: 0.95rem;">
|
||||||
|
Möchten Sie das Dokument verbindlich unterschreiben?
|
||||||
|
</p>
|
||||||
|
<p style="margin: 0; color: #6b7280; font-size: 0.85rem; line-height: 1.5;">
|
||||||
|
Diese Aktion kann nicht rückgängig gemacht werden. Mit der Bestätigung erklären Sie, das oben angezeigte Dokument elektronisch unterzeichnet zu haben. Das unterzeichnete Dokument wird anschließend an alle beteiligten Parteien übermittelt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BodyContentTemplate>
|
||||||
|
<FooterContentTemplate>
|
||||||
|
<div class="d-flex gap-2 justify-content-end w-100" style="padding: 0.5rem 0;">
|
||||||
|
<button class="btn btn-outline-secondary"
|
||||||
|
@onclick="() => _submitConfirmVisible = false"
|
||||||
|
style="border-radius: 6px; padding: 0.5rem 1.25rem; font-weight: 500;">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
@onclick="SubmitAndLogoutAsync"
|
||||||
|
disabled="@_isLoggingOut"
|
||||||
|
style="background: linear-gradient(135deg, #059669 0%, #047857 100%); border: none; border-radius: 6px; padding: 0.5rem 1.5rem; font-weight: 600; box-shadow: 0 2px 4px rgba(5, 150, 105, 0.3);">
|
||||||
|
@if (_isLoggingOut)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||||
|
}
|
||||||
|
Abschließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</FooterContentTemplate>
|
||||||
|
</DxPopup>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public string? EnvelopeKey { get; set; }
|
||||||
|
|
||||||
|
[SupplyParameterFromQuery(Name = "sid")]
|
||||||
|
public string? Sid { get; set; }
|
||||||
|
|
||||||
|
bool _isLoading = true;
|
||||||
|
string? _errorMessage;
|
||||||
|
ClaimsPrincipal? _receiverUser;
|
||||||
|
EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto? _envelopeReceiver;
|
||||||
|
IReadOnlyList<SignatureDto> _signatures = [];
|
||||||
|
XtraReport? _report;
|
||||||
|
SignatureCaptureDto? _sig;
|
||||||
|
|
||||||
|
// ----- Submit / logout state -----
|
||||||
|
bool _isLoggingOut = false;
|
||||||
|
bool _submitConfirmVisible = false;
|
||||||
|
|
||||||
|
void OpenSubmitConfirmPopup() => _submitConfirmVisible = true;
|
||||||
|
|
||||||
|
async Task SubmitAndLogoutAsync()
|
||||||
|
{
|
||||||
|
if (_isLoggingOut) return;
|
||||||
|
_isLoggingOut = true;
|
||||||
|
_submitConfirmVisible = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
await AuthService.LogoutEnvelopeReceiverAsync(EnvelopeKey!);
|
||||||
|
Navigation.NavigateTo("/", forceLoad: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||||
|
{
|
||||||
|
_errorMessage = "Envelope-Schlüssel fehlt.";
|
||||||
|
_isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_receiverUser = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
|
||||||
|
if (_receiverUser is null)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read signature from IMemoryCache
|
||||||
|
if (!string.IsNullOrWhiteSpace(Sid)
|
||||||
|
&& MemoryCache.TryGetValue(Sid, out SignatureCaptureDto? cached)
|
||||||
|
&& cached is not null)
|
||||||
|
{
|
||||||
|
_sig = cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss or missing sid — redirect back to report page
|
||||||
|
if (_sig is null)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(
|
||||||
|
"[SignedPage] Cache miss or no sid={Sid} for {EnvelopeKey}, redirecting to report page.",
|
||||||
|
Sid, EnvelopeKey);
|
||||||
|
Navigation.NavigateTo(
|
||||||
|
$"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/report",
|
||||||
|
forceLoad: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pdfBytes = await PageDataService.GetDocumentAsync(_receiverUser);
|
||||||
|
if (pdfBytes is not { Length: > 0 })
|
||||||
|
{
|
||||||
|
_errorMessage = "Dokument konnte nicht geladen werden.";
|
||||||
|
_isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
|
||||||
|
_signatures = await PageDataService.GetSignaturesAsync(_receiverUser);
|
||||||
|
|
||||||
|
// Burn signature image + info onto PDF via PdfSharp
|
||||||
|
if (_sig is not null && _signatures.Count > 0)
|
||||||
|
pdfBytes = DrawSignaturesOnPdf(pdfBytes, _signatures, _sig);
|
||||||
|
|
||||||
|
var report = new XtraReport
|
||||||
|
{
|
||||||
|
PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4,
|
||||||
|
Landscape = false,
|
||||||
|
Margins = new System.Drawing.Printing.Margins(0, 0, 0, 0),
|
||||||
|
};
|
||||||
|
var detail = new DetailBand();
|
||||||
|
report.Bands.Add(detail);
|
||||||
|
detail.Controls.Add(new XRPdfContent
|
||||||
|
{
|
||||||
|
Source = pdfBytes,
|
||||||
|
GenerateOwnPages = true,
|
||||||
|
});
|
||||||
|
_report = report;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Fehler: {ex.Message}";
|
||||||
|
Logger.LogError(ex, "Error loading signed page for {EnvelopeKey}", EnvelopeKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isLoading = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender) return;
|
||||||
|
|
||||||
|
if (_sig is not null)
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("console.log",
|
||||||
|
$"[SignedPage] sid={Sid} | FullName={_sig.FullName} | Place={_sig.Place} | Position={_sig.Position} | DataUrl.Length={_sig.DataUrl?.Length ?? 0}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("console.log",
|
||||||
|
$"[SignedPage] Cache miss or no sid. sid={Sid}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_report?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- PDF signature rendering -----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uses PdfSharp to burn the captured signature onto the PDF at each signature field.
|
||||||
|
/// Layout per field (top-left origin, Y down, units = PDF points):
|
||||||
|
/// [top 65%] signature image
|
||||||
|
/// [separator line]
|
||||||
|
/// [bottom 35%] FullName (bold) / Position (optional) / Place, Date
|
||||||
|
/// </summary>
|
||||||
|
static byte[] DrawSignaturesOnPdf(
|
||||||
|
byte[] pdfBytes,
|
||||||
|
IReadOnlyList<SignatureDto> signatures,
|
||||||
|
SignatureCaptureDto sig)
|
||||||
|
{
|
||||||
|
var imgBytes = DataUrlToBytes(sig.DataUrl);
|
||||||
|
if (imgBytes is not { Length: > 0 }) return pdfBytes;
|
||||||
|
|
||||||
|
using var inputMs = new System.IO.MemoryStream(pdfBytes);
|
||||||
|
using var outputMs = new System.IO.MemoryStream();
|
||||||
|
|
||||||
|
var document = PdfSharp.Pdf.IO.PdfReader.Open(
|
||||||
|
inputMs, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Modify);
|
||||||
|
|
||||||
|
const double sigW = 1.77 * 72; // 127.44 pt
|
||||||
|
const double sigH = 1.96 * 72; // 141.12 pt
|
||||||
|
const double imgRatio = 0.52; // top 52% = image
|
||||||
|
const double lineH = 11.5; // fixed row height matching font size (bold 7.5pt + normal 6.5pt)
|
||||||
|
const double bgPad = 3.0; // background box padding around content (pt)
|
||||||
|
|
||||||
|
var black = PdfSharp.Drawing.XColor.FromArgb(255, 20, 20, 20);
|
||||||
|
var darkGray = PdfSharp.Drawing.XColor.FromArgb(255, 80, 80, 80);
|
||||||
|
var lineColor = PdfSharp.Drawing.XColor.FromArgb(180, 100, 100, 120);
|
||||||
|
|
||||||
|
var bgColor = PdfSharp.Drawing.XColor.FromArgb(255, 255, 253, 240);
|
||||||
|
var bgBrush = new PdfSharp.Drawing.XSolidBrush(bgColor);
|
||||||
|
|
||||||
|
var fontBold = new PdfSharp.Drawing.XFont("Arial", 7.5, PdfSharp.Drawing.XFontStyleEx.Bold);
|
||||||
|
var fontNormal = new PdfSharp.Drawing.XFont("Arial", 6.5, PdfSharp.Drawing.XFontStyleEx.Regular);
|
||||||
|
var linePen = new PdfSharp.Drawing.XPen(lineColor, 0.5);
|
||||||
|
|
||||||
|
var fmtLeft = new PdfSharp.Drawing.XStringFormat
|
||||||
|
{
|
||||||
|
Alignment = PdfSharp.Drawing.XStringAlignment.Near,
|
||||||
|
LineAlignment = PdfSharp.Drawing.XLineAlignment.Near,
|
||||||
|
};
|
||||||
|
|
||||||
|
var date = DateTime.Now.ToString("dd.MM.yyyy");
|
||||||
|
|
||||||
|
foreach (var field in signatures)
|
||||||
|
{
|
||||||
|
int pageIndex = field.Page - 1;
|
||||||
|
if (pageIndex < 0 || pageIndex >= document.PageCount) continue;
|
||||||
|
|
||||||
|
var page = document.Pages[pageIndex];
|
||||||
|
|
||||||
|
using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page);
|
||||||
|
|
||||||
|
double x = field.X;
|
||||||
|
double y = field.Y;
|
||||||
|
|
||||||
|
// --- Calculate layout positions first (needed for bg rect) ---
|
||||||
|
double imgH = sigH * imgRatio;
|
||||||
|
double lineY = y + imgH + 1.0; // 1pt gap between image and separator
|
||||||
|
double textY = lineY + 1.5; // 1.5pt gap below separator line
|
||||||
|
double padding = 3;
|
||||||
|
|
||||||
|
// Row 1: FullName
|
||||||
|
double row1Y = textY;
|
||||||
|
// Row 2: Position (optional)
|
||||||
|
double row2Y = row1Y + lineH;
|
||||||
|
// Row 3: Place, Date — immediately after row2 regardless of position
|
||||||
|
double row3Y = !string.IsNullOrWhiteSpace(sig.Position) ? row2Y + lineH : row2Y;
|
||||||
|
double contentBottom = row3Y + lineH;
|
||||||
|
|
||||||
|
// --- Background rectangle sized to actual content (not full sigH) ---
|
||||||
|
var bgRect = new PdfSharp.Drawing.XRect(
|
||||||
|
x - bgPad,
|
||||||
|
y - bgPad,
|
||||||
|
sigW + bgPad * 2,
|
||||||
|
(contentBottom - y) + bgPad * 2);
|
||||||
|
gfx.DrawRectangle(bgBrush, bgRect);
|
||||||
|
|
||||||
|
// --- Image area ---
|
||||||
|
var imgRect = new PdfSharp.Drawing.XRect(x, y, sigW, imgH);
|
||||||
|
using var imgStream = new System.IO.MemoryStream(imgBytes);
|
||||||
|
var xImg = PdfSharp.Drawing.XImage.FromStream(imgStream);
|
||||||
|
gfx.DrawImage(xImg, imgRect);
|
||||||
|
|
||||||
|
// --- Separator line ---
|
||||||
|
gfx.DrawLine(linePen, x + 2, lineY, x + sigW - 2, lineY);
|
||||||
|
|
||||||
|
// --- Text rows ---
|
||||||
|
// Row 1: FullName (bold)
|
||||||
|
var nameRect = new PdfSharp.Drawing.XRect(x + padding, row1Y, sigW - padding * 2, lineH);
|
||||||
|
gfx.DrawString(sig.FullName, fontBold, new PdfSharp.Drawing.XSolidBrush(black), nameRect, fmtLeft);
|
||||||
|
|
||||||
|
// Row 2: Position (optional)
|
||||||
|
if (!string.IsNullOrWhiteSpace(sig.Position))
|
||||||
|
{
|
||||||
|
var posRect = new PdfSharp.Drawing.XRect(x + padding, row2Y, sigW - padding * 2, lineH);
|
||||||
|
gfx.DrawString(sig.Position, fontNormal, new PdfSharp.Drawing.XSolidBrush(darkGray), posRect, fmtLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row 3: Place, Date
|
||||||
|
var placeDate = $"{sig.Place}, {date}";
|
||||||
|
var dateRect = new PdfSharp.Drawing.XRect(x + padding, row3Y, sigW - padding * 2, lineH);
|
||||||
|
gfx.DrawString(placeDate, fontNormal, new PdfSharp.Drawing.XSolidBrush(darkGray), dateRect, fmtLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.Save(outputMs);
|
||||||
|
return outputMs.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[]? DataUrlToBytes(string? dataUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(dataUrl)) return null;
|
||||||
|
var comma = dataUrl.IndexOf(',');
|
||||||
|
if (comma < 0) return null;
|
||||||
|
return Convert.FromBase64String(dataUrl[(comma + 1)..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,683 @@
|
|||||||
|
@page "/sender/editor"
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@using DevExpress.Blazor.PdfViewer
|
||||||
|
@using DevExpress.Blazor.Reporting.Models
|
||||||
|
@using DevExpress.Blazor
|
||||||
|
@using EnvelopeGenerator.Server.Client.Services
|
||||||
|
@using EnvelopeGenerator.Server.Services
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@inject IJSRuntime JSRuntime
|
||||||
|
@inject AppVersionService AppVersion
|
||||||
|
@inject ILogger<EnvelopeSenderEditorPage> Logger
|
||||||
|
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
||||||
|
|
||||||
|
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||||
|
<link href="@AppVersion.GetVersionedUrl("css/envelope-viewer.css")" rel="stylesheet" />
|
||||||
|
<script src="@AppVersion.GetVersionedUrl("js/envelope-editor.js")"></script>
|
||||||
|
|
||||||
|
<div class="envelope-viewer-layout">
|
||||||
|
|
||||||
|
@* ── Action Bar ── *@
|
||||||
|
<div class="envelope-action-bar">
|
||||||
|
<div class="envelope-action-bar__inner"
|
||||||
|
style="flex-direction: row; align-items: center; padding: 0.35rem 1.5rem; gap: 0.75rem;">
|
||||||
|
|
||||||
|
@* Left: Title *@
|
||||||
|
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: stretch; gap: 0.75rem;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.75rem; min-width: 0; flex-wrap: wrap;">
|
||||||
|
<div style="font-size: 0.9rem; font-weight: 600; color: #1f2937; white-space: nowrap;">
|
||||||
|
Neues Dokument
|
||||||
|
</div>
|
||||||
|
@if (_pdfLoaded)
|
||||||
|
{
|
||||||
|
<span style="font-size: 0.7rem; color: #6b7280;">@_fileName</span>
|
||||||
|
@if (_signatureFields.Count > 0)
|
||||||
|
{
|
||||||
|
<span style="display: inline-flex; align-items: center; padding: 0.125rem 0.4rem;
|
||||||
|
background: #ede9fe; border-radius: 0.25rem; color: #6d28d9;
|
||||||
|
font-weight: 500; font-size: 0.7rem; white-space: nowrap;">
|
||||||
|
@_signatureFields.Count Signaturfeld@(_signatureFields.Count != 1 ? "er" : "")
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sender-receivers-panel">
|
||||||
|
<div class="sender-receivers-panel__header">
|
||||||
|
<div class="sender-receivers-panel__title-wrap">
|
||||||
|
<span class="sender-receivers-panel__title">Empfänger</span>
|
||||||
|
<span class="sender-receivers-panel__count">@_receivers.Count</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn"
|
||||||
|
@onclick="OpenAddReceiverPopup"
|
||||||
|
title="Empfänger hinzufügen">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 1a.5.5 0 0 1 .5.5v6h6a.5.5 0 0 1 0 1h-6v6a.5.5 0 0 1-1 0v-6h-6a.5.5 0 0 1 0-1h6v-6A.5.5 0 0 1 8 1z" />
|
||||||
|
</svg>
|
||||||
|
<span class="pdf-toolbar__btn-text">Empfänger hinzufügen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_receivers.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="sender-receivers-panel__empty">
|
||||||
|
Noch keine Empfänger hinzugefügt.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="sender-receivers-list">
|
||||||
|
@foreach (var receiver in _receivers)
|
||||||
|
{
|
||||||
|
<div class="sender-receiver-chip">
|
||||||
|
<div class="sender-receiver-chip__body">
|
||||||
|
<div class="sender-receiver-chip__name">@receiver.FullName</div>
|
||||||
|
<div class="sender-receiver-chip__email">@receiver.Email</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(receiver.PhoneNumber))
|
||||||
|
{
|
||||||
|
<div class="sender-receiver-chip__phone">@receiver.PhoneNumber</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--signature-change sender-toolbar-action-btn sender-toolbar-action-btn--compact"
|
||||||
|
@onclick="() => AddSignatureForReceiver(receiver)"
|
||||||
|
title="Signatur hinzufügen">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" />
|
||||||
|
</svg>
|
||||||
|
<span class="pdf-toolbar__btn-text">Signatur hinzufügen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Right: Buttons *@
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0;">
|
||||||
|
|
||||||
|
@* PDF Upload *@
|
||||||
|
<label class="pdf-toolbar__btn"
|
||||||
|
title="PDF hochladen"
|
||||||
|
style="cursor: pointer; display: inline-flex; align-items: center; gap: 0.3rem; padding: 0.3rem 0.75rem; font-size: 0.75rem; font-weight: 500;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
|
||||||
|
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
|
||||||
|
</svg>
|
||||||
|
PDF hochladen
|
||||||
|
<InputFile OnChange="OnPdfFileSelectedAsync"
|
||||||
|
accept=".pdf"
|
||||||
|
style="display: none;" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
@if (_pdfLoaded)
|
||||||
|
{
|
||||||
|
@* Toggle placement mode *@
|
||||||
|
<button class="pdf-toolbar__btn @(_placementMode ? "pdf-toolbar__btn--signature-change-active" : "pdf-toolbar__btn--signature-change")"
|
||||||
|
@onclick="TogglePlacementMode"
|
||||||
|
title="@(_placementMode ? "Platzierungsmodus beenden" : "Signaturfeld hinzufügen")">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10z" />
|
||||||
|
</svg>
|
||||||
|
<span class="pdf-toolbar__btn-text">
|
||||||
|
@(_placementMode ? "Abbrechen" : "+ Signaturfeld")
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@* Clear all fields *@
|
||||||
|
@if (_signatureFields.Count > 0)
|
||||||
|
{
|
||||||
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--reset"
|
||||||
|
@onclick="ClearAllFields"
|
||||||
|
title="Alle Signaturfelder entfernen">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z" />
|
||||||
|
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1 0-2h3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1h3a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3h11V2h-11v1z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Save *@
|
||||||
|
<button class="pdf-toolbar__btn pdf-toolbar__btn--success"
|
||||||
|
@onclick="SaveAsync"
|
||||||
|
title="Speichern"
|
||||||
|
style="background: linear-gradient(135deg,#059669,#047857); color:#fff; border-radius:6px; padding:0.3rem 0.75rem; font-size:0.75rem; font-weight:600; border:none;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
||||||
|
</svg>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Placement mode hint bar *@
|
||||||
|
@if (_placementMode)
|
||||||
|
{
|
||||||
|
<div style="background: #4F46E5; color: white; font-size: 0.75rem; font-weight: 500;
|
||||||
|
padding: 0.3rem 1.5rem; text-align: center; letter-spacing: 0.01em;">
|
||||||
|
📌 Klicken Sie auf die gewünschte Stelle im Dokument, um ein Signaturfeld zu platzieren.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Content ── *@
|
||||||
|
<div class="envelope-content" style="padding: 0; overflow: hidden;">
|
||||||
|
@if (!_pdfLoaded)
|
||||||
|
{
|
||||||
|
@* Empty state *@
|
||||||
|
<div class="d-flex justify-content-center align-items-center h-100">
|
||||||
|
<div class="text-center" style="max-width: 420px;">
|
||||||
|
<div style="width: 72px; height: 72px; background: rgba(255,255,255,0.15);
|
||||||
|
border-radius: 50%; display: flex; align-items: center;
|
||||||
|
justify-content: center; margin: 0 auto 1.25rem;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" fill="white" viewBox="0 0 16 16">
|
||||||
|
<path d="M14 4.5V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h5.5L14 4.5zm-3 0A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4.5h-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h5 class="text-white mb-2">Kein Dokument geladen</h5>
|
||||||
|
<p class="text-white mb-4" style="opacity: 0.75; font-size: 0.85rem;">
|
||||||
|
Laden Sie eine PDF-Datei hoch, um Signaturfelder zu platzieren.
|
||||||
|
</p>
|
||||||
|
<label class="btn btn-light btn-sm" style="cursor: pointer; font-weight: 500;">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="me-1" viewBox="0 0 16 16">
|
||||||
|
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5z" />
|
||||||
|
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708l3-3z" />
|
||||||
|
</svg>
|
||||||
|
PDF hochladen
|
||||||
|
<InputFile OnChange="OnPdfFileSelectedAsync" accept=".pdf" style="display: none;" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_errorMessage is not null)
|
||||||
|
{
|
||||||
|
<div class="error-container">
|
||||||
|
<div class="alert alert-danger shadow-lg m-4">
|
||||||
|
<strong>Fehler:</strong> @_errorMessage
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@* PDF viewer + overlay wrapper *@
|
||||||
|
<div id="pdf-editor-wrapper" class="pdf-editor-wrapper"
|
||||||
|
style="position: relative; width: 100%; height: 100%; overflow: auto;">
|
||||||
|
|
||||||
|
@* DxPdfViewer — zoom fixed to 1.0 for reliable coordinate mapping *@
|
||||||
|
<DxPdfViewer @ref="_pdfViewer"
|
||||||
|
DocumentContent="@_pdfBytes"
|
||||||
|
ZoomLevel="1.0"
|
||||||
|
IsSinglePagePreview="false"
|
||||||
|
CssClass="sender-editor-pdf-viewer" />
|
||||||
|
|
||||||
|
@* Transparent overlay for click capture (active only in placement mode) *@
|
||||||
|
<div id="pdf-editor-overlay"
|
||||||
|
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||||
|
z-index: 20;
|
||||||
|
pointer-events: @(_placementMode ? "auto" : "none");
|
||||||
|
cursor: @(_placementMode ? "crosshair" : "default");"
|
||||||
|
@onclick="OnOverlayClickAsync">
|
||||||
|
|
||||||
|
@* Render placed signature field placeholders *@
|
||||||
|
@foreach (var field in _signatureFields)
|
||||||
|
{
|
||||||
|
var f = field; // capture for lambda
|
||||||
|
<div style="position: absolute;
|
||||||
|
left: @(f.DisplayX.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))px;
|
||||||
|
top: @(f.DisplayY.ToString("F1", System.Globalization.CultureInfo.InvariantCulture))px;
|
||||||
|
width: @(SigDisplayW)px;
|
||||||
|
height: @(SigDisplayH)px;
|
||||||
|
border: 2px dashed #4F46E5;
|
||||||
|
background: rgba(79,70,229,0.10);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: default;"
|
||||||
|
@onclick:stopPropagation="true">
|
||||||
|
<span style="font-size: 0.6rem; font-weight: 700; color: #4F46E5;
|
||||||
|
letter-spacing: 0.05em; text-transform: uppercase;">
|
||||||
|
Unterschrift
|
||||||
|
</span>
|
||||||
|
<span style="font-size: 0.55rem; color: #6d28d9; opacity: 0.8;">
|
||||||
|
S.@f.Page
|
||||||
|
</span>
|
||||||
|
<button @onclick="() => RemoveField(f)"
|
||||||
|
style="position: absolute; top: 2px; right: 4px;
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
color: #6d28d9; font-size: 0.75rem; line-height: 1;
|
||||||
|
padding: 0; opacity: 0.7;"
|
||||||
|
title="Entfernen">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DxPopup @bind-Visible="_receiverPopupVisible"
|
||||||
|
HeaderText="Empfänger hinzufügen"
|
||||||
|
ShowFooter="true"
|
||||||
|
CloseOnEscape="true"
|
||||||
|
Width="720px"
|
||||||
|
CssClass="sender-receiver-popup">
|
||||||
|
<BodyContentTemplate Context="popupContext">
|
||||||
|
<div class="sender-receiver-popup__body">
|
||||||
|
<div class="sender-receiver-popup__form-grid">
|
||||||
|
<div class="sender-receiver-popup__field">
|
||||||
|
<div class="sender-receiver-popup__label">Vor- und Nachname</div>
|
||||||
|
<DxTextBox Text="@_receiverDraftName"
|
||||||
|
TextChanged="OnReceiverNameChanged"
|
||||||
|
NullText="Max Mustermann"
|
||||||
|
ClearButtonDisplayMode="DataEditorClearButtonDisplayMode.Auto"
|
||||||
|
BindValueMode="BindValueMode.OnInput"
|
||||||
|
CssClass="w-100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sender-receiver-popup__field">
|
||||||
|
<div class="sender-receiver-popup__label">Telefonnummer (optional)</div>
|
||||||
|
<DxTextBox Text="@_receiverDraftPhoneNumber"
|
||||||
|
TextChanged="OnReceiverPhoneNumberChanged"
|
||||||
|
NullText="+49 170 1234567"
|
||||||
|
ClearButtonDisplayMode="DataEditorClearButtonDisplayMode.Auto"
|
||||||
|
BindValueMode="BindValueMode.OnInput"
|
||||||
|
CssClass="w-100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sender-receiver-popup__field">
|
||||||
|
<div class="sender-receiver-popup__label">E-Mail-Adresse</div>
|
||||||
|
<div @onkeydown="OnReceiverEmailKeyDownAsync">
|
||||||
|
<DxTextBox Text="@_receiverDraftEmail"
|
||||||
|
TextChanged="OnReceiverEmailTextChangedAsync"
|
||||||
|
NullText="name@beispiel.de"
|
||||||
|
ClearButtonDisplayMode="DataEditorClearButtonDisplayMode.Auto"
|
||||||
|
BindValueMode="BindValueMode.OnInput"
|
||||||
|
CssClass="w-100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sender-receiver-popup__suggestions-shell">
|
||||||
|
@if (_receiverEmailSuggestions.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="sender-receiver-popup__suggestions">
|
||||||
|
<DxListBox TData="string"
|
||||||
|
TValue="string"
|
||||||
|
Data="@_receiverEmailSuggestions"
|
||||||
|
Value="@_selectedReceiverEmailSuggestion"
|
||||||
|
ValueChanged="OnReceiverEmailSuggestionSelectedAsync"
|
||||||
|
SelectionMode="ListBoxSelectionMode.Single"
|
||||||
|
CssClass="w-100" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sender-receiver-popup__hint">
|
||||||
|
Bereits verwendete E-Mail-Adressen werden bei der Eingabe vorgeschlagen.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_isReceiverEmailSearchRunning)
|
||||||
|
{
|
||||||
|
<div class="sender-receiver-popup__loading">E-Mail-Vorschläge werden geladen…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(_receiverPopupValidationMessage))
|
||||||
|
{
|
||||||
|
<div class="sender-receiver-popup__validation">@_receiverPopupValidationMessage</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</BodyContentTemplate>
|
||||||
|
<FooterContentTemplate>
|
||||||
|
<div class="sender-receiver-popup__footer">
|
||||||
|
<DxButton Text="Abbrechen"
|
||||||
|
RenderStyle="ButtonRenderStyle.Secondary"
|
||||||
|
Click="CloseAddReceiverPopup" />
|
||||||
|
<DxButton Text="Empfänger hinzufügen"
|
||||||
|
RenderStyle="ButtonRenderStyle.Primary"
|
||||||
|
Click="SaveReceiverAsync" />
|
||||||
|
</div>
|
||||||
|
</FooterContentTemplate>
|
||||||
|
</DxPopup>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
// ── Constants ──
|
||||||
|
// Signature field size in PDF points (fixed): 1.77" × 1.96" × 72 pt/inch
|
||||||
|
const double SigWidthPt = 1.77 * 72; // 127.44 pt
|
||||||
|
const double SigHeightPt = 1.96 * 72; // 141.12 pt
|
||||||
|
|
||||||
|
// Display size of the overlay placeholder (pixels at zoom=1.0).
|
||||||
|
// At zoom=1.0, 1 CSS px ≈ 1 pt in the DxPdfViewer render.
|
||||||
|
const double SigDisplayW = SigWidthPt;
|
||||||
|
const double SigDisplayH = SigHeightPt;
|
||||||
|
|
||||||
|
// ── State ──
|
||||||
|
DxPdfViewer? _pdfViewer;
|
||||||
|
byte[]? _pdfBytes;
|
||||||
|
bool _pdfLoaded = false;
|
||||||
|
string _fileName = string.Empty;
|
||||||
|
string? _errorMessage;
|
||||||
|
bool _placementMode = false;
|
||||||
|
List<SignatureFieldDraft> _signatureFields = [];
|
||||||
|
List<ReceiverDraft> _receivers = [];
|
||||||
|
bool _receiverPopupVisible;
|
||||||
|
string _receiverDraftName = string.Empty;
|
||||||
|
string _receiverDraftEmail = string.Empty;
|
||||||
|
string _receiverDraftPhoneNumber = string.Empty;
|
||||||
|
string? _selectedReceiverEmailSuggestion;
|
||||||
|
string? _receiverPopupValidationMessage;
|
||||||
|
bool _isReceiverEmailSearchRunning;
|
||||||
|
List<string> _receiverEmailSuggestions = [];
|
||||||
|
int _receiverEmailSearchVersion;
|
||||||
|
static readonly System.ComponentModel.DataAnnotations.EmailAddressAttribute ReceiverEmailValidator = new();
|
||||||
|
|
||||||
|
// ── PDF upload ──
|
||||||
|
async Task OnPdfFileSelectedAsync(InputFileChangeEventArgs e)
|
||||||
|
{
|
||||||
|
_errorMessage = null;
|
||||||
|
var file = e.File;
|
||||||
|
if (file is null) return;
|
||||||
|
|
||||||
|
if (!file.Name.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_errorMessage = "Bitte wählen Sie eine PDF-Datei aus.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Max 50 MB
|
||||||
|
const long maxBytes = 50 * 1024 * 1024;
|
||||||
|
using var ms = new System.IO.MemoryStream();
|
||||||
|
await file.OpenReadStream(maxBytes).CopyToAsync(ms);
|
||||||
|
_pdfBytes = ms.ToArray();
|
||||||
|
_fileName = file.Name;
|
||||||
|
_pdfLoaded = true;
|
||||||
|
_signatureFields.Clear();
|
||||||
|
_placementMode = false;
|
||||||
|
|
||||||
|
Logger.LogInformation("PDF loaded: {Name} ({Size} bytes)", _fileName, _pdfBytes.Length);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_errorMessage = $"Fehler beim Laden der Datei: {ex.Message}";
|
||||||
|
Logger.LogError(ex, "Failed to load PDF file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Placement mode ──
|
||||||
|
void TogglePlacementMode() => _placementMode = !_placementMode;
|
||||||
|
|
||||||
|
void ClearAllFields()
|
||||||
|
{
|
||||||
|
_signatureFields.Clear();
|
||||||
|
_placementMode = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoveField(SignatureFieldDraft field) => _signatureFields.Remove(field);
|
||||||
|
|
||||||
|
// ── Overlay click → add signature field ──
|
||||||
|
async Task OnOverlayClickAsync(MouseEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_placementMode) return;
|
||||||
|
|
||||||
|
// Get overlay container bounds via JS
|
||||||
|
var coords = await JSRuntime.InvokeAsync<OverlayCoords>(
|
||||||
|
"envelopeEditor.getClickCoords", "pdf-editor-overlay",
|
||||||
|
e.ClientX, e.ClientY);
|
||||||
|
|
||||||
|
if (coords is null) return;
|
||||||
|
|
||||||
|
// At zoom=1.0: container pixels ≈ PDF display pixels.
|
||||||
|
// DxPdfViewer renders at 96 dpi by default; PDF points = 72 dpi.
|
||||||
|
// Scale factor: 96/72 = 1.333 → px / 1.333 = pt
|
||||||
|
const double pxToPt = 72.0 / 96.0;
|
||||||
|
|
||||||
|
double xPt = coords.RelX * pxToPt;
|
||||||
|
double yPt = coords.RelY * pxToPt;
|
||||||
|
|
||||||
|
// Active page: DxPdfViewer.ActivePageIndex is 0-based
|
||||||
|
int page = (_pdfViewer?.ActivePageIndex ?? 0) + 1;
|
||||||
|
|
||||||
|
// Display position (px on overlay) — keep in px for CSS
|
||||||
|
double displayX = coords.RelX;
|
||||||
|
double displayY = coords.RelY;
|
||||||
|
|
||||||
|
// Prevent placing outside bounds
|
||||||
|
if (displayX < 0 || displayY < 0) return;
|
||||||
|
if (displayX + SigDisplayW > coords.ContainerW) displayX = coords.ContainerW - SigDisplayW;
|
||||||
|
if (displayY + SigDisplayH > coords.ContainerH) displayY = coords.ContainerH - SigDisplayH;
|
||||||
|
|
||||||
|
var field = new SignatureFieldDraft(xPt, yPt, page, displayX, displayY);
|
||||||
|
_signatureFields.Add(field);
|
||||||
|
|
||||||
|
Logger.LogInformation(
|
||||||
|
"Signature field added: Page={Page} X={X:F1}pt Y={Y:F1}pt",
|
||||||
|
page, xPt, yPt);
|
||||||
|
|
||||||
|
// Exit placement mode after one click (user can re-click button for next)
|
||||||
|
_placementMode = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save ──
|
||||||
|
async Task SaveAsync()
|
||||||
|
{
|
||||||
|
if (_signatureFields.Count == 0)
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("console.log",
|
||||||
|
"[SenderEditor] No signature fields to save.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var f in _signatureFields)
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("console.log",
|
||||||
|
$"[SenderEditor] Field: Page={f.Page} X={f.XPt:F2}pt ({f.XPt/72:F3}in) Y={f.YPt:F2}pt ({f.YPt/72:F3}in)");
|
||||||
|
}
|
||||||
|
|
||||||
|
await JSRuntime.InvokeVoidAsync("console.log",
|
||||||
|
$"[SenderEditor] Total fields: {_signatureFields.Count}");
|
||||||
|
}
|
||||||
|
|
||||||
|
void OpenAddReceiverPopup()
|
||||||
|
{
|
||||||
|
_receiverDraftName = string.Empty;
|
||||||
|
_receiverDraftEmail = string.Empty;
|
||||||
|
_receiverDraftPhoneNumber = string.Empty;
|
||||||
|
_selectedReceiverEmailSuggestion = null;
|
||||||
|
_receiverPopupValidationMessage = null;
|
||||||
|
_receiverEmailSuggestions.Clear();
|
||||||
|
_receiverPopupVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CloseAddReceiverPopup()
|
||||||
|
{
|
||||||
|
_receiverPopupVisible = false;
|
||||||
|
_receiverPopupValidationMessage = null;
|
||||||
|
_selectedReceiverEmailSuggestion = null;
|
||||||
|
_isReceiverEmailSearchRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnReceiverNameChanged(string? value)
|
||||||
|
{
|
||||||
|
_receiverDraftName = value ?? string.Empty;
|
||||||
|
_receiverPopupValidationMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnReceiverPhoneNumberChanged(string? value)
|
||||||
|
{
|
||||||
|
_receiverDraftPhoneNumber = value ?? string.Empty;
|
||||||
|
_receiverPopupValidationMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task OnReceiverEmailKeyDownAsync(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (_receiverEmailSuggestions.Count == 0)
|
||||||
|
{
|
||||||
|
if (e.Key == "Escape")
|
||||||
|
_selectedReceiverEmailSuggestion = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentIndex = _selectedReceiverEmailSuggestion is null
|
||||||
|
? -1
|
||||||
|
: _receiverEmailSuggestions.FindIndex(email => string.Equals(email, _selectedReceiverEmailSuggestion, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (e.Key == "ArrowDown")
|
||||||
|
{
|
||||||
|
var nextIndex = currentIndex < _receiverEmailSuggestions.Count - 1 ? currentIndex + 1 : 0;
|
||||||
|
await OnReceiverEmailSuggestionSelectedAsync(_receiverEmailSuggestions[nextIndex]);
|
||||||
|
}
|
||||||
|
else if (e.Key == "ArrowUp")
|
||||||
|
{
|
||||||
|
var nextIndex = currentIndex > 0 ? currentIndex - 1 : _receiverEmailSuggestions.Count - 1;
|
||||||
|
await OnReceiverEmailSuggestionSelectedAsync(_receiverEmailSuggestions[nextIndex]);
|
||||||
|
}
|
||||||
|
else if (e.Key == "Enter")
|
||||||
|
{
|
||||||
|
var selectedValue = currentIndex >= 0 && currentIndex < _receiverEmailSuggestions.Count
|
||||||
|
? _receiverEmailSuggestions[currentIndex]
|
||||||
|
: _receiverEmailSuggestions.FirstOrDefault();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(selectedValue))
|
||||||
|
{
|
||||||
|
await OnReceiverEmailSuggestionSelectedAsync(selectedValue);
|
||||||
|
_receiverEmailSuggestions.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (e.Key == "Escape")
|
||||||
|
{
|
||||||
|
_receiverEmailSuggestions.Clear();
|
||||||
|
_selectedReceiverEmailSuggestion = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Task OnReceiverEmailSuggestionSelectedAsync(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
_selectedReceiverEmailSuggestion = value.Trim();
|
||||||
|
_receiverDraftEmail = _selectedReceiverEmailSuggestion;
|
||||||
|
_receiverPopupValidationMessage = null;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task OnReceiverEmailTextChangedAsync(string? value)
|
||||||
|
{
|
||||||
|
_receiverDraftEmail = value?.Trim() ?? string.Empty;
|
||||||
|
_selectedReceiverEmailSuggestion = _receiverDraftEmail;
|
||||||
|
_receiverPopupValidationMessage = null;
|
||||||
|
|
||||||
|
var searchVersion = ++_receiverEmailSearchVersion;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(_receiverDraftEmail) || _receiverDraftEmail.Length < 2)
|
||||||
|
{
|
||||||
|
_receiverEmailSuggestions.Clear();
|
||||||
|
_selectedReceiverEmailSuggestion = null;
|
||||||
|
_isReceiverEmailSearchRunning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isReceiverEmailSearchRunning = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var results = await ReceiverPageDataService.SearchReceiverEMailsAsync(_receiverDraftEmail);
|
||||||
|
|
||||||
|
if (searchVersion != _receiverEmailSearchVersion)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_receiverEmailSuggestions = results
|
||||||
|
.Where(email => !string.IsNullOrWhiteSpace(email))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(email => email)
|
||||||
|
.Take(12)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_selectedReceiverEmailSuggestion = _receiverEmailSuggestions.FirstOrDefault(email =>
|
||||||
|
string.Equals(email, _receiverDraftEmail, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logger.LogWarning(ex, "Failed to load receiver email suggestions for {SearchTerm}", _receiverDraftEmail);
|
||||||
|
if (searchVersion == _receiverEmailSearchVersion)
|
||||||
|
{
|
||||||
|
_receiverEmailSuggestions.Clear();
|
||||||
|
_selectedReceiverEmailSuggestion = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (searchVersion == _receiverEmailSearchVersion)
|
||||||
|
_isReceiverEmailSearchRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Task SaveReceiverAsync()
|
||||||
|
{
|
||||||
|
var fullName = _receiverDraftName.Trim();
|
||||||
|
var email = _receiverDraftEmail.Trim();
|
||||||
|
var phoneNumber = _receiverDraftPhoneNumber.Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(fullName))
|
||||||
|
{
|
||||||
|
_receiverPopupValidationMessage = "Bitte geben Sie einen Vor- und Nachnamen ein.";
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
{
|
||||||
|
_receiverPopupValidationMessage = "Bitte geben Sie eine E-Mail-Adresse ein.";
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ReceiverEmailValidator.IsValid(email))
|
||||||
|
{
|
||||||
|
_receiverPopupValidationMessage = "Bitte geben Sie eine gültige E-Mail-Adresse ein.";
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_receivers.Any(receiver => string.Equals(receiver.Email, email, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
_receiverPopupValidationMessage = "Diese E-Mail-Adresse wurde bereits hinzugefügt.";
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
_receivers.Add(new ReceiverDraft(Guid.NewGuid(), fullName, email, phoneNumber));
|
||||||
|
CloseAddReceiverPopup();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddSignatureForReceiver(ReceiverDraft receiver)
|
||||||
|
{
|
||||||
|
Logger.LogInformation("Signature placement requested for receiver {Email}", receiver.Email);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!_pdfLoaded || _errorMessage is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await JSRuntime.InvokeVoidAsync(
|
||||||
|
"envelopeEditor.syncOverlayToPage",
|
||||||
|
"pdf-editor-wrapper",
|
||||||
|
"pdf-editor-overlay");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Models ──
|
||||||
|
record SignatureFieldDraft(double XPt, double YPt, int Page, double DisplayX, double DisplayY);
|
||||||
|
|
||||||
|
record OverlayCoords(double RelX, double RelY, double ContainerW, double ContainerH);
|
||||||
|
|
||||||
|
record ReceiverDraft(Guid Id, string FullName, string Email, string PhoneNumber);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
@page "/Error"
|
||||||
|
@using System.Diagnostics
|
||||||
|
|
||||||
|
<PageTitle>Error</PageTitle>
|
||||||
|
|
||||||
|
<h1 class="text-danger">Error.</h1>
|
||||||
|
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||||
|
|
||||||
|
@if (ShowRequestId)
|
||||||
|
{
|
||||||
|
<p>
|
||||||
|
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h3>Development Mode</h3>
|
||||||
|
<p>
|
||||||
|
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||||
|
It can result in displaying sensitive information from exceptions to end users.
|
||||||
|
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||||
|
and restarting the app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@code{
|
||||||
|
[CascadingParameter]
|
||||||
|
private HttpContext? HttpContext { get; set; }
|
||||||
|
|
||||||
|
private string? RequestId { get; set; }
|
||||||
|
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||||
|
|
||||||
|
protected override void OnInitialized() =>
|
||||||
|
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
@using System.Net.Http
|
||||||
|
@using System.Net.Http.Json
|
||||||
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
|
@using Microsoft.AspNetCore.Components.Routing
|
||||||
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
|
@using Microsoft.JSInterop
|
||||||
|
@using EnvelopeGenerator.Server
|
||||||
|
@using EnvelopeGenerator.Server.Client
|
||||||
|
@using EnvelopeGenerator.Server.Components
|
||||||
|
@using DevExpress.Blazor
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using DigitalData.Core.Abstraction.Application.DTO;
|
||||||
|
using DigitalData.Core.Exceptions;
|
||||||
|
using EnvelopeGenerator.Server.Extensions;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
using EnvelopeGenerator.Application.Common.Extensions;
|
||||||
|
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||||
|
using EnvelopeGenerator.Application.Common.Notifications.DocSigned;
|
||||||
|
using EnvelopeGenerator.Application.Common.Notifications.RemoveSignature;
|
||||||
|
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||||
|
using EnvelopeGenerator.Application.Histories.Queries;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages annotations and signature lifecycle for envelopes.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class AnnotationController : ControllerBase
|
||||||
|
{
|
||||||
|
[Obsolete("Use MediatR")]
|
||||||
|
private readonly IEnvelopeHistoryService _historyService;
|
||||||
|
|
||||||
|
[Obsolete("Use MediatR")]
|
||||||
|
private readonly IEnvelopeReceiverService _envelopeReceiverService;
|
||||||
|
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
private readonly ILogger<AnnotationController> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of <see cref="AnnotationController"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Obsolete("Use MediatR")]
|
||||||
|
public AnnotationController(
|
||||||
|
ILogger<AnnotationController> logger,
|
||||||
|
IEnvelopeHistoryService envelopeHistoryService,
|
||||||
|
IEnvelopeReceiverService envelopeReceiverService,
|
||||||
|
IMediator mediator)
|
||||||
|
{
|
||||||
|
_historyService = envelopeHistoryService;
|
||||||
|
_envelopeReceiverService = envelopeReceiverService;
|
||||||
|
_mediator = mediator;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates or updates annotations for the authenticated envelope receiver.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="psPdfKitAnnotation">Annotation payload.</param>
|
||||||
|
/// <param name="cancel">Cancellation token.</param>
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[HttpPost]
|
||||||
|
[Obsolete("PSPDF Kit will no longer be used.")]
|
||||||
|
public async Task<IActionResult> CreateOrUpdate([FromBody] PsPdfKitAnnotation? psPdfKitAnnotation = null, CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
var signature = User.ReceiverSignature();
|
||||||
|
var uuid = User.EnvelopeUuid();
|
||||||
|
|
||||||
|
var envelopeReceiver = await _mediator.ReadEnvelopeReceiverAsync(uuid, signature, cancel).ThrowIfNull(Exceptions.NotFound);
|
||||||
|
|
||||||
|
if (!envelopeReceiver.Envelope!.ReadOnly && psPdfKitAnnotation is null)
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
if (await _mediator.IsSignedAsync(uuid, signature, cancel))
|
||||||
|
return Problem(statusCode: StatusCodes.Status409Conflict);
|
||||||
|
else if (await _mediator.AnyHistoryAsync(uuid, new[] { EnvelopeStatus.EnvelopeRejected, EnvelopeStatus.DocumentRejected }, cancel))
|
||||||
|
return Problem(statusCode: StatusCodes.Status423Locked);
|
||||||
|
|
||||||
|
var envelopeReceiverDto = await _mediator.ReadEnvelopeReceiverAsync(uuid, signature, cancel);
|
||||||
|
var docSignedNotification = envelopeReceiverDto is not null
|
||||||
|
? new DocSignedNotification { EnvelopeReceiver = envelopeReceiverDto, PsPdfKitAnnotation = psPdfKitAnnotation }
|
||||||
|
: throw new NotFoundException("Envelope receiver is not found.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _mediator.Publish(docSignedNotification, cancel);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
await _mediator.Publish(new RemoveSignatureNotification()
|
||||||
|
{
|
||||||
|
EnvelopeId = docSignedNotification.EnvelopeReceiver.EnvelopeId,
|
||||||
|
ReceiverId = docSignedNotification.EnvelopeReceiver.ReceiverId
|
||||||
|
}, cancel);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rejects the document for the current receiver.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="reason">Optional rejection reason.</param>
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[HttpPost("reject")]
|
||||||
|
[Obsolete("Use MediatR")]
|
||||||
|
public async Task<IActionResult> Reject([FromBody] string? reason = null)
|
||||||
|
{
|
||||||
|
var signature = User.ReceiverSignature();
|
||||||
|
var uuid = User.EnvelopeUuid();
|
||||||
|
var mail = User.ReceiverMail();
|
||||||
|
|
||||||
|
var envRcvRes = await _envelopeReceiverService.ReadByUuidSignatureAsync(uuid: uuid, signature: signature);
|
||||||
|
|
||||||
|
if (envRcvRes.IsFailed)
|
||||||
|
{
|
||||||
|
_logger.LogNotice(envRcvRes.Notices);
|
||||||
|
return Unauthorized("you are not authorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
var histRes = await _historyService.RecordAsync(envRcvRes.Data.EnvelopeId, userReference: mail, EnvelopeStatus.DocumentRejected, comment: reason);
|
||||||
|
if (histRes.IsSuccess)
|
||||||
|
{
|
||||||
|
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogEnvelopeError(uuid: uuid, signature: signature, message: "Unexpected error happened in api/envelope/reject");
|
||||||
|
_logger.LogNotice(histRes.Notices);
|
||||||
|
return StatusCode(500, histRes.Messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using DigitalData.Auth.Claims;
|
||||||
|
using EnvelopeGenerator.Server.Controllers.Interfaces;
|
||||||
|
using EnvelopeGenerator.Server.Models;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controller verantwortlich für die Benutzer-Authentifizierung, einschließlich Anmelden, Abmelden und Überprüfung des Authentifizierungsstatus.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public partial class AuthController(IOptions<AuthTokenKeys> authTokenKeyOptions, IAuthorizationService authService) : ControllerBase, IAuthController
|
||||||
|
{
|
||||||
|
private readonly AuthTokenKeys authTokenKeys = authTokenKeyOptions.Value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public IAuthorizationService AuthService { get; } = authService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Entfernt das Authentifizierungs-Cookie des Benutzers (AuthCookie)
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// Gibt eine HTTP 200 oder 401.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Sample request:
|
||||||
|
///
|
||||||
|
/// POST /api/auth/logout
|
||||||
|
///
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="200">Erfolgreich gelöscht, wenn der Benutzer ein berechtigtes Cookie hat.</response>
|
||||||
|
/// <response code="401">Wenn es kein zugelassenes Cookie gibt, wird „nicht zugelassen“ zurückgegeben.</response>
|
||||||
|
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
|
||||||
|
[Authorize(Policy = AuthPolicy.Sender)]
|
||||||
|
[HttpPost("logout")]
|
||||||
|
public IActionResult Logout()
|
||||||
|
{
|
||||||
|
Response.Cookies.Delete(authTokenKeys.Cookie);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft, ob der Benutzer ein autorisiertes Token hat.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Wenn ein autorisiertes Token vorhanden ist HTTP 200 asynchron 401</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Sample request:
|
||||||
|
///
|
||||||
|
/// GET /api/auth
|
||||||
|
///
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="200">Wenn es einen autorisierten Cookie gibt.</response>
|
||||||
|
/// <response code="401">Wenn kein Cookie vorhanden ist oder nicht autorisierte.</response>
|
||||||
|
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
|
||||||
|
[HttpGet("check")]
|
||||||
|
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||||
|
public IActionResult Check(string? role = null)
|
||||||
|
=> role is not null && !User.IsInRole(role)
|
||||||
|
? Unauthorized()
|
||||||
|
: Ok();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether the caller holds a valid per-envelope receiver token for the given envelope key.
|
||||||
|
/// The request must carry a cookie named <c>AuthTokenSignFLOWReceiver.{envelopeKey}</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="envelopeKey">The unique envelope key extracted from the route.</param>
|
||||||
|
/// <response code="200">Valid per-envelope token found.</response>
|
||||||
|
/// <response code="401">Token is missing, expired or invalid.</response>
|
||||||
|
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)]
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[HttpGet("check/envelope/{envelopeKey}")]
|
||||||
|
public IActionResult CheckEnvelopeReceiver([FromRoute] string envelopeKey) => Ok();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the per-envelope receiver cookie for the given envelope key.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="envelopeKey">The unique envelope key whose cookie should be deleted.</param>
|
||||||
|
/// <response code="200">Cookie successfully deleted.</response>
|
||||||
|
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
|
||||||
|
[HttpPost("logout/envelope/{envelopeKey}")]
|
||||||
|
public IActionResult LogoutEnvelopeReceiver([FromRoute] string envelopeKey)
|
||||||
|
{
|
||||||
|
var cookieName = CookieNames.GetEnvelopeReceiverCookieName(authTokenKeys.Cookie, envelopeKey);
|
||||||
|
Response.Cookies.Delete(cookieName);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes all per-envelope receiver cookies from the current request.
|
||||||
|
/// </summary>
|
||||||
|
/// <response code="200">All envelope receiver cookies successfully deleted.</response>
|
||||||
|
[ProducesResponseType(typeof(void), StatusCodes.Status200OK)]
|
||||||
|
[HttpPost("logout/envelope")]
|
||||||
|
public IActionResult LogoutAllEnvelopeReceivers()
|
||||||
|
{
|
||||||
|
foreach (var cookieName in Request.Cookies.Keys.Where(k => CookieNames.IsEnvelopeReceiverCookie(k, authTokenKeys.Cookie)))
|
||||||
|
Response.Cookies.Delete(cookieName);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Text.Json;
|
||||||
|
using EnvelopeGenerator.Server.Options;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Server.Extensions;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages cached data for receivers using distributed cache.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
public class CacheController(
|
||||||
|
IDistributedCache cache,
|
||||||
|
IOptions<CacheOptions> cacheOptions) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string SignatureCacheKeyPrefix = "envelope-generator.receiver-ui.signature:";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores a receiver's signature in cache for the specified envelope.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[HttpPost("SignatureCapture/{envelopeKey}")]
|
||||||
|
public async Task<IActionResult> SaveSignature(
|
||||||
|
[FromRoute] string envelopeKey,
|
||||||
|
[FromBody] SignatureCacheRequest request,
|
||||||
|
CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{SignatureCacheKeyPrefix}{User.ReceiverSignature()}";
|
||||||
|
var json = JsonSerializer.Serialize(request);
|
||||||
|
|
||||||
|
var options = cacheOptions.Value.SignatureCacheExpiration.HasValue
|
||||||
|
? new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = cacheOptions.Value.SignatureCacheExpiration.Value }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await cache.SetStringAsync(cacheKey, json, options ?? new DistributedCacheEntryOptions(), cancel);
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a cached signature for the specified envelope.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[HttpGet("SignatureCapture/{envelopeKey}")]
|
||||||
|
public async Task<IActionResult> GetSignature([FromRoute] string envelopeKey, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{SignatureCacheKeyPrefix}{User.ReceiverSignature()}";
|
||||||
|
var json = await cache.GetStringAsync(cacheKey, cancel);
|
||||||
|
|
||||||
|
if (json is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var signature = JsonSerializer.Deserialize<SignatureCacheRequest>(json);
|
||||||
|
return Ok(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes a cached signature for the specified envelope.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[HttpDelete("SignatureCapture/{envelopeKey}")]
|
||||||
|
public async Task<IActionResult> DeleteSignature([FromRoute] string envelopeKey, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var cacheKey = $"{SignatureCacheKeyPrefix}{User.ReceiverSignature()}";
|
||||||
|
await cache.RemoveAsync(cacheKey, cancel);
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request model for caching signature data.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record SignatureCacheRequest(
|
||||||
|
string DataUrl,
|
||||||
|
string FullName,
|
||||||
|
string Place,
|
||||||
|
string? Position = null);
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using EnvelopeGenerator.Server.Models.PsPdfKitAnnotation;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exposes configuration data required by the client applications.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of <see cref="ConfigController"/>.
|
||||||
|
/// </remarks>
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
public class ConfigController(IOptionsMonitor<AnnotationParams> annotationParamsOptions) : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AnnotationParams _annotationParams = annotationParamsOptions.CurrentValue;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns annotation configuration that was previously rendered by MVC.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("Annotations")]
|
||||||
|
[Obsolete("PSPDF Kit will no longer be used.")]
|
||||||
|
public IActionResult GetAnnotationParams()
|
||||||
|
{
|
||||||
|
return Ok(_annotationParams.AnnotationJSObject);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using DigitalData.Auth.Claims;
|
||||||
|
using EnvelopeGenerator.Server.Controllers.Interfaces;
|
||||||
|
using EnvelopeGenerator.Server.Extensions;
|
||||||
|
using EnvelopeGenerator.Application.Documents.Queries;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides access to envelope documents for authenticated receivers.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initializes a new instance of the <see cref="DocumentController"/> class.
|
||||||
|
/// </remarks>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class DocumentController(IMediator mediator, IAuthorizationService authService, ILogger<DocumentController> logger) : ControllerBase, IAuthController
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public IAuthorizationService AuthService => authService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the document bytes receiver.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">Encoded envelope key.</param>
|
||||||
|
/// <param name="cancel">Cancellation token.</param>
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(Policy = AuthPolicy.Sender)]
|
||||||
|
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery? query = null)
|
||||||
|
{
|
||||||
|
if (query is null)
|
||||||
|
return BadRequest("Missing document query.");
|
||||||
|
|
||||||
|
var senderDoc = await mediator.Send(query, cancel);
|
||||||
|
return senderDoc.ByteData is byte[] senderDocByte
|
||||||
|
? File(senderDocByte, "application/octet-stream")
|
||||||
|
: NotFound("Document is empty.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the document for the specified envelope key.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="envelopeKey"></param>
|
||||||
|
/// <param name="cancel"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[HttpGet("{envelopeKey}")]
|
||||||
|
public async Task<IActionResult> GetDocumentOfReceiver(string envelopeKey, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
int envelopeId = User.EnvelopeId();
|
||||||
|
|
||||||
|
var senderDoc = await mediator.Send(new ReadDocumentQuery() { EnvelopeId = envelopeId }, cancel);
|
||||||
|
|
||||||
|
if (senderDoc.ByteData is not byte[] senderDocByte)
|
||||||
|
return NotFound("Document is empty.");
|
||||||
|
|
||||||
|
Response.Headers.ContentDisposition = $"inline; filename=\"{envelopeKey}.pdf\"";
|
||||||
|
return File(senderDocByte, "application/pdf");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using EnvelopeGenerator.Application.EmailTemplates.Commands;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MediatR;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
using DigitalData.Core.Abstraction.Application.Repository;
|
||||||
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Application.EmailTemplates.Queries;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controller for managing temp templates.
|
||||||
|
/// Steuerung zur Verwaltung von E-Mail-Vorlagen.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Initialisiert eine neue Instanz der <see cref="EmailTemplateController"/>-Klasse.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="mediator">
|
||||||
|
/// Die Mediator-Instanz, die zum Senden von Befehlen und Abfragen verwendet wird.
|
||||||
|
/// </param>
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(Policy = AuthPolicy.Sender)]
|
||||||
|
public class EmailTemplateController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Ruft E-Mail-Vorlagen basierend auf der angegebenen Abfrage ab.
|
||||||
|
/// Gibt alles zurück, wenn keine Id- oder Typ-Informationen eingegeben wurden.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="emailTemplate">Die Abfrageparameter zum Abrufen von E-Mail-Vorlagen.</param>
|
||||||
|
/// <param name="cancel"></param>
|
||||||
|
/// <returns>Gibt HTTP-Antwort zurück</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Sample request:
|
||||||
|
/// GET /api/EmailTemplate?emailTemplateId=123
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="200">Wenn die E-Mail-Vorlagen erfolgreich abgerufen werden.</response>
|
||||||
|
/// <response code="400">Wenn die Abfrageparameter ungültig sind.</response>
|
||||||
|
/// <response code="401">Wenn der Benutzer nicht authentifiziert ist.</response>
|
||||||
|
/// <response code="404">Wenn die gesuchte Abfrage nicht gefunden wird.</response>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Get([FromQuery] ReadEmailTemplateQuery emailTemplate, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(emailTemplate, cancel);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an temp template or resets it if no update command is provided.
|
||||||
|
/// Aktualisiert eine E-Mail-Vorlage oder setzt sie zurück, wenn kein Aktualisierungsbefehl angegeben ist.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="update"></param>
|
||||||
|
/// <param name="cancel"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
/// <response code="200">Wenn die E-Mail-Vorlage erfolgreich aktualisiert oder zurückgesetzt wird.</response>
|
||||||
|
/// <response code="400">Wenn die Abfrage ohne einen String gesendet wird.</response>
|
||||||
|
/// <response code="401">Wenn der Benutzer nicht authentifiziert ist.</response>
|
||||||
|
/// <response code="404">Wenn die gesuchte Abfrage nicht gefunden wird.</response>
|
||||||
|
[HttpPut]
|
||||||
|
public async Task<IActionResult> Update([FromBody] UpdateEmailTemplateCommand update, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
await mediator.Send(update, cancel);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
using EnvelopeGenerator.Server.Extensions;
|
||||||
|
using EnvelopeGenerator.Application.Envelopes.Commands;
|
||||||
|
using EnvelopeGenerator.Application.Envelopes.Queries;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dieser Controller stellt Endpunkte für die Verwaltung von Umschlägen bereit.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Die API ermöglicht das Abrufen und Verwalten von Umschlägen basierend auf Benutzerinformationen und Statusfiltern.
|
||||||
|
///
|
||||||
|
/// Mögliche Antworten:
|
||||||
|
/// - 200 OK: Die Anfrage war erfolgreich, und die angeforderten Daten werden zurückgegeben.
|
||||||
|
/// - 400 Bad Request: Die Anfrage war fehlerhaft oder unvollständig.
|
||||||
|
/// - 401 Unauthorized: Der Benutzer ist nicht authentifiziert.
|
||||||
|
/// - 403 Forbidden: Der Benutzer hat keine Berechtigung, auf die Ressource zuzugreifen.
|
||||||
|
/// - 404 Not Found: Die angeforderte Ressource wurde nicht gefunden.
|
||||||
|
/// - 500 Internal Server Error: Ein unerwarteter Fehler ist aufgetreten.
|
||||||
|
/// </remarks>
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
public class EnvelopeController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<EnvelopeController> _logger;
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Erstellt eine neue Instanz des EnvelopeControllers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Der Logger, der für das Protokollieren von Informationen verwendet wird.</param>
|
||||||
|
/// <param name="mediator"></param>
|
||||||
|
public EnvelopeController(ILogger<EnvelopeController> logger, IMediator mediator)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ruft eine Liste von Umschlägen basierend auf dem Benutzer und den angegebenen Statusfiltern ab.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="envelope"></param>
|
||||||
|
/// <returns>Eine IActionResult-Instanz, die die abgerufenen Umschläge oder einen Fehlerstatus enthält.</returns>
|
||||||
|
/// <response code="200">Die Anfrage war erfolgreich, und die Umschläge werden zurückgegeben.</response>
|
||||||
|
/// <response code="400">Die Anfrage war fehlerhaft oder unvollständig.</response>
|
||||||
|
/// <response code="401">Der Benutzer ist nicht authentifiziert.</response>
|
||||||
|
/// <response code="403">Der Benutzer hat keine Berechtigung, auf die Ressource zuzugreifen.</response>
|
||||||
|
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
|
||||||
|
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAsync([FromQuery] ReadEnvelopeQuery envelope)
|
||||||
|
{
|
||||||
|
var result = await _mediator.Send(envelope.Authorize(User.GetId()));
|
||||||
|
return result.Any() ? Ok(result) : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ruft das Ergebnis eines Dokuments basierend auf der ID ab.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query"></param>
|
||||||
|
/// <param name="view">Gibt an, ob das Dokument inline angezeigt werden soll (true) oder als Download bereitgestellt wird (false).</param>
|
||||||
|
/// <returns>Eine IActionResult-Instanz, die das Dokument oder einen Fehlerstatus enthält.</returns>
|
||||||
|
/// <response code="200">Das Dokument wurde erfolgreich abgerufen.</response>
|
||||||
|
/// <response code="404">Das Dokument wurde nicht gefunden oder ist nicht verfügbar.</response>
|
||||||
|
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
|
||||||
|
[HttpGet("doc-result")]
|
||||||
|
public async Task<IActionResult> GetDocResultAsync([FromQuery] ReadEnvelopeQuery query, [FromQuery] bool view = false)
|
||||||
|
{
|
||||||
|
var envelopes = await _mediator.Send(query.Authorize(User.GetId()));
|
||||||
|
var envelope = envelopes.FirstOrDefault();
|
||||||
|
|
||||||
|
if (envelope is null)
|
||||||
|
return NotFound("Envelope not available.");
|
||||||
|
if (envelope.DocResult is null)
|
||||||
|
return NotFound("The document has not been fully signed or the result has not yet been released.");
|
||||||
|
|
||||||
|
if (view)
|
||||||
|
{
|
||||||
|
Response.Headers.Append("Content-Disposition", "inline; filename=\"" + envelope.Uuid + ".pdf\"");
|
||||||
|
return File(envelope.DocResult, "application/pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(envelope.DocResult, "application/pdf", $"{envelope.Uuid}.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="command"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[NonAction]
|
||||||
|
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeCommand command)
|
||||||
|
{
|
||||||
|
var res = await _mediator.Send(command.WithAuth(User.GetId()));
|
||||||
|
|
||||||
|
if (res is null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to create envelope. Envelope details: {EnvelopeDetails}", JsonConvert.SerializeObject(command));
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return Ok(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
|
||||||
|
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||||
|
using EnvelopeGenerator.Application.Envelopes.Queries;
|
||||||
|
using EnvelopeGenerator.Domain.Entities;
|
||||||
|
using EnvelopeGenerator.Server.Models;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Data;
|
||||||
|
using EnvelopeGenerator.Application.Common.SQL;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||||
|
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
|
||||||
|
using EnvelopeGenerator.Server.Extensions;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controller für die Verwaltung von Umschlagempfängern.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Dieser Controller bietet Endpunkte für das Abrufen und Verwalten von Umschlagempfängerdaten.
|
||||||
|
/// </remarks>
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize]
|
||||||
|
[ApiController]
|
||||||
|
public class EnvelopeReceiverController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<EnvelopeReceiverController> _logger;
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
private readonly IMapper _mapper;
|
||||||
|
private readonly IEnvelopeExecutor _envelopeExecutor;
|
||||||
|
private readonly IEnvelopeReceiverExecutor _erExecutor;
|
||||||
|
private readonly IDocumentExecutor _documentExecutor;
|
||||||
|
private readonly string _cnnStr;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Konstruktor für den EnvelopeReceiverController.
|
||||||
|
/// </summary>
|
||||||
|
public EnvelopeReceiverController(ILogger<EnvelopeReceiverController> logger, IMediator mediator, IMapper mapper, IEnvelopeExecutor envelopeExecutor, IEnvelopeReceiverExecutor erExecutor, IDocumentExecutor documentExecutor, IOptions<ConnectionString> csOpt)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_mediator = mediator;
|
||||||
|
_mapper = mapper;
|
||||||
|
_envelopeExecutor = envelopeExecutor;
|
||||||
|
_erExecutor = erExecutor;
|
||||||
|
_documentExecutor = documentExecutor;
|
||||||
|
_cnnStr = csOpt.Value.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ruft eine Liste von Umschlagempfängern basierend auf den angegebenen Abfrageparametern ab.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="envelopeReceiver">Die Abfrageparameter für die Filterung von Umschlagempfängern.</param>
|
||||||
|
/// <returns>Eine HTTP-Antwort mit der Liste der gefundenen Umschlagempfänger oder einem Fehlerstatus.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Dieser Endpunkt ermöglicht es, Umschlagempfänger basierend auf dem Benutzernamen und optionalen Statusfiltern abzurufen.
|
||||||
|
/// Wenn der Benutzername nicht ermittelt werden kann, wird ein Serverfehler zurückgegeben.
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="200">Die Liste der Umschlagempfänger wurde erfolgreich abgerufen.</response>
|
||||||
|
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
|
||||||
|
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
|
||||||
|
[Authorize]
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetEnvelopeReceiver([FromQuery] ReadEnvelopeReceiverQuery envelopeReceiver)
|
||||||
|
{
|
||||||
|
envelopeReceiver = envelopeReceiver with { Username = User.GetUsername() };
|
||||||
|
|
||||||
|
var result = await _mediator.Send(envelopeReceiver);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="envelopeKey"></param>
|
||||||
|
/// <param name="cancel"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[HttpGet("{envelopeKey}")]
|
||||||
|
public async Task<IActionResult> GetEnvelopeReceiverOfReceiver([FromRoute] string envelopeKey, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var er = await _mediator.Send(new ReadEnvelopeReceiverQuery()
|
||||||
|
{
|
||||||
|
Key = envelopeKey
|
||||||
|
}, cancel);
|
||||||
|
|
||||||
|
return Ok(er.SingleOrDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ruft den Namen des zuletzt verwendeten Empfängers basierend auf der angegebenen E-Mail-Adresse ab.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="receiver">Abfrage, bei der nur eine der Angaben ID, Signatur oder E-Mail-Adresse des Empfängers eingegeben werden muss.</param>
|
||||||
|
/// <returns>Eine HTTP-Antwort mit dem Namen des Empfängers oder einem Fehlerstatus.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Dieser Endpunkt ermöglicht es, den Namen des zuletzt verwendeten Empfängers basierend auf der E-Mail-Adresse abzurufen.
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="200">Der Name des Empfängers wurde erfolgreich abgerufen.</response>
|
||||||
|
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
|
||||||
|
/// <response code="404">Kein Empfänger gefunden.</response>
|
||||||
|
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
|
||||||
|
[Authorize]
|
||||||
|
[HttpGet("salute")]
|
||||||
|
public async Task<IActionResult> GetReceiverName([FromQuery] ReadReceiverNameQuery receiver)
|
||||||
|
{
|
||||||
|
var name = await _mediator.Send(receiver);
|
||||||
|
return name is null ? NotFound() : Ok(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Datenübertragungsobjekt mit Informationen zu Umschlägen, Empfängern und Unterschriften.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request"></param>
|
||||||
|
/// <param name="cancel"></param>
|
||||||
|
/// <returns>HTTP-Antwort</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Sample request:
|
||||||
|
///
|
||||||
|
/// POST /api/envelope
|
||||||
|
/// {
|
||||||
|
/// "title": "Vertragsdokument",
|
||||||
|
/// "message": "Bitte unterschreiben Sie dieses Dokument.",
|
||||||
|
/// "document": {
|
||||||
|
/// "dataAsBase64": "dGVzdC1iYXNlNjQtZGF0YQ=="
|
||||||
|
/// },
|
||||||
|
/// "receivers": [
|
||||||
|
/// {
|
||||||
|
/// "emailAddress": "example@example.com",
|
||||||
|
/// "signatures": [
|
||||||
|
/// {
|
||||||
|
/// "x": 100,
|
||||||
|
/// "y": 200,
|
||||||
|
/// "page": 1
|
||||||
|
/// }
|
||||||
|
/// ],
|
||||||
|
/// "name": "Max Mustermann",
|
||||||
|
/// "phoneNumber": "+49123456789"
|
||||||
|
/// }
|
||||||
|
/// ],
|
||||||
|
/// "tfaEnabled": false
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// </remarks>
|
||||||
|
/// <response code="202">Envelope-Erstellung und Sendeprozessbefehl erfolgreich</response>
|
||||||
|
/// <response code="400">Wenn ein Fehler im HTTP-Body auftritt</response>
|
||||||
|
/// <response code="401">Wenn kein autorisierter Token vorhanden ist</response>
|
||||||
|
/// <response code="500">Es handelt sich um einen unerwarteten Fehler. Die Protokolle sollten überprüft werden.</response>
|
||||||
|
[Authorize]
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> CreateAsync([FromBody] CreateEnvelopeReceiverCommand request, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
#region Create Envelope
|
||||||
|
var envelope = await _envelopeExecutor.CreateEnvelopeAsync(User.GetId(), request.Title, request.Message, request.TFAEnabled, cancel);
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Add receivers
|
||||||
|
List<EnvelopeReceiver> sentReceivers = new();
|
||||||
|
List<ReceiverGetOrCreateCommand> unsentReceivers = new();
|
||||||
|
|
||||||
|
foreach (var receiver in request.Receivers)
|
||||||
|
{
|
||||||
|
var envelopeReceiver = await _erExecutor.AddEnvelopeReceiverAsync(envelope.Uuid, receiver.EmailAddress, receiver.Salution, receiver.PhoneNumber, cancel);
|
||||||
|
|
||||||
|
if (envelopeReceiver is null)
|
||||||
|
unsentReceivers.Add(receiver);
|
||||||
|
else
|
||||||
|
sentReceivers.Add(envelopeReceiver);
|
||||||
|
}
|
||||||
|
|
||||||
|
var res = _mapper.Map<CreateEnvelopeReceiverResponse>(envelope);
|
||||||
|
res.UnsentReceivers = unsentReceivers;
|
||||||
|
res.SentReceiver = _mapper.Map<List<ReceiverDto>>(sentReceivers.Select(er => er.Receiver));
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Add document
|
||||||
|
var document = await _documentExecutor.CreateDocumentAsync(request.Document.DataAsBase64, envelope.Uuid, cancel);
|
||||||
|
|
||||||
|
if (document is null)
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError, "Document creation is failed.");
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Add document element
|
||||||
|
// @DOC_ID, @RECEIVER_ID, @POSITION_X, @POSITION_Y, @PAGE
|
||||||
|
string sql = @"
|
||||||
|
DECLARE @OUT_SUCCESS bit;
|
||||||
|
|
||||||
|
EXEC [dbo].[PRSIG_API_ADD_DOC_RECEIVER_ELEM]
|
||||||
|
{0},
|
||||||
|
{1},
|
||||||
|
{2},
|
||||||
|
{3},
|
||||||
|
{4},
|
||||||
|
@OUT_SUCCESS OUTPUT;
|
||||||
|
|
||||||
|
SELECT @OUT_SUCCESS as [@OUT_SUCCESS];";
|
||||||
|
|
||||||
|
foreach (var rcv in res.SentReceiver)
|
||||||
|
foreach (var sign in request.Receivers.Where(r => r.EmailAddress == rcv.EmailAddress).FirstOrDefault()?.DocReceiverElements ?? Enumerable.Empty<Application.EnvelopeReceivers.Commands.DocReceiverElementCreateDto>())
|
||||||
|
{
|
||||||
|
using SqlConnection conn = new(_cnnStr);
|
||||||
|
conn.Open();
|
||||||
|
|
||||||
|
var formattedSQL = string.Format(sql, document.Id.ToSqlParam(), rcv.Id.ToSqlParam(), sign.X.ToSqlParam(), sign.Y.ToSqlParam(), sign.Page.ToSqlParam());
|
||||||
|
|
||||||
|
using SqlCommand cmd = new(formattedSQL, conn);
|
||||||
|
cmd.CommandType = CommandType.Text;
|
||||||
|
|
||||||
|
using SqlDataReader reader = cmd.ExecuteReader();
|
||||||
|
if (reader.Read())
|
||||||
|
{
|
||||||
|
bool outSuccess = reader.GetBoolean(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Create history
|
||||||
|
// ENV_UID, STATUS_ID, USER_ID,
|
||||||
|
string sql_hist = @"
|
||||||
|
USE [DD_ECM]
|
||||||
|
|
||||||
|
DECLARE @OUT_SUCCESS bit;
|
||||||
|
|
||||||
|
EXEC [dbo].[PRSIG_API_ADD_HISTORY_STATE]
|
||||||
|
{0},
|
||||||
|
{1},
|
||||||
|
{2},
|
||||||
|
@OUT_SUCCESS OUTPUT;
|
||||||
|
|
||||||
|
SELECT @OUT_SUCCESS as [@OUT_SUCCESS];";
|
||||||
|
|
||||||
|
using (SqlConnection conn = new(_cnnStr))
|
||||||
|
{
|
||||||
|
conn.Open();
|
||||||
|
var formattedSQL_hist = string.Format(sql_hist, envelope.Uuid.ToSqlParam(), 1003.ToSqlParam(), User.GetId().ToSqlParam());
|
||||||
|
using SqlCommand cmd = new(formattedSQL_hist, conn);
|
||||||
|
cmd.CommandType = CommandType.Text;
|
||||||
|
|
||||||
|
using SqlDataReader reader = cmd.ExecuteReader();
|
||||||
|
if (reader.Read())
|
||||||
|
{
|
||||||
|
bool outSuccess = reader.GetBoolean(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
return Ok(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static bool IsBase64String(string input)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Convert.FromBase64String(input);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using MediatR;
|
||||||
|
using EnvelopeGenerator.Application.EnvelopeTypes.Queries;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.GeneratorAPI.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public class EnvelopeTypeController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<EnvelopeTypeController> _logger;
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger"></param>
|
||||||
|
/// <param name="mediator"></param>
|
||||||
|
public EnvelopeTypeController(ILogger<EnvelopeTypeController> logger, IMediator mediator)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAllAsync()
|
||||||
|
{
|
||||||
|
var result = await _mediator.Send(new ReadEnvelopeTypesQuery());
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using EnvelopeGenerator.Application.Histories.Queries;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Application.Common.Extensions;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dieser Controller stellt Endpunkte für den Zugriff auf die Umschlaghistorie bereit.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
public class HistoryController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IMemoryCache _memoryCache;
|
||||||
|
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Konstruktor für den HistoryController.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="memoryCache"></param>
|
||||||
|
/// <param name="mediator"></param>
|
||||||
|
public HistoryController(IMemoryCache memoryCache, IMediator mediator)
|
||||||
|
{
|
||||||
|
_memoryCache = memoryCache;
|
||||||
|
_mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gibt alle möglichen Verweise auf alle möglichen Include in einem Verlaufsdatensatz zurück. (z. B. DocumentSigned bezieht sich auf Receiver.)
|
||||||
|
/// Dies wird hinzugefügt, damit Client-Anwendungen sich selbst auf dem neuesten Stand halten können.
|
||||||
|
/// 1 - Sender:
|
||||||
|
/// Historische Datensätze über den Include der Empfänger. Diese haben Statuscodes, die mit 1* beginnen.
|
||||||
|
/// 2 - Receiver:
|
||||||
|
/// Historische Datensätze, die sich auf den Include des Absenders beziehen. Sie haben Statuscodes, die mit 2* beginnen.
|
||||||
|
/// 3 - System:
|
||||||
|
/// Historische Datensätze, die sich auf den allgemeinen Zustand des Umschlags beziehen. Diese haben Statuscodes, die mit 3* beginnen.
|
||||||
|
/// 4 - Unknown:
|
||||||
|
/// Ein unbekannter Datensatz weist auf einen möglichen Mangel oder eine Unstimmigkeit im Aktualisierungsprozess der Anwendung hin.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns></returns>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet("related")]
|
||||||
|
[Authorize]
|
||||||
|
public IActionResult GetReferenceTypes(ReferenceType? referenceType = null)
|
||||||
|
{
|
||||||
|
return referenceType is null
|
||||||
|
? Ok(_memoryCache.GetEnumAsDictionary<ReferenceType>("gen.api", ReferenceType.Unknown))
|
||||||
|
: Ok(referenceType.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gibt alle möglichen Include in einem Verlaufsdatensatz zurück.
|
||||||
|
/// Dies wird hinzugefügt, damit Client-Anwendungen sich selbst auf dem neuesten Stand halten können.
|
||||||
|
/// 1003: EnvelopeQueued
|
||||||
|
/// 1006: EnvelopeCompletelySigned
|
||||||
|
/// 1007: EnvelopeReportCreated
|
||||||
|
/// 1008: EnvelopeArchived
|
||||||
|
/// 1009: EnvelopeDeleted
|
||||||
|
/// 10007: EnvelopeRejected
|
||||||
|
/// 10009: EnvelopeWithdrawn
|
||||||
|
/// 2001: AccessCodeRequested
|
||||||
|
/// 2002: AccessCodeCorrect
|
||||||
|
/// 2003: AccessCodeIncorrect
|
||||||
|
/// 2004: DocumentOpened
|
||||||
|
/// 2005: DocumentSigned
|
||||||
|
/// 2006: DocumentForwarded
|
||||||
|
/// 2007: DocumentRejected
|
||||||
|
/// 2008: EnvelopeShared
|
||||||
|
/// 2009: EnvelopeViewed
|
||||||
|
/// 3001: MessageInvitationSent (Wird von Trigger verwendet)
|
||||||
|
/// 3002: MessageAccessCodeSent
|
||||||
|
/// 3003: MessageConfirmationSent
|
||||||
|
/// 3004: MessageDeletionSent
|
||||||
|
/// 3005: MessageCompletionSent
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="status">
|
||||||
|
/// Abfrageparameter, der angibt, auf welche Referenz sich der Include bezieht.
|
||||||
|
/// 1 - Sender: Historische Datensätze, die sich auf den Include des Absenders beziehen. Sie haben Statuscodes, die mit 1* beginnen.
|
||||||
|
/// 2 - Receiver: Historische Datensätze über den Include der Empfänger. Diese haben Statuscodes, die mit 2* beginnen.
|
||||||
|
/// 3 - System: Diese werden durch Datenbank-Trigger aktualisiert und sind in den Tabellen EnvelopeHistory und EmailOut zu finden.Sie arbeiten
|
||||||
|
/// integriert mit der Anwendung EmailProfiler, um E-Mails zu versenden und haben die Codes, die mit 3* beginnen.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>Gibt die HTTP-Antwort zurück.</returns>
|
||||||
|
/// <response code="200"></response>
|
||||||
|
[HttpGet("status")]
|
||||||
|
[Authorize]
|
||||||
|
public IActionResult GetEnvelopeStatus([FromQuery] EnvelopeStatus? status = null)
|
||||||
|
{
|
||||||
|
return status is null
|
||||||
|
? Ok(_memoryCache.GetEnumAsDictionary<EnvelopeStatus>("gen.api", Status.NonHist, Status.RelatedToFormApp))
|
||||||
|
: Ok(status.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ruft die gesamte Umschlaghistorie basierend auf den angegebenen Abfrageparametern ab.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="historyQuery">Die Abfrageparameter, die die Filterkriterien für die Umschlaghistorie definieren.</param>
|
||||||
|
/// <param name="cancel"></param>
|
||||||
|
/// <returns>Eine Liste von Historieneinträgen, die den angegebenen Kriterien entsprechen, oder nur der letzte Eintrag.</returns>
|
||||||
|
/// <response code="200">Die Anfrage war erfolgreich, und die Umschlaghistorie wird zurückgegeben.</response>
|
||||||
|
/// <response code="400">Die Anfrage war ungültig oder unvollständig.</response>
|
||||||
|
/// <response code="401">Der Benutzer ist nicht authentifiziert.</response>
|
||||||
|
/// <response code="403">Der Benutzer hat keine Berechtigung, auf die Ressource zuzugreifen.</response>
|
||||||
|
/// <response code="500">Ein unerwarteter Fehler ist aufgetreten.</response>
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize]
|
||||||
|
public async Task<IActionResult> GetAllAsync([FromQuery] ReadHistoryQuery historyQuery, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
var history = await _mediator.Send(historyQuery, cancel).ThrowIfEmpty(Exceptions.NotFound);
|
||||||
|
return Ok((historyQuery.OnlyLast) ? history.MaxBy(h => h.AddedWhen) : history);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public interface IAuthController
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
IAuthorizationService AuthService { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
ClaimsPrincipal User { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public static class AuthControllerExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="controller"></param>
|
||||||
|
/// <param name="policyName"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public static async Task<bool> IsUserInPolicyAsync(this IAuthController controller, string policyName)
|
||||||
|
{
|
||||||
|
var result = await controller.AuthService.AuthorizeAsync(controller.User, policyName);
|
||||||
|
return result.Succeeded;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using DigitalData.Core.API;
|
||||||
|
using EnvelopeGenerator.Application.Resources;
|
||||||
|
using Microsoft.AspNetCore.Localization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
using EnvelopeGenerator.Application.Resources;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controller für die Verwaltung der Lokalisierung und Spracheinstellungen.
|
||||||
|
/// </summary>
|
||||||
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public class LocalizationController : ControllerBase
|
||||||
|
{
|
||||||
|
private static readonly Guid L_KEY = Guid.NewGuid();
|
||||||
|
|
||||||
|
private readonly ILogger<LocalizationController> _logger;
|
||||||
|
private readonly IStringLocalizer<Resource> _mLocalizer;
|
||||||
|
private readonly IStringLocalizer<Resource> _localizer;
|
||||||
|
private readonly IMemoryCache _cache;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Konstruktor für den <see cref="LocalizationController"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">Logger für die Protokollierung.</param>
|
||||||
|
/// <param name="localizer">Lokalisierungsdienst für Ressourcen.</param>
|
||||||
|
/// <param name="memoryCache">Speicher-Cache für die Zwischenspeicherung von Daten.</param>
|
||||||
|
/// <param name="_modelLocalizer">Lokalisierungsdienst für Modelle.</param>
|
||||||
|
public LocalizationController(
|
||||||
|
ILogger<LocalizationController> logger,
|
||||||
|
IStringLocalizer<Resource> localizer,
|
||||||
|
IMemoryCache memoryCache,
|
||||||
|
IStringLocalizer<Resource> _modelLocalizer)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_localizer = localizer;
|
||||||
|
_cache = memoryCache;
|
||||||
|
_mLocalizer = _modelLocalizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ruft alle lokalisierten Daten ab.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Eine Liste aller lokalisierten Daten.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult GetAll() => Ok(_cache.GetOrCreate(Language ?? string.Empty + L_KEY, _ => _mLocalizer.ToDictionary()));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ruft die aktuelle Sprache ab.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Die aktuelle Sprache oder ein NotFound-Ergebnis, wenn keine Sprache gesetzt ist.</returns>
|
||||||
|
[HttpGet("lang")]
|
||||||
|
public IActionResult GetLanguage() => Language is null ? NotFound() : Ok(Language);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setzt die Sprache.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="language">Die zu setzende Sprache.</param>
|
||||||
|
/// <returns>Ein Ok-Ergebnis, wenn die Sprache erfolgreich gesetzt wurde, oder ein BadRequest-Ergebnis, wenn die Eingabe ungültig ist.</returns>
|
||||||
|
[HttpPost("lang")]
|
||||||
|
public IActionResult SetLanguage([FromQuery] string language)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(language))
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
Language = language;
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Löscht die aktuelle Sprache.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Ein Ok-Ergebnis, wenn die Sprache erfolgreich gelöscht wurde.</returns>
|
||||||
|
[HttpDelete("lang")]
|
||||||
|
public IActionResult DeleteLanguage()
|
||||||
|
{
|
||||||
|
Language = null;
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Eigenschaft für die Verwaltung der aktuellen Sprache über Cookies.
|
||||||
|
/// </summary>
|
||||||
|
private string? Language
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var cookieValue = Request.Cookies[CookieRequestCultureProvider.DefaultCookieName];
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(cookieValue))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var culture = CookieRequestCultureProvider.ParseCookieValue(cookieValue)?.Cultures[0];
|
||||||
|
return culture?.Value ?? null;
|
||||||
|
}
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (value is null)
|
||||||
|
Response.Cookies.Delete(CookieRequestCultureProvider.DefaultCookieName);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var cookieOptions = new CookieOptions()
|
||||||
|
{
|
||||||
|
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
||||||
|
Secure = false,
|
||||||
|
SameSite = SameSiteMode.Strict,
|
||||||
|
HttpOnly = true
|
||||||
|
};
|
||||||
|
|
||||||
|
Response.Cookies.Append(
|
||||||
|
CookieRequestCultureProvider.DefaultCookieName,
|
||||||
|
CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(value)),
|
||||||
|
cookieOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using DigitalData.Core.Abstraction.Application.DTO;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiverReadOnly;
|
||||||
|
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Server.Extensions;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages read-only envelope sharing flows.
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public class ReadOnlyController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<ReadOnlyController> _logger;
|
||||||
|
private readonly IEnvelopeReceiverReadOnlyService _readOnlyService;
|
||||||
|
private readonly IEnvelopeMailService _mailService;
|
||||||
|
private readonly IEnvelopeHistoryService _historyService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ReadOnlyController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public ReadOnlyController(ILogger<ReadOnlyController> logger, IEnvelopeReceiverReadOnlyService readOnlyService, IEnvelopeMailService mailService, IEnvelopeHistoryService historyService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_readOnlyService = readOnlyService;
|
||||||
|
_mailService = mailService;
|
||||||
|
_historyService = historyService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new read-only receiver for the current envelope.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="createDto">Creation payload.</param>
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[Obsolete("Use MediatR")]
|
||||||
|
public async Task<IActionResult> CreateAsync([FromBody] EnvelopeReceiverReadOnlyCreateDto createDto)
|
||||||
|
{
|
||||||
|
var authReceiverMail = User.ReceiverMail();
|
||||||
|
if (authReceiverMail is null)
|
||||||
|
{
|
||||||
|
_logger.LogError("EmailAddress claim is not found in envelope-receiver-read-only creation process. Create DTO is:\n {dto}", JsonConvert.SerializeObject(createDto));
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelopeId = User.EnvelopeId();
|
||||||
|
|
||||||
|
createDto.AddedWho = authReceiverMail;
|
||||||
|
createDto.EnvelopeId = envelopeId;
|
||||||
|
|
||||||
|
var creationRes = await _readOnlyService.CreateAsync(createDto: createDto);
|
||||||
|
|
||||||
|
if (creationRes.IsFailed)
|
||||||
|
{
|
||||||
|
_logger.LogNotice(creationRes);
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var readRes = await _readOnlyService.ReadByIdAsync(creationRes.Data.Id);
|
||||||
|
if (readRes.IsFailed)
|
||||||
|
{
|
||||||
|
_logger.LogNotice(creationRes);
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||||
|
}
|
||||||
|
|
||||||
|
var newReadOnly = readRes.Data;
|
||||||
|
|
||||||
|
return await _mailService.SendAsync(newReadOnly).ThenAsync<int, IActionResult>(SuccessAsync: async _ =>
|
||||||
|
{
|
||||||
|
var histRes = await _historyService.RecordAsync((int)createDto.EnvelopeId, createDto.AddedWho, EnvelopeStatus.EnvelopeShared);
|
||||||
|
if (histRes.IsFailed)
|
||||||
|
{
|
||||||
|
_logger.LogError("Although the envelope was sent as read-only, the EnvelopeShared history could not be saved. Create DTO:\n{createDto}", JsonConvert.SerializeObject(createDto));
|
||||||
|
_logger.LogNotice(histRes.Notices);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok();
|
||||||
|
},
|
||||||
|
|
||||||
|
Fail: (msg, ntc) =>
|
||||||
|
{
|
||||||
|
_logger.LogNotice(ntc);
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using MediatR;
|
||||||
|
using EnvelopeGenerator.Application.Receivers.Queries;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controller für die Verwaltung von Empfängern.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Dieser Controller bietet Endpunkte für das Abrufen von Empfängern basierend auf E-Mail-Adresse oder Signatur.
|
||||||
|
/// </remarks>
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
public class ReceiverController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initialisiert eine neue Instanz des <see cref="ReceiverController"/>-Controllers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="mediator">Mediator für Anfragen.</param>
|
||||||
|
public ReceiverController(IMediator mediator)
|
||||||
|
{
|
||||||
|
_mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ruft eine Liste von Empfängern ab, basierend auf den angegebenen Abfrageparametern.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="receiver">Die Abfrageparameter, einschließlich E-Mail-Adresse und Signatur.</param>
|
||||||
|
/// <returns>Eine Liste von Empfängern oder ein Fehlerstatus.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||||
|
public async Task<IActionResult> Get([FromQuery] ReadReceiverQuery? receiver = null, [FromQuery] bool onlyEmailAddress = false)
|
||||||
|
{
|
||||||
|
var result = await _mediator.Send(receiver ?? new ReadReceiverQuery());
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
return NotFound();
|
||||||
|
else if (onlyEmailAddress)
|
||||||
|
return Ok(result.Select(r => r.EmailAddress).ToList());
|
||||||
|
else
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using EnvelopeGenerator.Server.Extensions;
|
||||||
|
using EnvelopeGenerator.Application.Common.Dto;
|
||||||
|
using EnvelopeGenerator.Application.Common.Extensions;
|
||||||
|
using EnvelopeGenerator.Application.Documents.Queries;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class SignatureController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of <see cref="SignatureController"/>.
|
||||||
|
/// </summary>
|
||||||
|
public SignatureController(IMediator mediator)
|
||||||
|
{
|
||||||
|
_mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: update to use signature query
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="envelopeKey"></param>
|
||||||
|
/// <param name="cancel"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[HttpGet("{envelopeKey}")]
|
||||||
|
public async Task<IActionResult> Get(string envelopeKey, CancellationToken cancel)
|
||||||
|
{
|
||||||
|
int envelopeId = User.EnvelopeId();
|
||||||
|
|
||||||
|
int receiverId = User.ReceiverId();
|
||||||
|
|
||||||
|
var doc = await _mediator.Send(new ReadDocumentQuery() { EnvelopeId = envelopeId }, cancel);
|
||||||
|
|
||||||
|
if (doc.Elements is not IEnumerable<DocReceiverElementDto> docSignatures)
|
||||||
|
return NotFound("Document is empty.");
|
||||||
|
|
||||||
|
var rcvSignatures = docSignatures.Where(s => s.ReceiverId == receiverId).ToList();
|
||||||
|
|
||||||
|
if (rcvSignatures is null)
|
||||||
|
return NotFound("No signatures found for the current receiver.");
|
||||||
|
else
|
||||||
|
return Ok(rcvSignatures);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
using DigitalData.Core.Abstraction.Application.DTO;
|
||||||
|
using EnvelopeGenerator.Application.Common.Extensions;
|
||||||
|
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||||
|
using EnvelopeGenerator.Application.Resources;
|
||||||
|
using EnvelopeGenerator.Domain.Constants;
|
||||||
|
using EnvelopeGenerator.Server.Models;
|
||||||
|
using Ganss.Xss;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exposes endpoints for registering and managing two-factor authentication for envelope receivers.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/tfa")]
|
||||||
|
public class TfaRegistrationController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ILogger<TfaRegistrationController> _logger;
|
||||||
|
private readonly IEnvelopeReceiverService _envelopeReceiverService;
|
||||||
|
private readonly IAuthenticator _authenticator;
|
||||||
|
private readonly IReceiverService _receiverService;
|
||||||
|
private readonly TFARegParams _parameters;
|
||||||
|
private readonly IStringLocalizer<Resource> _localizer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TfaRegistrationController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public TfaRegistrationController(
|
||||||
|
ILogger<TfaRegistrationController> logger,
|
||||||
|
IEnvelopeReceiverService envelopeReceiverService,
|
||||||
|
IAuthenticator authenticator,
|
||||||
|
IReceiverService receiverService,
|
||||||
|
IOptions<TFARegParams> tfaRegParamsOptions,
|
||||||
|
IStringLocalizer<Resource> localizer)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_envelopeReceiverService = envelopeReceiverService;
|
||||||
|
_authenticator = authenticator;
|
||||||
|
_receiverService = receiverService;
|
||||||
|
_parameters = tfaRegParamsOptions.Value;
|
||||||
|
_localizer = localizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates registration metadata (QR code and deadline) for a receiver.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="envelopeReceiverId">Encoded envelope receiver id.</param>
|
||||||
|
[Authorize]
|
||||||
|
[HttpGet("{envelopeReceiverId}")]
|
||||||
|
public async Task<IActionResult> RegisterAsync(string envelopeReceiverId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (uuid, signature) = envelopeReceiverId.DecodeEnvelopeReceiverId();
|
||||||
|
|
||||||
|
if (uuid is null || signature is null)
|
||||||
|
{
|
||||||
|
_logger.LogEnvelopeError(uuid: uuid, signature: signature, message: _localizer.WrongEnvelopeReceiverId());
|
||||||
|
return Unauthorized(new { message = _localizer.WrongEnvelopeReceiverId() });
|
||||||
|
}
|
||||||
|
|
||||||
|
var secretResult = await _envelopeReceiverService.ReadWithSecretByUuidSignatureAsync(uuid: uuid, signature: signature);
|
||||||
|
if (secretResult.IsFailed)
|
||||||
|
{
|
||||||
|
_logger.LogNotice(secretResult.Notices);
|
||||||
|
return NotFound(new { message = _localizer.WrongEnvelopeReceiverId() });
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelopeReceiver = secretResult.Data;
|
||||||
|
|
||||||
|
if (!envelopeReceiver.Envelope!.TFAEnabled)
|
||||||
|
return Unauthorized(new { message = _localizer.WrongAccessCode() });
|
||||||
|
|
||||||
|
var receiver = envelopeReceiver.Receiver;
|
||||||
|
receiver!.TotpSecretkey = _authenticator.GenerateTotpSecretKey();
|
||||||
|
await _receiverService.UpdateAsync(receiver);
|
||||||
|
var totpQr64 = _authenticator.GenerateTotpQrCode(userEmail: receiver.EmailAddress, secretKey: receiver.TotpSecretkey).ToBase64String();
|
||||||
|
|
||||||
|
if (receiver.TfaRegDeadline is null)
|
||||||
|
{
|
||||||
|
receiver.TfaRegDeadline = _parameters.Deadline;
|
||||||
|
await _receiverService.UpdateAsync(receiver);
|
||||||
|
}
|
||||||
|
else if (receiver.TfaRegDeadline <= DateTime.Now)
|
||||||
|
{
|
||||||
|
return StatusCode(StatusCodes.Status410Gone, new { message = _localizer.WrongAccessCode() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
envelopeReceiver.EnvelopeId,
|
||||||
|
envelopeReceiver.Envelope!.Uuid,
|
||||||
|
envelopeReceiver.Receiver!.Signature,
|
||||||
|
receiver.TfaRegDeadline,
|
||||||
|
TotpQR64 = totpQr64
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogEnvelopeError(envelopeReceiverId: envelopeReceiverId, exception: ex, message: _localizer.WrongEnvelopeReceiverId());
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError, new { message = _localizer.UnexpectedError() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Logs out the envelope receiver from cookie authentication.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||||
|
[HttpPost("auth/logout")]
|
||||||
|
public async Task<IActionResult> LogOutAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "{message}", ex.Message);
|
||||||
|
return StatusCode(StatusCodes.Status500InternalServerError, new { message = _localizer.UnexpectedError() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using EnvelopeGenerator.Server.Models;
|
||||||
|
using Microsoft.OpenApi.Any;
|
||||||
|
using Microsoft.OpenApi.Models;
|
||||||
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Documentation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthProxyDocumentFilter : IDocumentFilter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="swaggerDoc"></param>
|
||||||
|
/// <param name="context"></param>
|
||||||
|
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
||||||
|
{
|
||||||
|
AddLoginOperation(swaggerDoc, context);
|
||||||
|
AddEnvelopeReceiverLoginOperation(swaggerDoc, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddLoginOperation(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
||||||
|
{
|
||||||
|
const string path = "/api/auth";
|
||||||
|
|
||||||
|
var loginSchema = context.SchemaGenerator.GenerateSchema(typeof(Login), context.SchemaRepository);
|
||||||
|
var loginExample = new OpenApiObject
|
||||||
|
{
|
||||||
|
["password"] = new OpenApiString(""),
|
||||||
|
["username"] = new OpenApiString("")
|
||||||
|
};
|
||||||
|
|
||||||
|
var operation = new OpenApiOperation
|
||||||
|
{
|
||||||
|
Summary = "Proxy login (auth-hub)",
|
||||||
|
Description = "Proxies the request to the auth service. Add query parameter `cookie=true|false`.",
|
||||||
|
Tags = [new() { Name = "Auth" }],
|
||||||
|
Parameters =
|
||||||
|
{
|
||||||
|
new OpenApiParameter
|
||||||
|
{
|
||||||
|
Name = "cookie",
|
||||||
|
In = ParameterLocation.Query,
|
||||||
|
Required = false,
|
||||||
|
Schema = new OpenApiSchema { Type = "boolean", Default = new OpenApiBoolean(true) },
|
||||||
|
Example = new OpenApiBoolean(true),
|
||||||
|
Description = "If true, auth service sets the auth cookie."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RequestBody = new OpenApiRequestBody
|
||||||
|
{
|
||||||
|
Required = true,
|
||||||
|
Content =
|
||||||
|
{
|
||||||
|
["application/json"] = new OpenApiMediaType { Schema = loginSchema, Example = loginExample },
|
||||||
|
["multipart/form-data"] = new OpenApiMediaType { Schema = loginSchema, Example = loginExample }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Responses =
|
||||||
|
{
|
||||||
|
["200"] = new OpenApiResponse { Description = "OK (proxied response)" },
|
||||||
|
["401"] = new OpenApiResponse { Description = "Unauthorized" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
swaggerDoc.Paths[path] = new OpenApiPathItem
|
||||||
|
{
|
||||||
|
Operations =
|
||||||
|
{
|
||||||
|
[OperationType.Post] = operation
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddEnvelopeReceiverLoginOperation(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
||||||
|
{
|
||||||
|
const string path = "/api/Auth/envelope-receiver/{key}";
|
||||||
|
|
||||||
|
var bodySchema = context.SchemaGenerator.GenerateSchema(typeof(EnvelopeReceiverLogin), context.SchemaRepository);
|
||||||
|
|
||||||
|
var operation = new OpenApiOperation
|
||||||
|
{
|
||||||
|
Summary = "Envelope receiver login (auth-hub proxy)",
|
||||||
|
Description = "Proxies the envelope receiver login to the auth service. " +
|
||||||
|
"The `cookie` query parameter is always forwarded as `true` so the auth service sets the per-envelope cookie automatically.",
|
||||||
|
Tags = [new() { Name = "Auth" }],
|
||||||
|
Parameters =
|
||||||
|
{
|
||||||
|
new OpenApiParameter
|
||||||
|
{
|
||||||
|
Name = "key",
|
||||||
|
In = ParameterLocation.Path,
|
||||||
|
Required = true,
|
||||||
|
Schema = new OpenApiSchema { Type = "string" },
|
||||||
|
Description = "The unique envelope receiver key."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
RequestBody = new OpenApiRequestBody
|
||||||
|
{
|
||||||
|
Required = false,
|
||||||
|
Content =
|
||||||
|
{
|
||||||
|
["multipart/form-data"] = new OpenApiMediaType { Schema = bodySchema }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Responses =
|
||||||
|
{
|
||||||
|
["200"] = new OpenApiResponse { Description = "OK – per-envelope cookie set by auth service." },
|
||||||
|
["401"] = new OpenApiResponse { Description = "Unauthorized – invalid or missing access code." }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
swaggerDoc.Paths[path] = new OpenApiPathItem
|
||||||
|
{
|
||||||
|
Operations =
|
||||||
|
{
|
||||||
|
[OperationType.Post] = operation
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj" />
|
||||||
|
<ProjectReference Include="..\..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="8.0.22" />
|
||||||
|
<PackageReference Include="DevExpress.Blazor" Version="25.2.3" />
|
||||||
|
<PackageReference Include="DevExpress.Blazor.PdfViewer" Version="25.2.3" />
|
||||||
|
<PackageReference Include="DevExpress.Blazor.Reporting.Viewer" Version="25.2.3" />
|
||||||
|
|
||||||
|
<!-- API Packages from EnvelopeGenerator.Server -->
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
|
||||||
|
<PackageReference Include="AspNetCore.Scalar" Version="1.1.8" />
|
||||||
|
<PackageReference Include="DigitalData.Auth.Claims" Version="1.0.3" />
|
||||||
|
<PackageReference Include="DigitalData.Auth.Client" Version="1.3.7" />
|
||||||
|
<PackageReference Include="DigitalData.Core.API" Version="2.2.1" />
|
||||||
|
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.11" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.17" />
|
||||||
|
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
|
||||||
|
<PackageReference Include="NLog" Version="5.2.5" />
|
||||||
|
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.0" />
|
||||||
|
<PackageReference Include="PDFsharp" Version="6.2.4" />
|
||||||
|
<PackageReference Include="Scalar.AspNetCore" Version="2.2.1" />
|
||||||
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
|
||||||
|
<PackageReference Include="DigitalData.EmailProfilerDispatcher.Abstraction" Version="3.2.0" />
|
||||||
|
<PackageReference Include="System.DirectoryServices" Version="8.0.0" />
|
||||||
|
<PackageReference Include="System.DirectoryServices.AccountManagement" Version="8.0.1" />
|
||||||
|
<PackageReference Include="System.DirectoryServices.Protocols" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Update="wwwroot\docs\privacy-policy.en-US.html">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Update="wwwroot\docs\privacy-policy.fr-FR.html">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Resources\Invoice.pdf" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using DigitalData.Auth.Claims;
|
||||||
|
using Microsoft.IdentityModel.JsonWebTokens;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides helper methods for working with envelope-specific authentication claims.
|
||||||
|
/// </summary>
|
||||||
|
public static class ReceiverClaimExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user"></param>
|
||||||
|
/// <param name="claimType"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
/// <exception cref="InvalidOperationException"></exception>
|
||||||
|
private static string GetRequiredClaimValue(this ClaimsPrincipal user, string claimType)
|
||||||
|
{
|
||||||
|
var value = user.FindFirstValue(claimType);
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = user.Identity;
|
||||||
|
var principalName = identity?.Name ?? "(anonymous)";
|
||||||
|
var authType = identity?.AuthenticationType ?? "(none)";
|
||||||
|
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
|
||||||
|
var message = $"Required claim '{claimType}' is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
|
||||||
|
throw new InvalidOperationException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetRequiredClaimValue(this ClaimsPrincipal user, params string[] claimTypes)
|
||||||
|
{
|
||||||
|
foreach (var claimType in claimTypes.Where(t => !string.IsNullOrWhiteSpace(t)).Distinct())
|
||||||
|
{
|
||||||
|
var value = user.FindFirstValue(claimType);
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = user.Identity;
|
||||||
|
var principalName = identity?.Name ?? "(anonymous)";
|
||||||
|
var authType = identity?.AuthenticationType ?? "(none)";
|
||||||
|
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
|
||||||
|
var message = $"Required claim(s) '{string.Join("', '", claimTypes)}' are missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
|
||||||
|
throw new InvalidOperationException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the authenticated envelope UUID from the claims.
|
||||||
|
/// </summary>
|
||||||
|
public static string EnvelopeUuid(this ClaimsPrincipal user)
|
||||||
|
=> user.GetRequiredClaimValue(EnvelopeClaimNames.EnvelopeUuid);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the authenticated receiver signature from the claims.
|
||||||
|
/// </summary>
|
||||||
|
public static string ReceiverSignature(this ClaimsPrincipal user)
|
||||||
|
=> user.GetRequiredClaimValue(EnvelopeClaimNames.ReceiverSignature);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the authenticated receiver email address from the claims.
|
||||||
|
/// </summary>
|
||||||
|
public static string ReceiverMail(this ClaimsPrincipal user)
|
||||||
|
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the authenticated envelope identifier from the claims.
|
||||||
|
/// </summary>
|
||||||
|
public static int EnvelopeId(this ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
var envIdStr = user.GetRequiredClaimValue(EnvelopeClaimNames.EnvelopeId);
|
||||||
|
if (int.TryParse(envIdStr, out var envId))
|
||||||
|
return envId;
|
||||||
|
else
|
||||||
|
throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.EnvelopeId}' is not a valid integer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the authenticated receiver identifier from the claims.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
/// <exception cref="InvalidOperationException"></exception>
|
||||||
|
public static int ReceiverId(this ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
var rcvIdStr = user.GetRequiredClaimValue(EnvelopeClaimNames.ReceiverId);
|
||||||
|
if (int.TryParse(rcvIdStr, out var rcvId))
|
||||||
|
return rcvId;
|
||||||
|
else
|
||||||
|
throw new InvalidOperationException($"Claim '{EnvelopeClaimNames.ReceiverId}' is not a valid integer.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Extensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Provides extension methods for extracting user information from a <see cref="ClaimsPrincipal"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class SenderClaimExtensions
|
||||||
|
{
|
||||||
|
private static string GetRequiredClaimOfSender(this ClaimsPrincipal user, string claimType)
|
||||||
|
{
|
||||||
|
var value = user.FindFirstValue(claimType);
|
||||||
|
if (value is not null)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = user.Identity;
|
||||||
|
var principalName = identity?.Name ?? "(anonymous)";
|
||||||
|
var authType = identity?.AuthenticationType ?? "(none)";
|
||||||
|
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
|
||||||
|
var message = $"Required claim '{claimType}' is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
|
||||||
|
throw new InvalidOperationException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetRequiredClaimOfSender(this ClaimsPrincipal user, params string[] claimTypes)
|
||||||
|
{
|
||||||
|
string? value = null;
|
||||||
|
|
||||||
|
foreach (var claimType in claimTypes)
|
||||||
|
{
|
||||||
|
value = user.FindFirstValue(claimType);
|
||||||
|
if (value is not null)
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var identity = user.Identity;
|
||||||
|
var principalName = identity?.Name ?? "(anonymous)";
|
||||||
|
var authType = identity?.AuthenticationType ?? "(none)";
|
||||||
|
var availableClaims = string.Join(", ", user.Claims.Select(c => $"{c.Type}={c.Value}"));
|
||||||
|
var message = $"Required claim among [{string.Join(", ", claimTypes)}] is missing for user '{principalName}' (auth: {authType}). Available claims: [{availableClaims}].";
|
||||||
|
throw new InvalidOperationException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the user's ID from the claims. Throws an exception if the ID is missing or invalid.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
|
||||||
|
/// <returns>The user's ID as an integer.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">Thrown if the user ID claim is missing or invalid.</exception>
|
||||||
|
public static int GetId(this ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
var idValue = user.GetRequiredClaimOfSender(ClaimTypes.NameIdentifier, "sub");
|
||||||
|
|
||||||
|
if (!int.TryParse(idValue, out var result))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("User ID claim is missing or invalid. This may indicate a misconfigured or forged JWT token.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the username from the claims.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
|
||||||
|
/// <returns>The username as a string.</returns>
|
||||||
|
public static string GetUsername(this ClaimsPrincipal user)
|
||||||
|
=> user.GetRequiredClaimOfSender(ClaimTypes.Name);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the user's surname (last name) from the claims.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
|
||||||
|
/// <returns>The surname as a string.</returns>
|
||||||
|
public static string GetName(this ClaimsPrincipal user)
|
||||||
|
=> user.GetRequiredClaimOfSender(ClaimTypes.Surname);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the user's given name (first name) from the claims.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
|
||||||
|
/// <returns>The given name as a string.</returns>
|
||||||
|
public static string GetPrename(this ClaimsPrincipal user)
|
||||||
|
=> user.GetRequiredClaimOfSender(ClaimTypes.GivenName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the user's email address from the claims.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="user">The <see cref="ClaimsPrincipal"/> representing the user.</param>
|
||||||
|
/// <returns>The email address as a string.</returns>
|
||||||
|
public static string GetEmail(this ClaimsPrincipal user)
|
||||||
|
=> user.GetRequiredClaimOfSender(ClaimTypes.Email);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
EnvelopeGenerator.Server/EnvelopeGenerator.Server/Jenkinsfile
vendored
Normal file
10
EnvelopeGenerator.Server/EnvelopeGenerator.Server/Jenkinsfile
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
stages {
|
||||||
|
stage('Build') {
|
||||||
|
steps {
|
||||||
|
sh 'dotnet build'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Middleware;
|
||||||
|
|
||||||
|
using DigitalData.Core.Exceptions;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Middleware for handling exceptions globally in the application.
|
||||||
|
/// Captures exceptions thrown during the request pipeline execution,
|
||||||
|
/// logs them, and returns an appropriate HTTP response with a JSON error message.
|
||||||
|
/// </summary>
|
||||||
|
public class ExceptionHandlingMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ExceptionHandlingMiddleware"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="next">The next middleware in the request pipeline.</param>
|
||||||
|
/// <param name="logger">The logger instance for logging exceptions.</param>
|
||||||
|
public ExceptionHandlingMiddleware(RequestDelegate next, ILogger<ExceptionHandlingMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invokes the middleware to handle the HTTP request.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The HTTP context of the current request.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context); // Continue down the pipeline
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await HandleExceptionAsync(context, ex, _logger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles exceptions by logging them and writing an appropriate JSON response.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">The HTTP context of the current request.</param>
|
||||||
|
/// <param name="exception">The exception that occurred.</param>
|
||||||
|
/// <param name="logger">The logger instance for logging the exception.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
private static async Task HandleExceptionAsync(HttpContext context, Exception exception, ILogger logger)
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "application/json";
|
||||||
|
|
||||||
|
string message;
|
||||||
|
|
||||||
|
switch (exception)
|
||||||
|
{
|
||||||
|
case BadRequestException badRequestEx:
|
||||||
|
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
|
||||||
|
message = badRequestEx.Message;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case NotFoundException notFoundEx:
|
||||||
|
context.Response.StatusCode = (int)HttpStatusCode.NotFound;
|
||||||
|
message = notFoundEx.Message;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.LogError(exception, "Unhandled exception occurred.");
|
||||||
|
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||||
|
message = "An unexpected error occurred.";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.Response.WriteAsync(JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
message
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Models;
|
||||||
|
|
||||||
|
[Obsolete("Use auth DTO")]
|
||||||
|
public record Auth(string? AccessCode = null, string? SmsCode = null, string? AuthenticatorCode = null, bool UserSelectSMS = default)
|
||||||
|
{
|
||||||
|
public bool HasAccessCode => AccessCode is not null;
|
||||||
|
|
||||||
|
public bool HasSmsCode => SmsCode is not null;
|
||||||
|
|
||||||
|
public bool HasAuthenticatorCode => AuthenticatorCode is not null;
|
||||||
|
|
||||||
|
public bool HasMulti => new[] { HasAccessCode, HasSmsCode, HasAuthenticatorCode }.Count(state => state) > 1;
|
||||||
|
|
||||||
|
public bool HasNone => !(HasAccessCode || HasSmsCode || HasAuthenticatorCode);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the keys and default values used for authentication token handling
|
||||||
|
/// within the Envelope Generator Server.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthTokenKeys
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of the cookie used to store the authentication token.
|
||||||
|
/// </summary>
|
||||||
|
public string Cookie { get; init; } = "AuthToken";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of the query string parameter used to pass the authentication token.
|
||||||
|
/// </summary>
|
||||||
|
public string QueryString { get; init; } = "AuthToken";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the expected issuer value for the authentication token.
|
||||||
|
/// </summary>
|
||||||
|
public string Issuer { get; init; } = "auth.digitaldata.works";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the expected audience value for the authentication token.
|
||||||
|
/// </summary>
|
||||||
|
public string Audience { get; init; } = "sign-flow.digitaldata.works";
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the database connection string for dependency injection.
|
||||||
|
/// </summary>
|
||||||
|
public class ConnectionString
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The database connection string value.
|
||||||
|
/// </summary>
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a hyperlink for contact purposes with various HTML attributes.
|
||||||
|
/// </summary>
|
||||||
|
public class ContactLink
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the label of the hyperlink.
|
||||||
|
/// </summary>
|
||||||
|
public string Label { get; init; } = "Contact";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the URL that the hyperlink points to.
|
||||||
|
/// </summary>
|
||||||
|
public string Href { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the target where the hyperlink should open.
|
||||||
|
/// Commonly used values are "_blank", "_self", "_parent", "_top".
|
||||||
|
/// </summary>
|
||||||
|
public string Target { get; set; } = "_blank";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the relationship of the linked URL as space-separated link types.
|
||||||
|
/// Examples include "nofollow", "noopener", "noreferrer".
|
||||||
|
/// </summary>
|
||||||
|
public string Rel { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the filename that should be downloaded when clicking the hyperlink.
|
||||||
|
/// This attribute will only have an effect if the href attribute is set.
|
||||||
|
/// </summary>
|
||||||
|
public string Download { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the language of the linked resource. Useful when linking to
|
||||||
|
/// content in another language.
|
||||||
|
/// </summary>
|
||||||
|
public string HrefLang { get; set; } = "en";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the MIME type of the linked URL. Helps browsers to handle
|
||||||
|
/// the type correctly when the link is clicked.
|
||||||
|
/// </summary>
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets additional information about the hyperlink, typically viewed
|
||||||
|
/// as a tooltip when the mouse hovers over the link.
|
||||||
|
/// </summary>
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets an identifier for the hyperlink, unique within the HTML document.
|
||||||
|
/// </summary>
|
||||||
|
public string Id { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Models;
|
||||||
|
|
||||||
|
public class Culture
|
||||||
|
{
|
||||||
|
private string _language = string.Empty;
|
||||||
|
public string Language { get => _language;
|
||||||
|
init {
|
||||||
|
_language = value;
|
||||||
|
Info = new(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public string FIClass { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public CultureInfo? Info { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Models;
|
||||||
|
|
||||||
|
public class Cultures : List<Culture>
|
||||||
|
{
|
||||||
|
public IEnumerable<string> Languages => this.Select(c => c.Language);
|
||||||
|
|
||||||
|
public IEnumerable<string> FIClasses => this.Select(c => c.FIClass);
|
||||||
|
|
||||||
|
public Culture Default => this.First();
|
||||||
|
|
||||||
|
public Culture? this[string? language] => language is null ? null : this.Where(c => c.Language == language).FirstOrDefault();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Models;
|
||||||
|
|
||||||
|
public class CustomImages : Dictionary<string, Image>
|
||||||
|
{
|
||||||
|
public new Image this[string key] => TryGetValue(key, out var img) && img is not null ? img : new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request body for the envelope-receiver login endpoint.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="AccessCode">The access code sent to the receiver.</param>
|
||||||
|
public record EnvelopeReceiverLogin(string? AccessCode = null);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Models;
|
||||||
|
|
||||||
|
public class ErrorViewModel
|
||||||
|
{
|
||||||
|
public string Title { get; init; } = "404";
|
||||||
|
|
||||||
|
public string Subtitle { get; init; } = "Hmmm...";
|
||||||
|
|
||||||
|
public string Body { get; init; } = "It looks like one of the developers fell asleep";
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Models;
|
||||||
|
|
||||||
|
public class Image
|
||||||
|
{
|
||||||
|
public string Src { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
public Dictionary<string, string> Classes { get; init; } = new();
|
||||||
|
|
||||||
|
public string GetClassIn(string page) => Classes.TryGetValue(page, out var cls) && cls is not null ? cls : string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Repräsentiert ein Login-Modell mit erforderlichem Passwort und optionaler ID und Benutzername.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Password">Das erforderliche Passwort für das Login.</param>
|
||||||
|
/// <param name="UserId">Die optionale ID des Benutzers.</param>
|
||||||
|
/// <param name="Username">Der optionale Benutzername.</param>
|
||||||
|
public record Login([Required] string Password, int? UserId = null, string? Username = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Models;
|
||||||
|
|
||||||
|
public class MainViewModel
|
||||||
|
{
|
||||||
|
public string? Title { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using EnvelopeGenerator.Server.Models.PsPdfKitAnnotation;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Models.PsPdfKitAnnotation;
|
||||||
|
|
||||||
|
public record Annotation : IAnnotation
|
||||||
|
{
|
||||||
|
public required string Name { get; init; }
|
||||||
|
|
||||||
|
#region Bound Annotation
|
||||||
|
[JsonIgnore]
|
||||||
|
public string? HorBoundAnnotName { get; init; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string? VerBoundAnnotName { get; init; }
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Layout
|
||||||
|
[JsonIgnore]
|
||||||
|
public double? MarginLeft { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public double MarginLeftRatio { get; init; } = 1;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public double? MarginTop { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public double MarginTopRatio { get; init; } = 1;
|
||||||
|
|
||||||
|
public double? Width { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public double WidthRatio { get; init; } = 1;
|
||||||
|
|
||||||
|
public double? Height { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public double HeightRatio { get; init; } = 1;
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Position
|
||||||
|
public double Left => (MarginLeft ?? 0) + (HorBoundAnnot?.HorBoundary ?? 0);
|
||||||
|
|
||||||
|
public double Top => (MarginTop ?? 0) + (VerBoundAnnot?.VerBoundary ?? 0);
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Boundary
|
||||||
|
[JsonIgnore]
|
||||||
|
public double HorBoundary => Left + (Width ?? 0);
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public double VerBoundary => Top + (Height ?? 0);
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region BoundAnnot
|
||||||
|
[JsonIgnore]
|
||||||
|
public Annotation? HorBoundAnnot { get; set; }
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public Annotation? VerBoundAnnot { get; set; }
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
public Color? BackgroundColor { get; init; }
|
||||||
|
|
||||||
|
#region Border
|
||||||
|
public Color? BorderColor { get; init; }
|
||||||
|
|
||||||
|
public string? BorderStyle { get; init; }
|
||||||
|
|
||||||
|
public int? BorderWidth { get; set; }
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
internal Annotation Default
|
||||||
|
{
|
||||||
|
set
|
||||||
|
{
|
||||||
|
// To set null value, annotation must have null (0) value but null must has non-null value
|
||||||
|
if (MarginLeft == null && value.MarginLeft != null)
|
||||||
|
MarginLeft = value.MarginLeft * MarginLeftRatio;
|
||||||
|
|
||||||
|
if (MarginTop == null && value.MarginTop != null)
|
||||||
|
MarginTop = value.MarginTop * MarginTopRatio;
|
||||||
|
|
||||||
|
if (Width == null && value.Width != null)
|
||||||
|
Width = value.Width * WidthRatio;
|
||||||
|
|
||||||
|
if (Height == null && value.Height != null)
|
||||||
|
Height = value.Height * HeightRatio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using EnvelopeGenerator.Server.Models.PsPdfKitAnnotation;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Models.PsPdfKitAnnotation;
|
||||||
|
|
||||||
|
public class AnnotationParams
|
||||||
|
{
|
||||||
|
public AnnotationParams()
|
||||||
|
{
|
||||||
|
_AnnotationJSObjectInitor = new(CreateAnnotationJSObject);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Background? Background { get; init; }
|
||||||
|
|
||||||
|
#region Annotation
|
||||||
|
[JsonIgnore]
|
||||||
|
public Annotation? DefaultAnnotation { get; init; }
|
||||||
|
|
||||||
|
private readonly List<Annotation> _annots = new List<Annotation>();
|
||||||
|
|
||||||
|
public bool TryGet(string name, out Annotation annotation)
|
||||||
|
{
|
||||||
|
#pragma warning disable CS8601 // Possible null reference assignment.
|
||||||
|
annotation = _annots.FirstOrDefault(a => a.Name == name);
|
||||||
|
#pragma warning restore CS8601 // Possible null reference assignment.
|
||||||
|
return annotation is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public required IEnumerable<Annotation> Annotations
|
||||||
|
{
|
||||||
|
get => _annots;
|
||||||
|
init
|
||||||
|
{
|
||||||
|
_annots = value.ToList();
|
||||||
|
|
||||||
|
if (DefaultAnnotation is not null)
|
||||||
|
foreach (var annot in _annots)
|
||||||
|
annot.Default = DefaultAnnotation;
|
||||||
|
|
||||||
|
for (int i = 0; i < _annots.Count; i++)
|
||||||
|
{
|
||||||
|
#region set bound annotations
|
||||||
|
// horizontal
|
||||||
|
if (_annots[i].HorBoundAnnotName is string horBoundAnnotName)
|
||||||
|
if (TryGet(horBoundAnnotName, out var horBoundAnnot))
|
||||||
|
_annots[i].HorBoundAnnot = horBoundAnnot;
|
||||||
|
else
|
||||||
|
throw new InvalidOperationException($"{horBoundAnnotName} added as bound anotation. However, it is not defined.");
|
||||||
|
|
||||||
|
// vertical
|
||||||
|
if (_annots[i].VerBoundAnnotName is string verBoundAnnotName)
|
||||||
|
if (TryGet(verBoundAnnotName, out var verBoundAnnot))
|
||||||
|
_annots[i].VerBoundAnnot = verBoundAnnot;
|
||||||
|
else
|
||||||
|
throw new InvalidOperationException($"{verBoundAnnotName} added as bound anotation. However, it is not defined.");
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region AnnotationJSObject
|
||||||
|
private Dictionary<string, IAnnotation> CreateAnnotationJSObject()
|
||||||
|
{
|
||||||
|
var dict = _annots.ToDictionary(a => a.Name.ToLower(), a => a as IAnnotation);
|
||||||
|
|
||||||
|
if (Background is not null)
|
||||||
|
{
|
||||||
|
Background.Locate(_annots);
|
||||||
|
dict.Add(Background.Name.ToLower(), Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Lazy<Dictionary<string, IAnnotation>> _AnnotationJSObjectInitor;
|
||||||
|
|
||||||
|
public Dictionary<string, IAnnotation> AnnotationJSObject => _AnnotationJSObjectInitor.Value;
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.Server.Models.PsPdfKitAnnotation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Background is an annotation for the PSPDF Kit. However, it has no function.
|
||||||
|
/// It is only the first annotation as a background for other annotations.
|
||||||
|
/// </summary>
|
||||||
|
public record Background : IAnnotation
|
||||||
|
{
|
||||||
|
[JsonIgnore]
|
||||||
|
public double Margin { get; init; }
|
||||||
|
|
||||||
|
public string Name { get; } = "Background";
|
||||||
|
|
||||||
|
public double? Width { get; set; }
|
||||||
|
|
||||||
|
public double? Height { get; set; }
|
||||||
|
|
||||||
|
public double Left { get; set; }
|
||||||
|
|
||||||
|
public double Top { get; set; }
|
||||||
|
|
||||||
|
public Color? BackgroundColor { get; init; }
|
||||||
|
|
||||||
|
#region Border
|
||||||
|
public Color? BorderColor { get; init; }
|
||||||
|
|
||||||
|
public string? BorderStyle { get; init; }
|
||||||
|
|
||||||
|
public int? BorderWidth { get; set; }
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
public void Locate(IEnumerable<IAnnotation> annotations)
|
||||||
|
{
|
||||||
|
// set Top
|
||||||
|
if (annotations.MinBy(a => a.Top)?.Top is double minTop)
|
||||||
|
Top = minTop;
|
||||||
|
|
||||||
|
// set Left
|
||||||
|
if (annotations.MinBy(a => a.Left)?.Left is double minLeft)
|
||||||
|
Left = minLeft;
|
||||||
|
|
||||||
|
// set Width
|
||||||
|
if(annotations.MaxBy(a => a.GetRight())?.GetRight() is double maxRight)
|
||||||
|
Width = maxRight - Left;
|
||||||
|
|
||||||
|
// set Height
|
||||||
|
if (annotations.MaxBy(a => a.GetBottom())?.GetBottom() is double maxBottom)
|
||||||
|
Height = maxBottom - Top;
|
||||||
|
|
||||||
|
// add margins
|
||||||
|
Top -= Margin;
|
||||||
|
Left -= Margin;
|
||||||
|
Width += Margin * 2;
|
||||||
|
Height += Margin * 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user