Compare commits
76 Commits
master
...
feat/migr-
| Author | SHA1 | Date | |
|---|---|---|---|
| 732fe92952 | |||
| 99fbb33f1c | |||
| a10ee590c9 | |||
| 03367ebc4a | |||
| 1ac7188466 | |||
| db593cb46a | |||
| 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,419 @@
|
|||||||
# 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
|
---
|
||||||
|
|
||||||
|
## Authentication Model
|
||||||
|
|
||||||
|
### Sender
|
||||||
|
Client login page uses `EnvelopeGenerator.Server.Client/Services/AuthService.cs`.
|
||||||
|
|
||||||
|
Key sender endpoints:
|
||||||
|
- `POST /api/auth?cookie=true` — login
|
||||||
|
- `GET /api/auth/check` — current sender access check
|
||||||
|
- `POST /api/auth/logout` — logout
|
||||||
|
|
||||||
|
### Receiver
|
||||||
|
Receiver authentication is **per envelope**.
|
||||||
|
|
||||||
|
Key receiver endpoints used by client services:
|
||||||
|
- `POST /api/Auth/envelope-receiver/{envelopeKey}` — submit access code
|
||||||
|
- `GET /api/auth/check/envelope/{envelopeKey}` — check access
|
||||||
|
- `POST /api/auth/logout/envelope/{envelopeKey}` — logout receiver for one envelope
|
||||||
|
|
||||||
|
Receiver cookie resolution in server auth uses an envelope-specific cookie name derived from:
|
||||||
|
- `AuthTokenSignFLOWReceiver.{envelopeKey}` pattern
|
||||||
|
|
||||||
|
### Receiver Server-Side Authorization
|
||||||
|
`EnvelopeReceiverPage.razor` does **not** rely on its own API access-check call for page authorization.
|
||||||
|
|
||||||
|
It uses:
|
||||||
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Services/EnvelopeReceiverAuthorizationService.cs`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- 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: **Migration in progress from `PDF.js` to `DxPdfViewer`**
|
||||||
|
- toolbar: page navigation, zoom, thumbnail toggle, signature navigation, signature reset
|
||||||
|
- signature popup: `DxPopup`
|
||||||
|
- thumbnail sidebar: resizable and stored in `localStorage`
|
||||||
|
|
||||||
|
### ⚠️ CRITICAL: DevExpress DxPdfViewer Control Requirements
|
||||||
|
|
||||||
|
**Verified API for installed `DevExpress.Blazor.PdfViewer` v25.2.3:**
|
||||||
|
|
||||||
|
| Property | Access | Notes |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| `DocumentContent` | `[Parameter]` GET/SET | Feed PDF as `byte[]` |
|
||||||
|
| `ZoomLevel` | `[Parameter]` GET/SET | **Factor** (not percentage): `1.5` = 150% |
|
||||||
|
| `IsSinglePagePreview` | `[Parameter]` GET/SET | Single page mode |
|
||||||
|
| `CssClass` | `[Parameter]` GET/SET | CSS class |
|
||||||
|
| `DocumentName` | `[Parameter]` GET/SET | Download filename |
|
||||||
|
| `SizeMode` | `[Parameter]` GET/SET | `Small` / `Medium` / `Large` |
|
||||||
|
| `PageCount` | Read-only GET | Total pages — **no JS call needed** |
|
||||||
|
| `ActivePageIndex` | Read-only GET | Current page (0-based) — **cannot SET** |
|
||||||
|
| `CustomizeToolbar` | Event | Only available toolbar event |
|
||||||
|
|
||||||
|
**Does NOT exist in v25.2.3 — do NOT use:**
|
||||||
|
- `GoToPageAsync()` ❌
|
||||||
|
- `GoToNextPageAsync()` ❌
|
||||||
|
- `ZoomAsync()` ❌
|
||||||
|
- `PageNumberChanged` event ❌
|
||||||
|
- `ZoomLevelChanged` event ❌
|
||||||
|
- `ToolbarVisible` property ❌
|
||||||
|
|
||||||
|
**Correct approach:**
|
||||||
|
```razor
|
||||||
|
<DxPdfViewer @ref="_pdfViewer"
|
||||||
|
DocumentContent="@_pdfDocumentContent"
|
||||||
|
ZoomLevel="@_viewerZoomLevel"
|
||||||
|
IsSinglePagePreview="true"
|
||||||
|
CustomizeToolbar="OnCustomizeToolbar" />
|
||||||
|
```
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ZoomLevel: always divide by 100 (factor, not percentage)
|
||||||
|
_viewerZoomLevel = _currentZoom / 100d; // 150 -> 1.5
|
||||||
|
|
||||||
|
// PageCount: read directly, no JS needed
|
||||||
|
_totalPages = _pdfViewer.PageCount;
|
||||||
|
|
||||||
|
// Page navigation: only via CustomizeToolbar buttons
|
||||||
|
protected void OnCustomizeToolbar(ToolbarModel toolbarModel)
|
||||||
{
|
{
|
||||||
"PdfViewer": {
|
toolbarModel.AllItems.Clear();
|
||||||
"ThumbnailBaseScale": 0.75,
|
var nextButton = new ToolbarItem
|
||||||
"ThumbnailEnableHiDPI": true,
|
{
|
||||||
"MainCanvasEnableHiDPI": true,
|
IconCssClass = "dx-icon-chevronnext",
|
||||||
"ZoomStepPercentage": 5
|
Enabled = _currentPage < _totalPages,
|
||||||
}
|
Click = async (args) =>
|
||||||
|
{
|
||||||
|
_currentPage++;
|
||||||
|
_viewerZoomLevel = _currentZoom / 100d;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
await RenderSignatureButtonsAsync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
toolbarModel.AllItems.Add(nextButton);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### JavaScript API
|
**JavaScript role after migration:**
|
||||||
**File:** `ReceiverUI/wwwroot/js/pdf-viewer.js`
|
- Overlay geometry calculations only
|
||||||
|
- Thumbnail rendering via PDF.js helper
|
||||||
|
- Custom UI interactions (sidebar resize, signature canvas)
|
||||||
|
- **NOT** for controlling DxPdfViewer page or zoom
|
||||||
|
|
||||||
```javascript
|
See `DEVEXPRESS_V25_LIMITATIONS.md` for complete verified API reference.
|
||||||
window.pdfViewer = {
|
|
||||||
initialize(canvasId, pdfDataUrl, dotNetRef),
|
### JS Assets
|
||||||
renderPage(num),
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/pdf-viewer.js`
|
||||||
renderSignatureButtons(signatures, pageNum, dotNetRef),
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/receiver-signature.js`
|
||||||
applySignature(signatureId, dataUrl, fullName, position, place),
|
|
||||||
zoomIn(), zoomOut(), dispose()
|
### 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 — EnvelopeReceiver
|
## Signature Workflow
|
||||||
|
|
||||||
**IMPORTANT:** iText7 NOT used (GPL license issue). Client-side overlay system only.
|
Receiver signatures are handled as a **viewer overlay workflow**.
|
||||||
|
|
||||||
### Workflow Steps
|
### 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.
|
||||||
|
|
||||||
1. **Page Load:**
|
### Important note
|
||||||
- Check `SignatureCacheService` for cached signature
|
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.
|
||||||
- If cached ? skip popup, load signature
|
|
||||||
- If not ? show automatic popup (mandatory)
|
|
||||||
|
|
||||||
2. **Signature Popup (DxPopup):**
|
### Signature DTO
|
||||||
- **Cannot close** (no X, no ESC, no outside-click)
|
`EnvelopeGenerator.Server.Client/Models/SignatureCaptureDto.cs`
|
||||||
- **3 Tabs:** Draw (canvas) / Text (font select) / Image (upload)
|
|
||||||
- **Required:** Full name, Place
|
|
||||||
- **Optional:** Position
|
|
||||||
- **Save ?** Store in `_capturedSignature`, cache via API
|
|
||||||
|
|
||||||
3. **Signature Buttons:**
|
|
||||||
- Render purple "Unterschreiben" buttons at signature field positions
|
|
||||||
- Coordinates: INCHES ? POINTS ? Pixels (scaled)
|
|
||||||
- File: `pdf-viewer.js` ? `renderSignatureButtons()`
|
|
||||||
|
|
||||||
4. **Apply Signature (Click "Unterschreiben"):**
|
|
||||||
- JS: Remove button, create HTML overlay
|
|
||||||
- Format: Image + separator + text (Name, Position, Place, Date)
|
|
||||||
- **NOT stamped on PDF bytes** (visual overlay only)
|
|
||||||
|
|
||||||
5. **Re-rendering:**
|
|
||||||
- Zoom/Page change ? recalculate button positions
|
|
||||||
- Session state: `_capturedSignature` (lost on refresh)
|
|
||||||
|
|
||||||
### Data Model
|
|
||||||
**File:** `ReceiverUI/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)
|
|
||||||
|
|||||||
300
DEBUG_NOTES.md
Normal file
300
DEBUG_NOTES.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# Debug Tools for DevExpress DxPdfViewer Integration
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
This document describes temporary debug tools added to diagnose DevExpress DOM structure and page navigation issues.
|
||||||
|
|
||||||
|
## IMPORTANT: TEMPORARY DEBUG CODE
|
||||||
|
|
||||||
|
**These debug tools are TEMPORARY and should be REMOVED after resolving the page navigation issue.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Debug Tools Added
|
||||||
|
|
||||||
|
### 1. Debug UI Button (Toolbar)
|
||||||
|
|
||||||
|
**Location:** `EnvelopeReceiverPage.razor` - Toolbar Section
|
||||||
|
|
||||||
|
**Visual:** Orange button with "?" icon in the PDF viewer toolbar
|
||||||
|
|
||||||
|
**What it does:**
|
||||||
|
- Opens a floating overlay panel showing DevExpress DOM analysis
|
||||||
|
- Displays all input elements found in DxPdfViewer
|
||||||
|
- Shows which CSS selectors successfully find the page input
|
||||||
|
- Provides a "Test: Go to Page 2" button for live testing
|
||||||
|
|
||||||
|
**Code Location:**
|
||||||
|
```razor
|
||||||
|
@* DEBUG: DevExpress DOM Inspector *@
|
||||||
|
<div class="pdf-toolbar__section">
|
||||||
|
<button class="pdf-toolbar__btn" @onclick="ShowDebugUI" ...>
|
||||||
|
```
|
||||||
|
|
||||||
|
**C# Method:**
|
||||||
|
```csharp
|
||||||
|
async Task ShowDebugUI()
|
||||||
|
{
|
||||||
|
await JSRuntime.InvokeVoidAsync("dxPdfViewerShowDebugUI");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. JavaScript Debug Functions
|
||||||
|
|
||||||
|
**Location:** `pdf-viewer.js`
|
||||||
|
|
||||||
|
**Functions Added:**
|
||||||
|
|
||||||
|
#### `window.dxPdfViewerDebugDOM()`
|
||||||
|
- Console-based debug function
|
||||||
|
- Logs detailed DOM analysis to browser console
|
||||||
|
- Returns analysis object for programmatic inspection
|
||||||
|
|
||||||
|
#### `window.dxPdfViewerShowDebugUI()`
|
||||||
|
- HTML overlay-based debug function
|
||||||
|
- Creates visual debug panel without console interaction
|
||||||
|
- No security warnings (no need to paste code)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
### Step 1: Run Application
|
||||||
|
```powershell
|
||||||
|
dotnet run --project EnvelopeGenerator.Server/EnvelopeGenerator.Server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Open Receiver Page
|
||||||
|
Navigate to: `https://localhost:8088/envelope/{EnvelopeKey}`
|
||||||
|
|
||||||
|
### Step 3: Click Debug Button
|
||||||
|
- Look for the **orange "?" button** in the PDF toolbar (left side, after thumbnails toggle)
|
||||||
|
- Click it to open the debug overlay
|
||||||
|
|
||||||
|
### Step 4: Review Debug Information
|
||||||
|
|
||||||
|
The overlay shows:
|
||||||
|
- **Total Inputs**: Number of input elements found
|
||||||
|
- **Input Elements**: Details of each input (type, class, ID, value)
|
||||||
|
- **Selector Tests**: Which CSS selectors work (✓) and which don't (✗)
|
||||||
|
- **Toolbar**: Whether toolbar element was found
|
||||||
|
- **DxWidget**: Whether DevExpress widget element was found
|
||||||
|
|
||||||
|
### Step 5: Test Page Navigation
|
||||||
|
Click the **"Test: Go to Page 2"** button in the overlay
|
||||||
|
|
||||||
|
### Step 6: Report Results
|
||||||
|
|
||||||
|
**Copy the following information:**
|
||||||
|
|
||||||
|
1. **Total Inputs**: X
|
||||||
|
2. **Input Details**: (type, className, id for each input)
|
||||||
|
3. **Selector Test Results**: (which selectors show ✓ FOUND)
|
||||||
|
4. **Test Result**: Did PDF actually navigate to page 2? (Yes/No)
|
||||||
|
5. **Console Messages**: Any errors or warnings in F12 console
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to Look For
|
||||||
|
|
||||||
|
### ✓ Success Indicators
|
||||||
|
- At least one selector shows **✓ FOUND**
|
||||||
|
- "Test: Go to Page 2" button actually changes PDF page
|
||||||
|
- Console shows: `✓ Found page input with selector: "..."`
|
||||||
|
|
||||||
|
### ✗ Problem Indicators
|
||||||
|
- All selectors show **✗ NOT FOUND**
|
||||||
|
- "Test: Go to Page 2" does nothing
|
||||||
|
- Console shows: `✗ Page input not found`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After Diagnosis
|
||||||
|
|
||||||
|
Once the correct selector is identified:
|
||||||
|
|
||||||
|
### 1. Update `window.dxPdfViewerGoToPage()`
|
||||||
|
Update the `selectors` array in `pdf-viewer.js` to prioritize the working selector:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const selectors = [
|
||||||
|
'WORKING_SELECTOR_HERE', // ✓ Move this to top
|
||||||
|
'input[type="number"]',
|
||||||
|
// ... rest
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Remove Debug Code
|
||||||
|
|
||||||
|
**Files to clean up:**
|
||||||
|
|
||||||
|
#### `EnvelopeReceiverPage.razor`
|
||||||
|
Remove:
|
||||||
|
```razor
|
||||||
|
@* DEBUG: DevExpress DOM Inspector *@
|
||||||
|
<div class="pdf-toolbar__section">
|
||||||
|
<button class="pdf-toolbar__btn" @onclick="ShowDebugUI" ...>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remove C# method:
|
||||||
|
```csharp
|
||||||
|
async Task ShowDebugUI() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `pdf-viewer.js`
|
||||||
|
Remove:
|
||||||
|
```javascript
|
||||||
|
// ⚠ AUTO-DEBUG: Display results in HTML overlay
|
||||||
|
window.dxPdfViewerShowDebugUI = function() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep:
|
||||||
|
- `window.dxPdfViewerDebugDOM()` - can be useful for future debugging (optional)
|
||||||
|
- `window.dxPdfViewerGoToPage()` - this is permanent (after fixing selector)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Debug UI doesn't open
|
||||||
|
- Check browser console (F12) for JavaScript errors
|
||||||
|
- Ensure `pdf-viewer.js` is loaded
|
||||||
|
- Verify DxPdfViewer has finished rendering
|
||||||
|
|
||||||
|
### "Page input not found" error
|
||||||
|
- DevExpress may not have rendered toolbar yet
|
||||||
|
- Try waiting 2-3 seconds after page load
|
||||||
|
- Check if DxPdfViewer is visible on screen
|
||||||
|
|
||||||
|
### Selector works but page doesn't change
|
||||||
|
- DevExpress may require different event sequence
|
||||||
|
- Try adding more events (focus, click, etc.)
|
||||||
|
- May need to find DevExpress client API instead
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SOLUTION: CustomizeToolbar + Manual State Tracking
|
||||||
|
|
||||||
|
**Identified root cause:**
|
||||||
|
- DevExpress v25.2.3 has no event support
|
||||||
|
- `PageNumberChanged` event does not exist
|
||||||
|
- `ZoomLevelChanged` event does not exist
|
||||||
|
- `ToolbarVisible` property does not exist
|
||||||
|
- `GoToPageAsync()` method does not exist
|
||||||
|
- Only `CustomizeToolbar` event is available
|
||||||
|
|
||||||
|
**Verified working API (v25.2.3):**
|
||||||
|
- `DocumentContent` byte[] – for feeding PDF ✓
|
||||||
|
- `ZoomLevel` double – zoom factor (1.5 = 150%) ✓
|
||||||
|
- `IsSinglePagePreview` bool – single page mode ✓
|
||||||
|
- `PageCount` int (GET only) – **replaces JS call** ✓
|
||||||
|
- `ActivePageIndex` int (GET only) – current page index ✓
|
||||||
|
- `CssClass`, `DocumentName`, `SizeMode` ✓
|
||||||
|
|
||||||
|
**Implemented strategy:**
|
||||||
|
- Create custom navigation/zoom buttons via `CustomizeToolbar`
|
||||||
|
- Manual state tracking with `_currentPage`, `_currentZoom`, `_viewerZoomLevel`
|
||||||
|
- Manually trigger overlay refresh after button clicks
|
||||||
|
- Replace JS getTotalPages() call with `_totalPages = _pdfViewer.PageCount`
|
||||||
|
|
||||||
|
**Correct code example:**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
protected void OnCustomizeToolbar(ToolbarModel toolbarModel)
|
||||||
|
{
|
||||||
|
toolbarModel.AllItems.Clear();
|
||||||
|
|
||||||
|
var prevButton = new ToolbarItem
|
||||||
|
{
|
||||||
|
Text = "Previous",
|
||||||
|
IconCssClass = "dx-icon-chevronprev",
|
||||||
|
Enabled = _currentPage > 1,
|
||||||
|
Click = async (args) =>
|
||||||
|
{
|
||||||
|
if (_currentPage > 1)
|
||||||
|
{
|
||||||
|
_currentPage--;
|
||||||
|
_viewerZoomLevel = _currentZoom / 100d; // 150 -> 1.5
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
await RenderSignatureButtonsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var nextButton = new ToolbarItem
|
||||||
|
{
|
||||||
|
Text = "Next",
|
||||||
|
IconCssClass = "dx-icon-chevronnext",
|
||||||
|
Enabled = _currentPage < _totalPages,
|
||||||
|
Click = async (args) =>
|
||||||
|
{
|
||||||
|
if (_currentPage < _totalPages)
|
||||||
|
{
|
||||||
|
_currentPage++;
|
||||||
|
_viewerZoomLevel = _currentZoom / 100d; // 150 -> 1.5
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
await RenderSignatureButtonsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toolbarModel.AllItems.Add(prevButton);
|
||||||
|
toolbarModel.AllItems.Add(nextButton);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**PageCount usage (instead of JS):**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// In OnAfterRenderAsync
|
||||||
|
if (_pdfViewer is not null && _pdfViewer.PageCount > 0)
|
||||||
|
{
|
||||||
|
_totalPages = _pdfViewer.PageCount; // JS getTotalPages() no longer needed
|
||||||
|
_pdfLoaded = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Known limitations:**
|
||||||
|
1. If user scrolls PDF, C# receives no notification, overlays may desync
|
||||||
|
2. Thumbnail navigation only updates state, cannot move viewer
|
||||||
|
3. Cross-page signature navigation limited without programmatic page switching
|
||||||
|
|
||||||
|
**See:** `DEVEXPRESS_V25_LIMITATIONS.md` – complete verified API reference
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Expected Timeline
|
||||||
|
|
||||||
|
1. ✓ **Day 1**: Add debug tools (DONE)
|
||||||
|
2. ✓ **Day 1**: Collect DOM analysis data (DONE)
|
||||||
|
3. ✓ **Day 1**: Identify root cause (DONE - v25.2.3 has no events)
|
||||||
|
4. ✓ **Day 1**: Define workaround strategy (DONE - Custom toolbar with manual tracking)
|
||||||
|
5. ✓ **Day 1**: Implement workaround (DONE)
|
||||||
|
6. ⚠ **Day 2**: Test and document limitations
|
||||||
|
7. ⚠ **Day 2**: Consider DevExpress upgrade or accept limitations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeReceiverPage.razor`
|
||||||
|
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/js/pdf-viewer.js`
|
||||||
|
- `RECEIVER_PDF_VIEWER_CONTEXT.md` (main context document - **UPDATED with new strategy**)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Debug UI uses inline styles to avoid CSS conflicts
|
||||||
|
- Overlay is positioned at `z-index: 99999` to appear above everything
|
||||||
|
- Close button removes overlay from DOM completely
|
||||||
|
- All debug output also goes to browser console for advanced inspection
|
||||||
|
- **Debug findings led to complete strategy change - see RECEIVER_PDF_VIEWER_CONTEXT.md section 12-14**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Remember: This is TEMPORARY debugging code. Delete after completing the new implementation strategy!**
|
||||||
231
DEVEXPRESS_V25_LIMITATIONS.md
Normal file
231
DEVEXPRESS_V25_LIMITATIONS.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# DevExpress Blazor PdfViewer v25.2.3 - Verified API Reference
|
||||||
|
|
||||||
|
> **Source:** All information in this document has been verified from the actual source code of `DevExpress.Blazor.PdfViewer` v25.2.3 package.
|
||||||
|
> AI-generated API suggestions (GoToPageAsync, PageNumberChanged, etc.) are NOT real – do not use them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verified Available Parameters
|
||||||
|
|
||||||
|
| Property | Type | Access | Default | Description |
|
||||||
|
|----------|------|--------|---------|-------------|
|
||||||
|
| `DocumentContent` | `byte[]` | `[Parameter]` GET/SET | – | Feeds PDF content as byte array |
|
||||||
|
| `CssClass` | `string` | `[Parameter]` GET/SET | – | Assigns CSS class to component |
|
||||||
|
| `DocumentName` | `string` | `[Parameter]` GET/SET | `"Document"` | Download filename |
|
||||||
|
| `IsSinglePagePreview` | `bool` | `[Parameter]` GET/SET | `false` | Single page mode |
|
||||||
|
| `SizeMode` | `SizeMode?` | `[Parameter]` GET/SET | `null` | `Small`, `Medium`, `Large` |
|
||||||
|
| `ZoomLevel` | `double` | `[Parameter]` GET/SET | `-1` | **Factor** (not percentage). `1.5` = 150% |
|
||||||
|
| `ActivePageIndex` | `int` | GET only | – | Active page index (0-based). No SET. |
|
||||||
|
| `PageCount` | `int` | GET only | – | Total page count in document |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Events
|
||||||
|
|
||||||
|
- **`CustomizeToolbar`** – Allows toolbar customization
|
||||||
|
- **`ZoomLevelChanged`** – Fires when ZoomLevel property changes (EventCallback<double>)
|
||||||
|
|
||||||
|
## Missing Events (NOT AVAILABLE in v25.2.3)
|
||||||
|
|
||||||
|
- **`PageNumberChanged`** / **`ActivePageIndexChanged`** – Not available
|
||||||
|
- User scrolling or native toolbar page changes do not trigger C# code
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Properties (NOT AVAILABLE in v25.2.3)
|
||||||
|
|
||||||
|
- **`ToolbarVisible`** – Not available (toolbar cannot be completely hidden)
|
||||||
|
- **`ActivePageIndex` (settable)** – Read-only; no programmatic page navigation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Methods (NOT AVAILABLE in v25.2.3)
|
||||||
|
|
||||||
|
- **`GoToPageAsync()`** – Not available
|
||||||
|
- **`GoToNextPageAsync()`** – Not available
|
||||||
|
- **`ZoomAsync()`** – Not available
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Integration Notes
|
||||||
|
|
||||||
|
### ZoomLevel takes factor, not percentage
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// CORRECT
|
||||||
|
_viewerZoomLevel = 1.5; // viewer displays "150%"
|
||||||
|
_viewerZoomLevel = _currentZoom / 100d; // _currentZoom=150 -> 1.5
|
||||||
|
|
||||||
|
// WRONG
|
||||||
|
_viewerZoomLevel = 150; // viewer displays "15000%"
|
||||||
|
```
|
||||||
|
|
||||||
|
### PageCount replaces JS call
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// CORRECT - read directly from component (no JS needed)
|
||||||
|
_totalPages = _pdfViewer.PageCount;
|
||||||
|
|
||||||
|
// OLD method (no longer needed for this purpose)
|
||||||
|
// _totalPages = await JSRuntime.InvokeAsync<int>("pdfViewer.getTotalPages");
|
||||||
|
```
|
||||||
|
|
||||||
|
### ActivePageIndex is read-only
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// CORRECT - read for state synchronization
|
||||||
|
var currentPage = _pdfViewer.ActivePageIndex + 1; // convert to 1-based
|
||||||
|
|
||||||
|
// COMPILE ERROR - no setter
|
||||||
|
// _pdfViewer.ActivePageIndex = 3; // COMPILE ERROR
|
||||||
|
```
|
||||||
|
|
||||||
|
### DocumentContent byte[] feeding
|
||||||
|
|
||||||
|
```razor
|
||||||
|
<DxPdfViewer @ref="_pdfViewer"
|
||||||
|
DocumentContent="@_pdfDocumentContent"
|
||||||
|
ZoomLevel="@_viewerZoomLevel"
|
||||||
|
IsSinglePagePreview="true" />
|
||||||
|
|
||||||
|
@code {
|
||||||
|
DxPdfViewer? _pdfViewer;
|
||||||
|
byte[]? _pdfDocumentContent; // populate in OnInitializedAsync
|
||||||
|
double _viewerZoomLevel = 1.5; // 150%
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Impact on EnvelopeReceiverPage
|
||||||
|
|
||||||
|
### Features That Don't Work
|
||||||
|
1. **Event-driven overlay updates** – No page/zoom change events
|
||||||
|
2. **Thumbnail click navigation** – Cannot navigate viewer to specific page via C# API
|
||||||
|
3. **Cross-page signature navigation** – No programmatic page change API
|
||||||
|
4. **Automatic overlay synchronization** – User scroll/native toolbar doesn't trigger C#
|
||||||
|
|
||||||
|
### Features That Work
|
||||||
|
1. **ZoomLevel binding** – Custom zoom buttons can update viewer zoom
|
||||||
|
2. **PageCount** – Total pages can be read directly from component
|
||||||
|
3. **IsSinglePagePreview** – Single page mode works
|
||||||
|
4. **DocumentContent** – byte[] feeding works perfectly
|
||||||
|
5. **CustomizeToolbar** – Only way to add custom buttons to toolbar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workaround Strategy
|
||||||
|
|
||||||
|
CustomizeToolbar event is used to add custom navigation/zoom buttons.
|
||||||
|
Manual state tracking (`_currentPage`, `_currentZoom`, `_viewerZoomLevel`) is kept in C#.
|
||||||
|
Overlay refresh is manually triggered only after button clicks.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
protected void OnCustomizeToolbar(ToolbarModel toolbarModel)
|
||||||
|
{
|
||||||
|
toolbarModel.AllItems.Clear();
|
||||||
|
|
||||||
|
var prevButton = new ToolbarItem
|
||||||
|
{
|
||||||
|
Text = "Previous",
|
||||||
|
IconCssClass = "dx-icon-chevronprev",
|
||||||
|
Enabled = _currentPage > 1,
|
||||||
|
Click = async (args) =>
|
||||||
|
{
|
||||||
|
if (_currentPage > 1)
|
||||||
|
{
|
||||||
|
_currentPage--;
|
||||||
|
_viewerZoomLevel = _currentZoom / 100d;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
await RenderSignatureButtonsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var nextButton = new ToolbarItem
|
||||||
|
{
|
||||||
|
Text = "Next",
|
||||||
|
IconCssClass = "dx-icon-chevronnext",
|
||||||
|
Enabled = _currentPage < _totalPages,
|
||||||
|
Click = async (args) =>
|
||||||
|
{
|
||||||
|
if (_currentPage < _totalPages)
|
||||||
|
{
|
||||||
|
_currentPage++;
|
||||||
|
_viewerZoomLevel = _currentZoom / 100d;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
await RenderSignatureButtonsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var zoomInButton = new ToolbarItem
|
||||||
|
{
|
||||||
|
IconCssClass = "dx-icon-plus",
|
||||||
|
Enabled = _currentZoom < 300,
|
||||||
|
Click = async (args) =>
|
||||||
|
{
|
||||||
|
_currentZoom = Math.Min(_currentZoom + 10, 300);
|
||||||
|
_viewerZoomLevel = _currentZoom / 100d; // 150 -> 1.5
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
await RenderSignatureButtonsAsync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var zoomOutButton = new ToolbarItem
|
||||||
|
{
|
||||||
|
IconCssClass = "dx-icon-minus",
|
||||||
|
Enabled = _currentZoom > 50,
|
||||||
|
Click = async (args) =>
|
||||||
|
{
|
||||||
|
_currentZoom = Math.Max(_currentZoom - 10, 50);
|
||||||
|
_viewerZoomLevel = _currentZoom / 100d; // 150 -> 1.5
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
await RenderSignatureButtonsAsync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toolbarModel.AllItems.Add(prevButton);
|
||||||
|
toolbarModel.AllItems.Add(nextButton);
|
||||||
|
toolbarModel.AllItems.Add(zoomInButton);
|
||||||
|
toolbarModel.AllItems.Add(zoomOutButton);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PageCount reading example (in OnAfterRenderAsync)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!_pdfLoaded && _pdfDocumentContent is { Length: > 0 })
|
||||||
|
{
|
||||||
|
await Task.Delay(300); // wait for DxPdfViewer to load
|
||||||
|
|
||||||
|
if (_pdfViewer is not null && _pdfViewer.PageCount > 0)
|
||||||
|
{
|
||||||
|
_totalPages = _pdfViewer.PageCount; // read directly instead of JS
|
||||||
|
_pdfLoaded = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
await RenderThumbnailsAsync();
|
||||||
|
await RenderSignatureButtonsAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Acceptable Limitations
|
||||||
|
|
||||||
|
1. If user scrolls PDF, C# `_currentPage` does not synchronize
|
||||||
|
2. Thumbnail clicks update state but cannot move DevExpress viewer to target page
|
||||||
|
3. Browser zoom gestures do not trigger overlay updates
|
||||||
|
4. Custom toolbar buttons correctly trigger overlay updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- DevExpress official documentation: https://docs.devexpress.com/Blazor/DevExpress.Blazor.PdfViewer.DxPdfViewer
|
||||||
|
- Verified package: `DevExpress.Blazor.PdfViewer` v25.2.3
|
||||||
|
- **Note:** AI-suggested APIs (GoToPageAsync, PageNumberChanged, ActivePageIndexChanged, ZoomAsync, ToolbarVisible) are NOT real. Do not use.
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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>();
|
||||||
|
|
||||||
|
|||||||
@@ -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,446 @@
|
|||||||
|
@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()
|
||||||
|
{
|
||||||
|
// TODO: Navigate to envelope creation page
|
||||||
|
JSRuntime.InvokeVoidAsync("console.log", "Create envelope clicked - not yet implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
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,71 @@
|
|||||||
|
@page "/"
|
||||||
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
<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)}", 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,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,13 @@
|
|||||||
|
@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
|
||||||
|
@using DevExpress.Blazor.PdfViewer
|
||||||
@@ -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,47 @@
|
|||||||
|
using MediatR;
|
||||||
|
using EnvelopeGenerator.Application.Receivers.Queries;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace EnvelopeGenerator.GeneratorAPI.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]
|
||||||
|
public async Task<IActionResult> Get([FromQuery] ReadReceiverQuery receiver)
|
||||||
|
{
|
||||||
|
if (!receiver.HasAnyCriteria)
|
||||||
|
{
|
||||||
|
var all = await _mediator.Send(new ReadReceiverQuery());
|
||||||
|
return Ok(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _mediator.Send(receiver);
|
||||||
|
return result is null ? NotFound() : 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,61 @@
|
|||||||
|
<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="itext" Version="8.0.5" />
|
||||||
|
<PackageReference Include="itext.bouncy-castle-adapter" Version="8.0.5" />
|
||||||
|
<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="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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Models.PsPdfKitAnnotation;
|
||||||
|
|
||||||
|
public record Color
|
||||||
|
{
|
||||||
|
public int R { get; init; } = 0;
|
||||||
|
|
||||||
|
public int G { get; init; } = 0;
|
||||||
|
|
||||||
|
public int B { get; init; } = 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Models.PsPdfKitAnnotation;
|
||||||
|
|
||||||
|
public static class Extensions
|
||||||
|
{
|
||||||
|
public static double GetRight(this IAnnotation annotation) => annotation.Left + annotation?.Width ?? 0;
|
||||||
|
|
||||||
|
public static double GetBottom(this IAnnotation annotation) => annotation.Top + annotation?.Height ?? 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace EnvelopeGenerator.Server.Models.PsPdfKitAnnotation;
|
||||||
|
|
||||||
|
public interface IAnnotation
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
double? Width { get; }
|
||||||
|
|
||||||
|
double? Height { get; }
|
||||||
|
|
||||||
|
double Left { get; }
|
||||||
|
|
||||||
|
double Top { get; }
|
||||||
|
|
||||||
|
Color? BackgroundColor { get; }
|
||||||
|
|
||||||
|
Color? BorderColor { get; }
|
||||||
|
|
||||||
|
string? BorderStyle { get; }
|
||||||
|
|
||||||
|
int? BorderWidth { get; }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user