diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 243c381659..b1ee6c8c88 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -182,6 +182,9 @@ jobs: uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 with: egress-policy: audit + allowed-endpoints: > + one.digicert.com:443 + clientauth.one.digicert.com:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/.gitignore b/.gitignore index f108270428..02bc89597b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ exampleYmlFiles/stirling/ /testing/file_snapshots SwaggerDoc.json +# Runtime storage for uploaded files and user data (not Java source code) +app/core/storage/ + # Frontend build artifacts copied to backend static resources # These are generated by npm build and should not be committed app/core/src/main/resources/static/assets/ diff --git a/FILE_SHARING.md b/FILE_SHARING.md new file mode 100644 index 0000000000..e0d96cf810 --- /dev/null +++ b/FILE_SHARING.md @@ -0,0 +1,444 @@ +# File Sharing Feature - Architecture & Workflow + +## Overview + +The File Sharing feature enables users to store files server-side and share them with other registered users or via token-based share links. Files are stored using a pluggable storage provider (local filesystem or database) with optional quota enforcement. + +**Key Capabilities:** +- Server-side file storage (upload, update, download, delete) +- Optional history bundle and audit log attachments per file +- Direct user-to-user sharing with access roles +- Token-based share links (requires `system.frontendUrl`) +- Optional email notifications for shares (requires `mail.enabled`) +- Access audit trail (tracks who accessed a share link and how) +- Automatic share link expiration +- Storage quotas (per-user and total) +- Pluggable storage backend (local filesystem or database BLOB) +- Integration with the Shared Signing workflow + +## Architecture + +### Database Schema + +**`stored_files`** +- One record per uploaded file +- Stores file metadata (name, content type, size, storage key) +- Optionally links to a history bundle and audit log as separate stored objects +- `workflow_session_id` — nullable link to a `WorkflowSession` (signing feature) +- `file_purpose` — enum classifying the file's role: `GENERIC`, `SIGNING_ORIGINAL`, `SIGNING_SIGNED`, `SIGNING_HISTORY` + +**`file_shares`** +- One record per sharing relationship +- Two share types, distinguished by which fields are set: + - **User share**: `shared_with_user_id` is set, `share_token` is null + - **Link share**: `share_token` is set (UUID), `shared_with_user_id` is null +- `access_role` — `EDITOR`, `COMMENTER`, or `VIEWER` +- `expires_at` — nullable expiration for link shares +- `workflow_participant_id` — when set, marks this as a **workflow share** (hidden from the file manager, accessible only via workflow endpoints) + +**`file_share_accesses`** +- One record per access event on a share link +- Tracks: user, share link, access type (`VIEW` or `DOWNLOAD`), timestamp + +**`storage_cleanup_entries`** +- Queue of storage keys to be deleted asynchronously +- Used when a file is deleted but the physical storage object cleanup is deferred + +### Access Roles + +| Role | Can Read | Can Write | +|------|----------|-----------| +| `EDITOR` | ✅ | ✅ | +| `COMMENTER` | ✅ | ❌ | +| `VIEWER` | ✅ | ❌ | + +Default role when none is specified: `EDITOR`. + +Owners always have full access regardless of role. + +#### Role Semantics: COMMENTER vs VIEWER + +In the file storage layer, `COMMENTER` and `VIEWER` are equivalent — both grant read-only access and neither can replace file content. The distinction is meaningful in the **signing workflow** context: + +| Context | COMMENTER | VIEWER | +|---------|-----------|--------| +| File storage | Read only (same as VIEWER) | Read only | +| Signing workflow | Can submit a signing action | Read only | + +`WorkflowParticipant.canEdit()` returns `true` for `COMMENTER` (and `EDITOR`) roles, which the signing workflow uses to determine if a participant can still submit a signature. Once a participant has signed or declined, their effective role is automatically downgraded to `VIEWER` regardless of their configured role. + +The rationale: "annotating" a document (submitting a signature) is not the same as "replacing" it. COMMENTER grants annotation rights without file-replacement rights. + +### Backend Architecture + +#### Service Layer + +**FileStorageService** (`1137 lines`) +- Core file management service +- Upload, update, download, and delete operations +- User share management (share, revoke, leave) +- Link share management (create, revoke, access) +- Access recording and listing +- Storage quota enforcement +- Configuration feature gate checks + +**StorageCleanupService** +- Scheduled daily: deletes orphaned storage keys from `storage_cleanup_entries` +- Scheduled daily: purges expired share links from `file_shares` +- Processes cleanup in batches of 50 entries + +#### Storage Providers + +**LocalStorageProvider** +- Files stored on the filesystem under `storage.local.basePath` (default: `./storage`) +- Storage key is a path relative to the base directory + +**DatabaseStorageProvider** +- Files stored as BLOBs in `stored_file_blobs` table +- No filesystem dependency + +Provider is selected at startup via `storage.provider: local | database`. + +#### Controller Layer + +**FileStorageController** (`/api/v1/storage`) +- All endpoints require authentication +- File CRUD and sharing operations + +### Data Flow + +``` +User uploads file → StorageProvider stores bytes → StoredFile record created + ↓ +Owner shares file → FileShare record created (user or link) + ↓ +Recipient accesses file → Access recorded → File bytes streamed +``` + +## File Operations + +### Upload File + +```bash +POST /api/v1/storage/files +Content-Type: multipart/form-data + +file: document.pdf # Required — main file +historyBundle: history.json # Optional — version history +auditLog: audit.json # Optional — audit trail +``` + +**Response:** +```json +{ + "id": 42, + "fileName": "document.pdf", + "contentType": "application/pdf", + "sizeBytes": 102400, + "owner": "alice", + "ownedByCurrentUser": true, + "accessRole": "editor", + "createdAt": "2025-01-01T12:00:00", + "updatedAt": "2025-01-01T12:00:00", + "sharedWithUsers": [], + "sharedUsers": [], + "shareLinks": [] +} +``` + +### Update File + +Replaces the file content. Only the owner can update. + +```bash +PUT /api/v1/storage/files/{fileId} +Content-Type: multipart/form-data + +file: document_v2.pdf +historyBundle: history.json # Optional +auditLog: audit.json # Optional +``` + +### List Files + +Returns all files owned by or shared with the current user. Workflow-shared files (signing participants) are excluded — those are accessible via signing endpoints only. + +```bash +GET /api/v1/storage/files +``` + +Response is sorted by `createdAt` descending. + +### Download File + +```bash +GET /api/v1/storage/files/{fileId}/download?inline=false +``` + +- `inline=false` (default) — `Content-Disposition: attachment` +- `inline=true` — `Content-Disposition: inline` (for browser preview) + +### Delete File + +Only the owner can delete. All associated share links and their access records are deleted first, then the database record, then the physical storage object. + +```bash +DELETE /api/v1/storage/files/{fileId} +``` + +## Sharing Operations + +### Share with User + +```bash +POST /api/v1/storage/files/{fileId}/shares/users +Content-Type: application/json + +{ + "username": "bob", # Username or email address + "accessRole": "editor" # "editor", "commenter", or "viewer" (default: "editor") +} +``` + +**Behaviour:** +- If the target user exists: creates/updates a `FileShare` with `sharedWithUser` set +- If `username` is an email address and the user doesn't exist: creates a share link and sends a notification email (requires `sharing.emailEnabled` and `sharing.linkEnabled`) +- If the target user is the owner: returns 400 +- If sharing is disabled: returns 403 + +### Revoke User Share + +Only the owner can revoke. + +```bash +DELETE /api/v1/storage/files/{fileId}/shares/users/{username} +``` + +### Leave Shared File + +The recipient removes themselves from a shared file. + +```bash +DELETE /api/v1/storage/files/{fileId}/shares/self +``` + +### Create Share Link + +Creates a token-based link for anonymous/authenticated access. Requires `sharing.linkEnabled` and `system.frontendUrl` to be configured. + +```bash +POST /api/v1/storage/files/{fileId}/shares/links +Content-Type: application/json + +{ + "accessRole": "viewer" # Optional (default: "editor") +} +``` + +**Response:** +```json +{ + "token": "550e8400-e29b-41d4-a716-446655440000", + "accessRole": "viewer", + "createdAt": "2025-01-01T12:00:00", + "expiresAt": "2025-01-04T12:00:00" +} +``` + +Expiration is set to `now + sharing.linkExpirationDays` (default: 3 days). + +### Revoke Share Link + +```bash +DELETE /api/v1/storage/files/{fileId}/shares/links/{token} +``` + +Also deletes all access records for that token. + +## Share Link Access + +### Download via Share Link + +Authentication is required (even for share links). Anonymous access is not permitted. + +```bash +GET /api/v1/storage/share-links/{token}?inline=false +``` + +- Returns 401 if unauthenticated +- Returns 403 if authenticated but link doesn't permit access +- Returns 410 if the link has expired +- Records a `FileShareAccess` entry on success + +> **Token-as-credential semantics:** Any authenticated user who holds the token can access the file — the token is the credential. If you need per-user access control (only a specific person can open it), use "Share with User" instead. Share links are appropriate for broader distribution where possession of the token implies authorization. + +### Get Share Link Metadata + +```bash +GET /api/v1/storage/share-links/{token}/metadata +``` + +Returns file name, owner, access role, creation/expiry timestamps, and whether the current user owns the file. + +### List Accessed Share Links + +Returns the most recent access for each non-expired share link the current user has accessed. + +```bash +GET /api/v1/storage/share-links/accessed +``` + +### List Accesses for a Link (Owner Only) + +```bash +GET /api/v1/storage/files/{fileId}/shares/links/{token}/accesses +``` + +Returns per-user access history (username, VIEW/DOWNLOAD, timestamp), sorted descending by time. + +## Workflow Share Integration + +Signing workflow participants access documents via their own `WorkflowParticipant.shareToken`. No `FileShare` record is created for participants; access control is self-contained in the `WorkflowParticipant` entity. + +The `FileShare.workflow_participant_id` column and the `FileShare.isWorkflowShare()` method are **deprecated**. Legacy data (sessions created before this change) may still have `FileShare` records with `workflow_participant_id` set, which continue to work via the existing token lookup path in `UnifiedAccessControlService`. No new records are created. + +`GET /api/v1/storage/files` returns all files owned by or shared with the current user (via `FileShare`). Signing-session PDFs use the `file_purpose` field (`SIGNING_ORIGINAL`, `SIGNING_SIGNED`, etc.) to distinguish them from generic files. The file manager UI can filter on this field if needed. + +## API Reference + +| Method | Endpoint | Description | Auth | +|--------|----------|-------------|------| +| POST | `/api/v1/storage/files` | Upload file | Required | +| PUT | `/api/v1/storage/files/{id}` | Update file | Required (owner) | +| GET | `/api/v1/storage/files` | List accessible files | Required | +| GET | `/api/v1/storage/files/{id}` | Get file metadata | Required | +| GET | `/api/v1/storage/files/{id}/download` | Download file | Required | +| DELETE | `/api/v1/storage/files/{id}` | Delete file | Required (owner) | +| POST | `/api/v1/storage/files/{id}/shares/users` | Share with user | Required (owner) | +| DELETE | `/api/v1/storage/files/{id}/shares/users/{username}` | Revoke user share | Required (owner) | +| DELETE | `/api/v1/storage/files/{id}/shares/self` | Leave shared file | Required | +| POST | `/api/v1/storage/files/{id}/shares/links` | Create share link | Required (owner) | +| DELETE | `/api/v1/storage/files/{id}/shares/links/{token}` | Revoke share link | Required (owner) | +| GET | `/api/v1/storage/share-links/{token}` | Download via share link | Required | +| GET | `/api/v1/storage/share-links/{token}/metadata` | Get share link metadata | Required | +| GET | `/api/v1/storage/share-links/accessed` | List accessed share links | Required | +| GET | `/api/v1/storage/files/{id}/shares/links/{token}/accesses` | List share accesses | Required (owner) | + +## Configuration + +All storage settings live under the `storage:` key in `settings.yml`: + +```yaml +storage: + enabled: true # Requires security.enableLogin = true + provider: local # 'local' or 'database' + local: + basePath: './storage' # Filesystem base directory (local provider only) + quotas: + maxStorageMbPerUser: -1 # Per-user storage cap in MB; -1 = unlimited + maxStorageMbTotal: -1 # Total storage cap in MB; -1 = unlimited + maxFileMb: -1 # Max size per upload (main + history + audit) in MB; -1 = unlimited + sharing: + enabled: false # Master switch for all sharing (opt-in) + linkEnabled: false # Enable token-based share links (requires system.frontendUrl) + emailEnabled: false # Enable email notifications (requires mail.enabled) + linkExpirationDays: 3 # Days until share links expire +``` + +**Prerequisites:** +- `storage.enabled` requires `security.enableLogin = true` +- `sharing.linkEnabled` requires `system.frontendUrl` to be set (used to build share link URLs) +- `sharing.emailEnabled` requires `mail.enabled = true` + +## Security Considerations + +### Access Control +- All endpoints require authentication — there is no anonymous access +- Owner-only operations enforced in service layer (not just controller) +- `requireReadAccess` / `requireEditorAccess` checked on every download + +### Share Link Security +- Tokens are UUIDs (random, not guessable) +- Expiration enforced on every access +- Expired links return HTTP 410 Gone +- Revoked links delete all access records + +### Quota Enforcement +- Checked before storing (not after) +- Accounts for existing file size when replacing (only the delta counts) +- Covers main file + history bundle + audit log in a single check + +## Automatic Cleanup + +`StorageCleanupService` runs two scheduled jobs daily: + +1. **Orphaned storage cleanup** — processes up to 50 `StorageCleanupEntry` records, deletes the physical storage object, then removes the entry. Failed attempts increment `attemptCount` for retry. + +2. **Expired share link cleanup** — deletes all `FileShare` records where `expiresAt` is in the past and `shareToken` is set. + +## Troubleshooting + +**"Storage is disabled":** +- Check `storage.enabled: true` in settings +- Verify `security.enableLogin: true` + +**"Share links are disabled":** +- Check `sharing.linkEnabled: true` +- Verify `system.frontendUrl` is set and non-empty + +**"Email sharing is disabled":** +- Check `sharing.emailEnabled: true` +- Verify `mail.enabled: true` and mail configuration + +**Signing-session PDF appearing in the general file list:** +- This is expected — signing PDFs are accessible to owners and shared users +- Filter by `file_purpose` (`SIGNING_ORIGINAL`, `SIGNING_SIGNED`) in the UI to distinguish them + +**Share link returns 410:** +- Link has expired — check `expires_at` in `file_shares` table +- Owner must create a new link + +### Debug Queries + +```sql +-- List files and their share counts +SELECT sf.stored_file_id, sf.original_filename, u.username as owner, + COUNT(DISTINCT fs.file_share_id) FILTER (WHERE fs.shared_with_user_id IS NOT NULL) as user_shares, + COUNT(DISTINCT fs.file_share_id) FILTER (WHERE fs.share_token IS NOT NULL) as link_shares +FROM stored_files sf +LEFT JOIN users u ON sf.owner_id = u.user_id +LEFT JOIN file_shares fs ON fs.stored_file_id = sf.stored_file_id +GROUP BY sf.stored_file_id, u.username; + +-- Check share link expiration +SELECT share_token, access_role, created_at, expires_at, + expires_at < NOW() as is_expired +FROM file_shares +WHERE share_token IS NOT NULL; + +-- Check access history for a share link +SELECT u.username, fsa.access_type, fsa.accessed_at +FROM file_share_accesses fsa +JOIN file_shares fs ON fsa.file_share_id = fs.file_share_id +JOIN users u ON fsa.user_id = u.user_id +WHERE fs.share_token = '{token}' +ORDER BY fsa.accessed_at DESC; + +-- Pending cleanup entries +SELECT storage_key, attempt_count, updated_at +FROM storage_cleanup_entries +ORDER BY updated_at ASC; +``` + +## Summary + +The File Sharing feature provides: +- ✅ Server-side file storage with pluggable backend (local/database) +- ✅ History bundle and audit log attachments per file +- ✅ Direct user-to-user sharing with EDITOR/COMMENTER/VIEWER roles +- ✅ Token-based share links with expiration +- ✅ Optional email notifications for shares +- ✅ Per-access audit trail for share links +- ✅ Storage quotas (per-user, total, per-file) +- ✅ Automatic cleanup of expired links and orphaned storage +- ✅ Workflow integration (signing-session PDFs stored via same infrastructure; participant access via `WorkflowParticipant.shareToken`) diff --git a/SHARED_SIGNING.md b/SHARED_SIGNING.md new file mode 100644 index 0000000000..a6ebb4d5e1 --- /dev/null +++ b/SHARED_SIGNING.md @@ -0,0 +1,691 @@ +# Shared Signing Feature - Architecture & Workflow + +## Overview + +The Shared Signing feature enables collaborative document signing workflows where a document owner can request signatures from multiple participants. Each participant receives a secure token to access the document, submit their digital signature (with optional wet signature overlay), and track the signing progress. + +**Key Capabilities:** +- Multi-participant signing sessions +- Digital certificate signatures (P12/PKCS12, JKS, SERVER, USER_CERT, PEM/UPLOAD) +- Visual wet signature overlays (drawn, typed, or uploaded) — multiple per participant +- Token-based participant access (no authentication required for participants) +- Authenticated participant access for registered users via sign-requests API +- Progress tracking for session owners +- Optional signature summary page appended to finalized PDF +- Automatic role downgrade after signing (security) +- GDPR-compliant wet signature metadata cleanup + +## Architecture + +### Database Schema + +#### Core Tables + +**`workflow_sessions`** +- Tracks signing sessions created by document owners +- Links to original and processed (signed) PDF files +- Stores session metadata (message, due date, status) + +**`workflow_participants`** +- One record per participant per session +- Tracks participant status: PENDING → VIEWED → SIGNED/DECLINED + - `NOTIFIED` status is reserved for a future email notification feature; no current code path sets it +- Stores participant-specific metadata (certificates, wet signatures) as JSONB +- Each participant holds their own `shareToken` (UUID) for token-based access — no separate `FileShare` record is created +- `accessRole` controls what actions the participant can perform. `COMMENTER` (and `EDITOR`) allow submitting a signature; `VIEWER` does not. After signing/declining, effective role is automatically downgraded to `VIEWER` + +**`user_server_certificates`** +- Stores auto-generated certificates per user +- Enables "Use My Personal Certificate" option + +#### Extended Tables + +**`stored_files`** +- Added `workflow_session_id` to link files to signing sessions +- Added `file_purpose` enum (SIGNING_ORIGINAL, SIGNING_SIGNED, etc.) + +**`file_shares`** +- Regular file shares are created when the session owner shares the document with other users via the file manager +- The `workflow_participant_id` column is deprecated; participant access is self-contained in `WorkflowParticipant.shareToken` + +### Backend Architecture + +#### Service Layer + +**WorkflowSessionService** (`816 lines`) +- Core workflow management service +- Creates sessions with participants +- Handles participant status updates +- Stores signature metadata (certificates and wet signatures) +- Finalizes sessions by coordinating signing process + +Key responsibilities: +- Session lifecycle management (create, list, get details, delete) +- Participant management (add, remove, notify) +- Certificate submission storage +- Wet signature metadata storage +- Session finalization orchestration + +**UnifiedAccessControlService** +- Validates participant tokens +- Checks session status and expiration +- Maps participant status to effective access role +- Automatic role downgrade after signing: SIGNED/DECLINED → VIEWER role + +**UserServerCertificateService** +- Auto-generates personal certificates for users +- Manages certificate storage and retrieval +- Enables "Use My Personal Certificate" signing option + +#### Controller Layer + +**SigningSessionController** (Owner-facing + Authenticated participant endpoints) +- `POST /api/v1/security/cert-sign/sessions` - Create signing session +- `GET /api/v1/security/cert-sign/sessions` - List user's sessions +- `GET /api/v1/security/cert-sign/sessions/{id}` - Get session details +- `GET /api/v1/security/cert-sign/sessions/{id}/pdf` - Download original PDF +- `POST /api/v1/security/cert-sign/sessions/{id}/finalize` - Finalize and apply signatures +- `GET /api/v1/security/cert-sign/sessions/{id}/signed-pdf` - Download signed PDF +- `DELETE /api/v1/security/cert-sign/sessions/{id}` - Delete session +- `POST /api/v1/security/cert-sign/sessions/{id}/participants` - Add participants +- `DELETE /api/v1/security/cert-sign/sessions/{id}/participants/{participantId}` - Remove participant +- `GET /api/v1/security/cert-sign/sign-requests` - List sign requests for authenticated user +- `GET /api/v1/security/cert-sign/sign-requests/{id}` - Get sign request details +- `GET /api/v1/security/cert-sign/sign-requests/{id}/document` - Download document for signing +- `POST /api/v1/security/cert-sign/sign-requests/{id}/sign` - Sign document (authenticated) +- `POST /api/v1/security/cert-sign/sign-requests/{id}/decline` - Decline sign request (authenticated) + +**WorkflowParticipantController** (Participant-facing, token-based) +- `GET /api/v1/workflow/participant/session?token={token}` - View session details +- `GET /api/v1/workflow/participant/details?token={token}` - Get participant details +- `GET /api/v1/workflow/participant/document?token={token}` - Download PDF +- `POST /api/v1/workflow/participant/submit-signature` - Submit signature +- `POST /api/v1/workflow/participant/decline?token={token}` - Decline to sign + +#### Data Flow + +``` +Owner creates session → Participants receive tokens → +Participants access via token (or authenticated) → Participants submit signatures → +Owner finalizes → System applies signatures → [Optional: append summary page] → Signed PDF generated +``` + +### Frontend Architecture + +#### Quick Access Integration + +**SignPopout Component** +- Displays in Quick Access Bar (top navigation) +- Shows active and completed signing sessions +- Auto-refreshes every 15 seconds to show signature progress +- Badge indicator shows count of pending sessions + +**ActiveSessionsPanel** +- Lists sessions where user is owner or participant +- Shows signature progress: "X/Y signatures" (e.g., "2/5 signatures") +- Color-coded badges: + - Blue: No signatures yet (0/X) + - Yellow: Partial signatures (X/Y) + - Green: Ready to finalize (X/X) + +**CompletedSessionsPanel** +- Lists finalized sessions and declined sign requests +- Allows viewing/downloading signed PDFs + +#### Workbench Views + +**SignRequestWorkbenchView** +- Full-screen view for participants to sign documents +- Integrated PDF viewer with annotation support +- Certificate selection (Personal/Organization/Custom P12) +- Wet signature input (draw, type, or upload) +- Signature placement on PDF pages + +**SessionDetailWorkbenchView** +- Owner's view of session details +- Participant list with status indicators +- Ability to add/remove participants +- Finalize button when all signatures collected +- Download original/signed PDF + +#### State Management + +**FileContext Integration** +- Signing sessions operate within FileContext workflow +- PDFs loaded once, persist across tool switches +- Memory management for large files (up to 100GB+) + +**ToolWorkflowContext** +- Registers custom workbench views +- Manages navigation between viewer and signing tools +- Preserves file state during signing operations + +#### Services & Hooks + +**workflowService.ts** +- API client for all signing endpoints +- Handles session creation, listing, and management +- Participant operations (submit, decline) + +**useWorkflowSession.ts** +- React hook for owner session management +- State management for session list and details + +**useParticipantSession.ts** +- React hook for participant signing workflow +- Manages signature submission state + +## Signing Workflow Process + +### 1. Session Creation (Owner) + +``` +Owner → Uploads PDF → Selects participants → Creates session + ↓ +System creates: + - WorkflowSession record + - WorkflowParticipant records (one per participant, each with a unique shareToken) + ↓ +Participants receive token (via email or share link) +``` + +**API Call:** +```bash +POST /api/v1/security/cert-sign/sessions +Content-Type: multipart/form-data + +file: document.pdf +workflowType: SIGNING +documentName: "contract.pdf" # Optional display name +participantUserIds: [1, 2, 3] # Registered user IDs +participantEmails: ["a@b.com"] # External/unregistered users +participants: [...] # Detailed participant configs (optional) +message: "Please sign this contract" +dueDate: "2025-12-31" +ownerEmail: "owner@example.com" # Optional, for notifications +workflowMetadata: '{"showSignature": false, "showLogo": false, "includeSummaryPage": true}' +``` + +**Session-level `workflowMetadata` fields:** +| Field | Type | Description | +|-------|------|-------------| +| `showSignature` | boolean | Show visible digital signature block on PDF | +| `pageNumber` | integer | Page to place digital signature on | +| `showLogo` | boolean | Show logo in digital signature block | +| `includeSummaryPage` | boolean | Append a signature summary page before digital signing | + +**Response:** +```json +{ + "sessionId": "uuid", + "documentName": "contract.pdf", + "participants": [ + { + "userId": 1, + "email": "user1@example.com", + "shareToken": "token1", + "status": "PENDING" + } + ], + "participantCount": 3, + "signedCount": 0 +} +``` + +### 2. Participant Access + +``` +Participant → Clicks token link → Views session details + ↓ +Status changes: PENDING/NOTIFIED → VIEWED + ↓ +Participant downloads PDF to review +``` + +**Access URL (unauthenticated):** +``` +https://app.example.com/sign?token={participant_token} +``` + +**Authenticated participants** can also use: +``` +GET /api/v1/security/cert-sign/sign-requests +GET /api/v1/security/cert-sign/sign-requests/{sessionId} +GET /api/v1/security/cert-sign/sign-requests/{sessionId}/document +``` + +**Automatic Status Update:** +- First access: PENDING/NOTIFIED → VIEWED +- Downloads tracked but don't change status + +### 3. Signature Submission + +``` +Participant → Selects certificate type → Uploads certificate (if needed) + → Draws/uploads wet signatures (optional, multiple supported) + → Submits signature + ↓ +System stores: + - Certificate data (P12/JKS keystore as base64) + - Certificate password + - Wet signatures metadata (JSON array: base64 image + coordinates per signature) + ↓ +Status changes: VIEWED → SIGNED +Access role: EDITOR → VIEWER (automatic downgrade) +``` + +**API Call (token-based, unauthenticated):** +```bash +POST /api/v1/workflow/participant/submit-signature +Content-Type: multipart/form-data + +participantToken: {token} +certType: P12 | JKS | SERVER | USER_CERT +p12File: certificate.p12 (if certType=P12) +jksFile: keystore.jks (if certType=JKS) +password: cert_password +showSignature: false +pageNumber: 1 +location: "New York" +reason: "I approve this contract" +showLogo: false +wetSignaturesData: '[{"page":0,"x":100,"y":200,"width":150,"height":50,"type":"IMAGE","data":"base64..."}]' +``` + +**API Call (authenticated users):** +```bash +POST /api/v1/security/cert-sign/sign-requests/{sessionId}/sign +Content-Type: multipart/form-data + +certType: SERVER | USER_CERT | UPLOAD | PEM | PKCS12 | PFX | JKS +p12File: certificate.p12 (if applicable) +password: cert_password +reason: "I approve this contract" +location: "New York" +wetSignaturesData: '[...]' +``` + +**Metadata Storage (JSONB):** +```json +{ + "certificateSubmission": { + "certType": "P12", + "password": "cert_password", + "p12Keystore": "base64_encoded_keystore", + "showSignature": false, + "pageNumber": 1, + "location": "New York", + "reason": "I approve this contract", + "showLogo": false + }, + "wetSignatures": [ + { + "type": "IMAGE", + "data": "base64_image", + "page": 0, + "x": 100, + "y": 200, + "width": 150, + "height": 50 + } + ] +} +``` + +Note: Multiple wet signatures are supported per participant (array). + +### 4. Progress Tracking (Owner) + +``` +Owner → Views session list → Sees "2/5 signatures" + → Clicks session → Views participant status + ↓ +Participant list shows: + - user1@example.com: SIGNED ✓ + - user2@example.com: SIGNED ✓ + - user3@example.com: VIEWED (pending) + - user4@example.com: PENDING + - user5@example.com: DECLINED ✗ + ↓ +Auto-refresh every 15 seconds +``` + +**Badge Colors:** +- 🔵 Blue: 0/5 signatures (awaiting) +- 🟡 Yellow: 2/5 signatures (partial) +- 🟢 Green: 5/5 signatures (ready to finalize) + +### 5. Session Finalization + +``` +Owner → Clicks "Finalize" → System processes signatures + ↓ +Processing steps: + 1. Apply wet signatures to PDF (visual overlays) + 1.5. Append signature summary page (if includeSummaryPage=true) + 2. Apply digital certificates in participant order + - Visual signature block suppressed when summary page is enabled + 3. Store signed PDF + 4. Clear wet signature metadata (GDPR compliance) + ↓ +Owner downloads signed PDF +``` + +**Finalization Process:** + +1. **Apply Wet Signatures First** + ```java + for (WetSignature sig : wetSignatures) { + PDPage page = document.getPage(sig.getPage()); + byte[] imageBytes = Base64.decode(sig.getData()); + // Convert Y from top-left (UI) to bottom-left (PDF) coordinate system + float pdfY = page.getMediaBox().getHeight() - sig.getY() - sig.getHeight(); + PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "signature"); + contentStream.drawImage(image, sig.getX(), pdfY, sig.getWidth(), sig.getHeight()); + } + ``` + +2. **Append Summary Page (optional, before digital signing)** + + If `includeSummaryPage=true`, a new A4 page is appended showing: + - Stirling logo and "Signature Summary" title + - Document name and session owner + - Finalization timestamp + - Per-participant: name, email, status, signed timestamp, reason, location, certificate type + - Supports overflow to additional pages + + This step occurs **before** digital certificate signing so signatures are not invalidated. + When a summary page is added, the visual digital signature block (`showSignature`) is suppressed — wet signatures (hand-drawn overlays) are unaffected. + +3. **Apply Digital Certificates (in participant order)** + ```java + for (Participant p : participants) { + if (p.status == SIGNED) { + KeyStore keystore = buildKeystore(p.certificate); + // Reason: participant override > owner default > "Document Signing" + // Location: participant-provided only (no default) + CertSignController.sign(pdfBytes, keystore, password, settings); + } + } + ``` + +4. **Store and Cleanup** + ```java + StoredFile signedFile = storeFile(signedPdfBytes, SIGNING_SIGNED); + session.setProcessedFile(signedFile); + session.setFinalized(true); + + // GDPR: Clear sensitive metadata after finalization + for (Participant p : participants) { + p.metadata.remove("wetSignatures"); // Clears wet signature image data + p.metadata.remove("certificateSubmission"); // Clears keystore bytes + password + } + ``` + +**API Call:** +```bash +POST /api/v1/security/cert-sign/sessions/{sessionId}/finalize +Authorization: Bearer {owner_token} +``` + +**Response:** Binary PDF file with Content-Disposition header + +## Key Technical Features + +### 1. Double JSON Encoding Fix (Recent) + +**Problem:** JSONB columns were storing JSON strings instead of JSON objects, requiring double-parsing. + +**Solution:** Created `JsonMapConverter` JPA AttributeConverter: +```java +@Convert(converter = JsonMapConverter.class) +@Column(name = "participant_metadata", columnDefinition = "jsonb") +private Map participantMetadata; +``` + +**Benefits:** +- Single parse on read +- Proper JSON storage in PostgreSQL +- Type-safe Map access +- Backward compatible with legacy data + +### 2. Signature Progress Display (Recent) + +**Implementation:** +- `WorkflowSessionResponse` includes `participantCount` and `signedCount` +- `WorkflowMapper` calculates counts when converting to DTO +- Frontend displays "X/Y signatures" in session list +- Auto-refresh every 15 seconds keeps counts updated + +### 3. Token-Based Security + +**No Authentication Required for Participants:** +- Participants access via secure token (UUID) +- Token linked to specific participant and session +- Automatic expiration support +- One-time signing (cannot sign twice) + +**Authenticated Participant Access:** +- Registered users can also access sign requests via `/api/v1/security/cert-sign/sign-requests` +- Standard Spring Security authentication required +- Supports additional cert types: UPLOAD, PEM, PKCS12, PFX + +**Automatic Role Downgrade:** +- After signing: EDITOR → VIEWER +- After declining: EDITOR → VIEWER +- Prevents modification after action taken + +### 4. Storage Integration + +**Unified with File Sharing:** +- All PDFs stored via `StorageProvider` (Database or Local) +- Respects storage quotas +- Supports files up to 100GB+ (with Local storage) +- Consistent with existing file sharing infrastructure + +### 5. Certificate Types + +**P12/PKCS12/PFX:** User uploads PKCS#12 file + password +**JKS:** User uploads Java KeyStore + password +**PEM/UPLOAD:** User uploads PEM certificate + private key +**SERVER:** Uses organization's server certificate (no upload needed) +**USER_CERT:** Uses user's auto-generated personal certificate (one-click) + +Note: UPLOAD, PEM, PKCS12, PFX are available on the authenticated (`sign-requests`) path. The token-based path uses P12, JKS, SERVER, USER_CERT. + +## Frontend Components Overview + +### Owner Workflow Components + +1. **CreateSessionPanel** - Form to create new signing session +2. **ActiveSessionsPanel** - List of pending sessions with progress +3. **SessionDetailWorkbenchView** - Full session management interface +4. **CompletedSessionsPanel** - History of finalized sessions + +### Participant Workflow Components + +1. **SignRequestWorkbenchView** - Main signing interface +2. **SignatureSettingsInput** - Certificate selection and configuration +3. **WetSignatureInput** - Draw/type/upload signature overlay +4. **SignatureSettingsDisplay** - Preview of signature settings + +### Shared Components + +1. **UserSelector** - Multi-select user picker for participants +2. **LocalEmbedPDFWithAnnotations** - PDF viewer with signature placement + +## Configuration + +### Backend Configuration + +**application.properties:** +```properties +# Database (H2 or PostgreSQL) +spring.jpa.hibernate.ddl-auto=update + +# Security +DOCKER_ENABLE_SECURITY=true + +# Storage Provider (DATABASE or LOCAL) +storage.provider=LOCAL +storage.maxFileSize=100GB +``` + +### Frontend Configuration + +**Quick Access Bar:** +- Signing popout accessible from top navigation +- Auto-refresh interval: 15 seconds +- Badge shows pending session count + +## API Reference Summary + +### Owner Endpoints (Authenticated) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/security/cert-sign/sessions` | Create session | +| GET | `/api/v1/security/cert-sign/sessions` | List sessions | +| GET | `/api/v1/security/cert-sign/sessions/{id}` | Get details | +| POST | `/api/v1/security/cert-sign/sessions/{id}/finalize` | Finalize session | +| GET | `/api/v1/security/cert-sign/sessions/{id}/pdf` | Download original | +| GET | `/api/v1/security/cert-sign/sessions/{id}/signed-pdf` | Download signed | +| DELETE | `/api/v1/security/cert-sign/sessions/{id}` | Delete session | +| POST | `/api/v1/security/cert-sign/sessions/{id}/participants` | Add participants | +| DELETE | `/api/v1/security/cert-sign/sessions/{id}/participants/{pid}` | Remove participant | + +### Authenticated Participant Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/security/cert-sign/sign-requests` | List sign requests | +| GET | `/api/v1/security/cert-sign/sign-requests/{id}` | Get sign request details | +| GET | `/api/v1/security/cert-sign/sign-requests/{id}/document` | Download document | +| POST | `/api/v1/security/cert-sign/sign-requests/{id}/sign` | Sign document | +| POST | `/api/v1/security/cert-sign/sign-requests/{id}/decline` | Decline signing | + +### Token-Based Participant Endpoints (No Auth Required) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/workflow/participant/session?token={token}` | View session | +| GET | `/api/v1/workflow/participant/details?token={token}` | Get participant details | +| GET | `/api/v1/workflow/participant/document?token={token}` | Download PDF | +| POST | `/api/v1/workflow/participant/submit-signature` | Submit signature | +| POST | `/api/v1/workflow/participant/decline?token={token}` | Decline signing | + +## Security Considerations + +### Data Protection +- Wet signature image data cleared after finalization (GDPR compliance) +- Certificate submission data (keystore bytes + password) cleared after finalization (GDPR compliance) +- Certificate passwords are not encrypted at rest while stored (TODO: encrypt at rest) +- Token expiration support + +### Access Control +- Owner authentication required for session management +- Participant access via secure UUID tokens (no auth) or standard auth (sign-requests) +- Automatic role downgrade prevents re-signing +- Session status checks prevent unauthorized actions + +### Audit Trail +- All participant actions tracked +- FileShare access logged +- Status transitions recorded +- Notification history maintained + +## Performance Characteristics + +### Scalability +- Supports PDFs up to 100GB+ (with Local storage provider) +- Memory-efficient streaming for large files +- IndexedDB caching on frontend +- Database indexes on session_id, share_token, workflow_session_id + +### Response Times +- Session creation: ~500ms (10MB file) +- Session listing: ~100ms +- Token validation: ~50ms +- Finalization: ~2s per MB of PDF (varies by certificate operations) + +## Future Enhancements + +### Planned Features +- Email notifications for participants +- Reminder system for pending signatures +- Bulk signing operations +- Template-based signing workflows +- Signature validation/verification UI +- Certificate password encryption at rest +- Certificate keystore cleanup after finalization (GDPR) +- Webhook support for external integrations +- Analytics dashboard for signing metrics + +### Additional Workflow Types +- **REVIEW** - Document review with comments +- **APPROVAL** - Multi-level approval chains +- **COLLABORATION** - Real-time collaborative editing + +## Troubleshooting + +### Common Issues + +**"Token invalid" error:** +- Check token exists in workflow_participants table +- Verify session is not finalized +- Check expiration date (expires_at) + +**Signature not appearing on PDF:** +- Verify certificate type is correct +- Check certificate password +- Review logs for signing errors +- Ensure PDFDocumentFactory is available + +**"Awaiting signatures" not updating:** +- Backend should return participantCount and signedCount +- Frontend auto-refresh every 15 seconds +- Check network tab for API errors + +**Wet signatures not visible after finalization:** +- Wet signatures are applied first as image overlays (Step 1) +- Check `wetSignaturesData` was sent as valid JSON array +- Verify page index is within document bounds +- Note: wet signatures survive regardless of `includeSummaryPage` setting + +### Debug Queries + +```sql +-- Check session status +SELECT session_id, status, finalized, + (SELECT COUNT(*) FROM workflow_participants WHERE workflow_session_id = ws.id) as participant_count, + (SELECT COUNT(*) FROM workflow_participants WHERE workflow_session_id = ws.id AND status = 'SIGNED') as signed_count +FROM workflow_sessions ws; + +-- Check participant tokens +SELECT email, status, share_token, expires_at +FROM workflow_participants +WHERE workflow_session_id = (SELECT id FROM workflow_sessions WHERE session_id = '{session_id}'); + +-- Check metadata storage +SELECT email, + participant_metadata->'certificateSubmission'->>'certType' as cert_type, + jsonb_array_length(participant_metadata->'wetSignatures') as wet_sig_count +FROM workflow_participants; +``` + +## Summary + +The Shared Signing feature provides a complete collaborative signing workflow with: +- ✅ Multi-participant support with progress tracking +- ✅ Multiple certificate types (P12/PKCS12/PFX, JKS, PEM, SERVER, USER_CERT) +- ✅ Visual wet signature overlays (multiple per participant) +- ✅ Token-based security for unauthenticated participants +- ✅ Authenticated participant access via sign-requests API +- ✅ Automatic role management +- ✅ Large file support (100GB+) +- ✅ GDPR-compliant wet signature metadata cleanup +- ✅ Real-time progress updates +- ✅ Full frontend integration with Quick Access Bar +- ✅ Optional signature summary page with logo and participant details + +The architecture leverages existing file sharing infrastructure while adding workflow-specific features, ensuring consistency and maintainability across the application. diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 82346d92fc..fa36998e7f 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -58,6 +58,7 @@ public class ApplicationProperties { private Legal legal = new Legal(); private Security security = new Security(); private System system = new System(); + private Storage storage = new Storage(); private Ui ui = new Ui(); private Endpoints endpoints = new Endpoints(); private Metrics metrics = new Metrics(); @@ -634,6 +635,41 @@ public class ApplicationProperties { } } + @Data + public static class Storage { + private boolean enabled = false; + private String provider = "local"; + private Local local = new Local(); + private Quotas quotas = new Quotas(); + private Sharing sharing = new Sharing(); + private Signing signing = new Signing(); + + @Data + public static class Local { + private String basePath = InstallationPathConfig.getPath() + "storage"; + } + + @Data + public static class Sharing { + private boolean enabled = false; + private boolean linkEnabled = false; + private boolean emailEnabled = false; + private int linkExpirationDays = 3; + } + + @Data + public static class Quotas { + private long maxStorageMbPerUser = -1; + private long maxStorageMbTotal = -1; + private long maxFileMb = -1; + } + + @Data + public static class Signing { + private boolean enabled = false; + } + } + @Data public static class DatabaseBackup { private String cron = "0 0 0 * * ?"; // daily at midnight diff --git a/app/common/src/main/java/stirling/software/common/model/api/security/UserSummaryDTO.java b/app/common/src/main/java/stirling/software/common/model/api/security/UserSummaryDTO.java new file mode 100644 index 0000000000..514d951097 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/model/api/security/UserSummaryDTO.java @@ -0,0 +1,16 @@ +package stirling.software.common.model.api.security; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UserSummaryDTO { + private Long userId; + private String username; + private String displayName; + private String teamName; + private boolean enabled; +} diff --git a/app/common/src/main/java/stirling/software/common/service/PdfSigningService.java b/app/common/src/main/java/stirling/software/common/service/PdfSigningService.java new file mode 100644 index 0000000000..3da0c702e6 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/PdfSigningService.java @@ -0,0 +1,37 @@ +package stirling.software.common.service; + +import java.security.KeyStore; + +/** + * Abstraction for PDF digital signature operations. Defined in common so that proprietary services + * can use it without creating a circular dependency on core. + */ +public interface PdfSigningService { + + /** + * Signs a PDF document using the provided KeyStore. + * + * @param pdfBytes raw PDF bytes to sign + * @param keystore the KeyStore containing the signing key and certificate chain + * @param password keystore password + * @param showSignature whether to render a visible signature block + * @param pageNumber 0-indexed page on which to render the visible signature (may be null) + * @param name signer name embedded in the signature + * @param location location string embedded in the signature + * @param reason reason string embedded in the signature + * @param showLogo whether to include the Stirling-PDF logo in the visible signature + * @return signed PDF bytes + * @throws Exception on any signing failure + */ + byte[] signWithKeystore( + byte[] pdfBytes, + KeyStore keystore, + char[] password, + boolean showSignature, + Integer pageNumber, + String name, + String location, + String reason, + boolean showLogo) + throws Exception; +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index c1cf56aec8..439436a44c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -204,6 +204,27 @@ public class ConfigController { boolean invitesEnabled = applicationProperties.getMail().isEnableInvites(); configData.put("enableEmailInvites", smtpEnabled && invitesEnabled); + // Storage settings + boolean storageEnabled = enableLogin && applicationProperties.getStorage().isEnabled(); + boolean sharingEnabled = + storageEnabled && applicationProperties.getStorage().getSharing().isEnabled(); + boolean frontendUrlConfigured = frontendUrl != null && !frontendUrl.trim().isEmpty(); + boolean shareLinksEnabled = + sharingEnabled + && applicationProperties.getStorage().getSharing().isLinkEnabled() + && frontendUrlConfigured; + boolean shareEmailEnabled = + sharingEnabled + && applicationProperties.getStorage().getSharing().isEmailEnabled() + && applicationProperties.getMail().isEnabled(); + boolean groupSigningEnabled = + storageEnabled && applicationProperties.getStorage().getSigning().isEnabled(); + configData.put("storageEnabled", storageEnabled); + configData.put("storageSharingEnabled", sharingEnabled); + configData.put("storageShareLinksEnabled", shareLinksEnabled); + configData.put("storageShareEmailEnabled", shareEmailEnabled); + configData.put("storageGroupSigningEnabled", groupSigningEnabled); + // Check if user is admin using UserServiceInterface boolean isAdmin = false; if (userService != null) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java index 11ca986c2a..5c45f5ab0f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java @@ -113,7 +113,7 @@ public class CertSignController { this.serverCertificateService = serverCertificateService; } - private static void sign( + public static void sign( CustomPDFDocumentFactory pdfDocumentFactory, MultipartFile input, OutputStream output, @@ -304,7 +304,7 @@ public class CertSignController { } } - class CreateSignature extends CreateSignatureBase { + public static class CreateSignature extends CreateSignatureBase { File logoFile; public CreateSignature(KeyStore keystore, char[] pin) diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfSigningServiceImpl.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfSigningServiceImpl.java new file mode 100644 index 0000000000..ef1dce3305 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfSigningServiceImpl.java @@ -0,0 +1,111 @@ +package stirling.software.SPDF.service; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.security.KeyStore; + +import org.springframework.stereotype.Service; + +import stirling.software.SPDF.controller.api.security.CertSignController; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfSigningService; + +/** Core implementation of {@link PdfSigningService} backed by {@link CertSignController}. */ +@Service +public class PdfSigningServiceImpl implements PdfSigningService { + + private final CustomPDFDocumentFactory pdfDocumentFactory; + + public PdfSigningServiceImpl(CustomPDFDocumentFactory pdfDocumentFactory) { + this.pdfDocumentFactory = pdfDocumentFactory; + } + + @Override + public byte[] signWithKeystore( + byte[] pdfBytes, + KeyStore keystore, + char[] password, + boolean showSignature, + Integer pageNumber, + String name, + String location, + String reason, + boolean showLogo) + throws Exception { + + CertSignController.CreateSignature createSignature = + new CertSignController.CreateSignature(keystore, password); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ByteArrayMultipartFile inputFile = + new ByteArrayMultipartFile(pdfBytes, "document.pdf", "application/pdf"); + + CertSignController.sign( + pdfDocumentFactory, + inputFile, + outputStream, + createSignature, + showSignature, + pageNumber, + name, + location, + reason, + showLogo); + + return outputStream.toByteArray(); + } + + /** Minimal MultipartFile wrapper for passing raw PDF bytes to CertSignController.sign(). */ + private static class ByteArrayMultipartFile + implements org.springframework.web.multipart.MultipartFile { + private final byte[] content; + private final String filename; + private final String contentType; + + ByteArrayMultipartFile(byte[] content, String filename, String contentType) { + this.content = content; + this.filename = filename; + this.contentType = contentType; + } + + @Override + public String getName() { + return "file"; + } + + @Override + public String getOriginalFilename() { + return filename; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return content == null || content.length == 0; + } + + @Override + public long getSize() { + return content == null ? 0 : content.length; + } + + @Override + public byte[] getBytes() { + return content; + } + + @Override + public java.io.InputStream getInputStream() { + return new ByteArrayInputStream(content); + } + + @Override + public void transferTo(java.io.File dest) throws java.io.IOException { + java.nio.file.Files.write(dest.toPath(), content); + } + } +} diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index 170baf1e1d..751eabd77e 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -50,6 +50,8 @@ spring.mvc.problemdetails.enabled=false # Or via SYSTEMFILEUPLOADLIMIT/SYSTEM_MAXFILESIZE which will also set fileUploadLimit in settings.yml spring.servlet.multipart.max-file-size=${SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE:2000MB} spring.servlet.multipart.max-request-size=${SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE:2000MB} +# Jetty max form content size (default 200KB is too small for signature images) +server.jetty.max-http-form-post-size=10MB server.servlet.session.tracking-modes=cookie server.servlet.context-path=${SYSTEM_ROOTURIPATH:/} spring.devtools.restart.enabled=true diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 43eb518a9c..c78a515bd3 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -240,6 +240,22 @@ system: databaseBackup: cron: "0 0 0 * * ?" # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight +storage: + enabled: false # set to 'true' to allow users to store files on the server (requires security.enableLogin) [ALPHA] + provider: local # storage provider: 'local' for filesystem storage, 'database' for DB-backed storage + local: + basePath: './storage' # base directory for stored files + quotas: + maxStorageMbPerUser: -1 # Max storage per user in MB; -1 disables per-user cap + maxStorageMbTotal: -1 # Max storage across all users in MB; -1 disables total cap + maxFileMb: -1 # Max size per stored file (including history/audit) in MB; -1 disables limit + sharing: + enabled: false # set to 'true' to enable file sharing features [ALPHA] + linkEnabled: true # set to 'false' to disable share links (requires system.frontendUrl) + emailEnabled: false # set to 'true' to allow sharing by email (requires mail.enabled) + linkExpirationDays: 3 # Number of days before share links expire + signing: + enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA] autoPipeline: outputFolder: "" # Output folder for processed pipeline files (leave empty for default) fileReadiness: diff --git a/app/core/src/main/resources/static/images/stirling-logo-white.png b/app/core/src/main/resources/static/images/stirling-logo-white.png new file mode 100644 index 0000000000..8018b0771c Binary files /dev/null and b/app/core/src/main/resources/static/images/stirling-logo-white.png differ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index 620f4a22cf..eddc0faf4a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -28,9 +28,16 @@ import stirling.software.common.model.exception.UnsupportedProviderException; basePackages = { "stirling.software.proprietary.security.database.repository", "stirling.software.proprietary.security.repository", - "stirling.software.proprietary.repository" + "stirling.software.proprietary.repository", + "stirling.software.proprietary.storage.repository", + "stirling.software.proprietary.workflow.repository" }) -@EntityScan({"stirling.software.proprietary.security.model", "stirling.software.proprietary.model"}) +@EntityScan({ + "stirling.software.proprietary.security.model", + "stirling.software.proprietary.model", + "stirling.software.proprietary.storage.model", + "stirling.software.proprietary.workflow.model" +}) public class DatabaseConfig { public final String DATASOURCE_DEFAULT_URL; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ProprietaryWebMvcConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ProprietaryWebMvcConfig.java new file mode 100644 index 0000000000..34e0116cf6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ProprietaryWebMvcConfig.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.security.configuration; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.security.filter.ParticipantRateLimitInterceptor; + +@Configuration +@RequiredArgsConstructor +public class ProprietaryWebMvcConfig implements WebMvcConfigurer { + + private final ParticipantRateLimitInterceptor participantRateLimitInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(participantRateLimitInterceptor) + .addPathPatterns("/api/v1/workflow/participant/**"); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 98f61f57e9..f73c37a7fb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -612,6 +612,7 @@ public class AdminSettingsController { case "endpoints" -> applicationProperties.getEndpoints(); case "metrics" -> applicationProperties.getMetrics(); case "mail" -> applicationProperties.getMail(); + case "storage" -> applicationProperties.getStorage(); case "premium" -> applicationProperties.getPremium(); case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor(); case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline(); @@ -633,6 +634,7 @@ public class AdminSettingsController { "endpoints", "metrics", "mail", + "storage", "premium", "processExecutor", "processexecutor", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index cde7b0e17e..c2d62d5e72 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -18,6 +18,7 @@ import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -33,6 +34,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.api.UserApi; import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.api.security.UserSummaryDTO; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.proprietary.audit.AuditEventType; @@ -775,6 +777,7 @@ public class UserController { @PreAuthorize("hasRole('ROLE_ADMIN')") @PostMapping("/admin/deleteUser/{username}") + @Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC) public ResponseEntity deleteUser( @PathVariable("username") String username, Authentication authentication) { if (!userService.usernameExistsIgnoreCase(username)) { @@ -964,4 +967,34 @@ public class UserController { .body("Failed to complete initial setup"); } } + + /** + * List all enabled users for selection in signing workflows. + * + * @param principal The authenticated user + * @return List of user summaries + */ + @GetMapping("/users") + public ResponseEntity> listUsers(Principal principal) { + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + List users = + userRepository.findAll().stream() + .filter(User::isEnabled) + .map(this::toUserSummaryDTO) + .collect(java.util.stream.Collectors.toList()); + + return ResponseEntity.ok(users); + } + + private UserSummaryDTO toUserSummaryDTO(User user) { + return new UserSummaryDTO( + user.getId(), + user.getUsername(), + user.getUsername(), // Use username as displayName + user.getTeam() != null ? user.getTeam().getName() : null, + user.isEnabled()); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ParticipantRateLimitInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ParticipantRateLimitInterceptor.java new file mode 100644 index 0000000000..de96cb22fe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/ParticipantRateLimitInterceptor.java @@ -0,0 +1,73 @@ +package stirling.software.proprietary.security.filter; + +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.http.HttpStatus; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import lombok.extern.slf4j.Slf4j; + +/** Per-IP rate limiter for the unauthenticated participant token endpoints. */ +@Slf4j +@Component +public class ParticipantRateLimitInterceptor implements HandlerInterceptor { + + private static final int MAX_REQUESTS_PER_MINUTE = 20; + private static final long WINDOW_MS = 60_000L; + + // value: [requestCount, windowStartMs] + private final ConcurrentHashMap requestCounts = new ConcurrentHashMap<>(); + + @Override + public boolean preHandle( + HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + + String ip = getClientIp(request); + long now = System.currentTimeMillis(); + + long[] entry = + requestCounts.compute( + ip, + (key, existing) -> { + if (existing == null || now - existing[1] >= WINDOW_MS) { + return new long[] {1, now}; + } + existing[0]++; + return existing; + }); + + if (entry[0] > MAX_REQUESTS_PER_MINUTE) { + log.warn( + "Rate limit exceeded for IP {} on participant endpoint {}", + ip, + request.getRequestURI()); + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setHeader("Retry-After", "60"); + response.setContentType("application/json"); + response.getWriter() + .write("{\"error\":\"Rate limit exceeded. Try again in 60 seconds.\"}"); + return false; + } + return true; + } + + private String getClientIp(HttpServletRequest request) { + // Do not trust X-Forwarded-For: it is user-controlled and trivially spoofed, + // which would allow an attacker to bypass this rate limiter by rotating fake IPs. + // Operators who deploy behind a trusted reverse proxy should configure Spring's + // RemoteIpFilter / ForwardedHeaderFilter at the framework level instead. + return request.getRemoteAddr(); + } + + @Scheduled(fixedDelay = 300_000) + public void cleanupExpiredWindows() { + long cutoff = System.currentTimeMillis() - WINDOW_MS; + requestCounts.entrySet().removeIf(e -> e.getValue()[1] < cutoff); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 943be47e8b..27f605016c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -40,6 +40,7 @@ public class User implements UserDetails, Serializable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "user_id") + @EqualsAndHashCode.Include private Long id; @Column(name = "username", unique = true) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index 2930d248ba..ea960207dd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -41,6 +41,7 @@ import stirling.software.common.service.UserServiceInterface; import stirling.software.common.util.RegexPatternUtils; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.AuthorityRepository; +import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; @@ -48,6 +49,16 @@ import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.session.SessionPersistentRegistry; +import stirling.software.proprietary.storage.model.FileShare; +import stirling.software.proprietary.storage.model.StorageCleanupEntry; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.repository.FileShareAccessRepository; +import stirling.software.proprietary.storage.repository.FileShareRepository; +import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; +import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository; +import stirling.software.proprietary.workflow.service.UserServerCertificateService; @Service @Slf4j @@ -68,6 +79,15 @@ public class UserService implements UserServiceInterface { private final ApplicationProperties.Security.OAUTH2 oAuth2; + private final PersistentLoginRepository persistentLoginRepository; + private final UserServerCertificateService userServerCertificateService; + private final WorkflowParticipantRepository workflowParticipantRepository; + private final WorkflowSessionRepository workflowSessionRepository; + private final StoredFileRepository storedFileRepository; + private final StorageCleanupEntryRepository storageCleanupEntryRepository; + private final FileShareRepository fileShareRepository; + private final FileShareAccessRepository fileShareAccessRepository; + @Transactional public void processSSOPostLogin( String username, @@ -200,19 +220,78 @@ public class UserService implements UserServiceInterface { return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey()); } + @Transactional public void deleteUser(String username) { Optional userOpt = findByUsernameIgnoreCase(username); if (userOpt.isPresent()) { - for (Authority authority : userOpt.get().getAuthorities()) { + User user = userOpt.get(); + for (Authority authority : user.getAuthorities()) { if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) { return; } } - userRepository.delete(userOpt.get()); + deleteUserRelatedData(user); + userRepository.delete(user); + persistentLoginRepository.deleteByUsername(username); } invalidateUserSessions(username); } + private void deleteUserRelatedData(User user) { + log.info("Deleting all associated data for user: {}", user.getUsername()); + + // Delete server certificate (non-nullable OneToOne → User) + userServerCertificateService.deleteUserCertificate(user.getId()); + + // Delete FileShareAccess records where this user is the accessor + fileShareAccessRepository.deleteByUser(user); + + // Delete FileShare records where this user is the recipient (shared with them by others). + // FileShareAccess for those shares must be cleared first (no cascade from FileShare side). + List sharesTargetingUser = fileShareRepository.findBySharedWithUser(user); + sharesTargetingUser.forEach(fileShareAccessRepository::deleteByFileShare); + fileShareRepository.deleteAll(sharesTargetingUser); + + // Null out WorkflowParticipant.user for sessions this user participates in but does not + // own. + // The participant record is retained to preserve the workflow audit trail. + workflowParticipantRepository.clearUserReferences(user); + + // Break circular FK: null out stored_files.workflow_session_id before deleting sessions + storedFileRepository.clearWorkflowSessionReferencesByOwner(user); + + // Delete WorkflowSessions (CascadeType.ALL cascades to WorkflowParticipant) + workflowSessionRepository.deleteAll( + workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user)); + + // Collect storage keys for physical cleanup before deleting DB records + List files = storedFileRepository.findAllByOwner(user); + List storageKeys = + files.stream() + .flatMap( + f -> + java.util.stream.Stream.of( + f.getStorageKey(), + f.getHistoryStorageKey(), + f.getAuditLogStorageKey())) + .filter(k -> k != null && !k.isBlank()) + .toList(); + + // Clear FileShareAccess per share (no cascade from FileShare), then delete StoredFiles + // (CascadeType.ALL on StoredFile.shares cascades to FileShare) + for (StoredFile file : files) { + file.getShares().forEach(fileShareAccessRepository::deleteByFileShare); + } + storedFileRepository.deleteAll(files); + + // Schedule physical deletion of all storage blobs; StorageCleanupService handles retry + for (String key : storageKeys) { + StorageCleanupEntry entry = new StorageCleanupEntry(); + entry.setStorageKey(key); + storageCleanupEntryRepository.save(entry); + } + } + public boolean usernameExists(String username) { return findByUsername(username).isPresent(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java new file mode 100644 index 0000000000..d769997fbc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/config/StorageProviderConfig.java @@ -0,0 +1,74 @@ +package stirling.software.proprietary.storage.config; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Locale; +import java.util.Optional; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.configuration.InstallationPathConfig; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.storage.provider.DatabaseStorageProvider; +import stirling.software.proprietary.storage.provider.LocalStorageProvider; +import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.repository.StoredFileBlobRepository; + +@Configuration +@RequiredArgsConstructor +@Slf4j +public class StorageProviderConfig { + + private final ApplicationProperties applicationProperties; + private final StoredFileBlobRepository storedFileBlobRepository; + + @Bean + public StorageProvider storageProvider() { + boolean storageEnabled = applicationProperties.getStorage().isEnabled(); + String providerName = + Optional.ofNullable(applicationProperties.getStorage().getProvider()) + .orElse("local") + .trim() + .toLowerCase(Locale.ROOT); + if ("database".equals(providerName)) { + return new DatabaseStorageProvider(storedFileBlobRepository); + } + if (!"local".equals(providerName)) { + throw new IllegalStateException("Storage provider not supported: " + providerName); + } + String basePathValue = applicationProperties.getStorage().getLocal().getBasePath(); + if (basePathValue == null || basePathValue.isBlank()) { + if (storageEnabled) { + throw new IllegalStateException("Storage base path is not configured"); + } + basePathValue = InstallationPathConfig.getPath() + "storage"; + } + Path basePath = Paths.get(basePathValue).toAbsolutePath().normalize(); + Path installRoot = Paths.get(InstallationPathConfig.getPath()).toAbsolutePath().normalize(); + if (!basePath.startsWith(installRoot)) { + // Warn rather than hard-fail: admins may legitimately point storage at an external + // volume, but an unexpected path could indicate a misconfiguration or traversal + // attempt. + log.warn( + "Storage basePath '{}' is outside the installation directory '{}'. " + + "Verify this is intentional.", + basePath, + installRoot); + } + if (storageEnabled) { + try { + Files.createDirectories(basePath); + } catch (IOException e) { + throw new IllegalStateException( + "Unable to create storage base directory: " + basePath, e); + } + } + return new LocalStorageProvider(basePath); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileStorageController.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileStorageController.java new file mode 100644 index 0000000000..8429135204 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/controller/FileStorageController.java @@ -0,0 +1,270 @@ +package stirling.software.proprietary.storage.controller; + +import java.util.List; +import java.util.Locale; + +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.FileShare; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.model.api.CreateShareLinkRequest; +import stirling.software.proprietary.storage.model.api.ShareLinkAccessResponse; +import stirling.software.proprietary.storage.model.api.ShareLinkMetadataResponse; +import stirling.software.proprietary.storage.model.api.ShareLinkResponse; +import stirling.software.proprietary.storage.model.api.ShareWithUserRequest; +import stirling.software.proprietary.storage.model.api.StoredFileResponse; +import stirling.software.proprietary.storage.service.FileStorageService; + +@RestController +@RequestMapping("/api/v1/storage") +@RequiredArgsConstructor +public class FileStorageController { + + private final FileStorageService fileStorageService; + + @PostMapping( + value = "/files", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public StoredFileResponse uploadFile( + @RequestPart("file") MultipartFile file, + @RequestPart(name = "historyBundle", required = false) MultipartFile historyBundle, + @RequestPart(name = "auditLog", required = false) MultipartFile auditLog) { + User user = fileStorageService.requireAuthenticatedUser(); + return fileStorageService.storeFileResponse(user, file, historyBundle, auditLog); + } + + @PutMapping( + value = "/files/{fileId}", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public StoredFileResponse updateFile( + @PathVariable Long fileId, + @RequestPart("file") MultipartFile file, + @RequestPart(name = "historyBundle", required = false) MultipartFile historyBundle, + @RequestPart(name = "auditLog", required = false) MultipartFile auditLog) { + User user = fileStorageService.requireAuthenticatedUser(); + return fileStorageService.updateFileResponse(user, fileId, file, historyBundle, auditLog); + } + + @GetMapping(value = "/files", produces = MediaType.APPLICATION_JSON_VALUE) + public List listFiles() { + User user = fileStorageService.requireAuthenticatedUser(); + return fileStorageService.listAccessibleFileResponses(user); + } + + @GetMapping(value = "/files/{fileId}", produces = MediaType.APPLICATION_JSON_VALUE) + public StoredFileResponse getFileMetadata(@PathVariable Long fileId) { + User user = fileStorageService.requireAuthenticatedUser(); + return fileStorageService.getAccessibleFileResponse(user, fileId); + } + + @GetMapping("/files/{fileId}/download") + public ResponseEntity downloadFile( + @PathVariable Long fileId, + @RequestParam(name = "inline", defaultValue = "false") boolean inline) { + User user = fileStorageService.requireAuthenticatedUser(); + StoredFile file = fileStorageService.getAccessibleFile(user, fileId); + fileStorageService.requireReadAccess(user, file); + return buildFileResponse(file, inline); + } + + @DeleteMapping("/files/{fileId}") + public ResponseEntity deleteFile(@PathVariable Long fileId) { + User user = fileStorageService.requireAuthenticatedUser(); + StoredFile file = fileStorageService.getOwnedFile(user, fileId); + fileStorageService.deleteFile(user, file); + return ResponseEntity.noContent().build(); + } + + @PostMapping( + value = "/files/{fileId}/shares/users", + produces = MediaType.APPLICATION_JSON_VALUE) + public StoredFileResponse shareWithUser( + @PathVariable Long fileId, @RequestBody ShareWithUserRequest request) { + User owner = fileStorageService.requireAuthenticatedUser(); + if (request == null || request.getUsername() == null || request.getUsername().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is required"); + } + return fileStorageService.shareWithUserResponse( + owner, + fileId, + request.getUsername(), + fileStorageService.normalizeShareRole(request.getAccessRole())); + } + + @DeleteMapping("/files/{fileId}/shares/users/{username}") + public ResponseEntity revokeUserShare( + @PathVariable Long fileId, @PathVariable String username) { + User owner = fileStorageService.requireAuthenticatedUser(); + StoredFile file = fileStorageService.getOwnedFile(owner, fileId); + fileStorageService.revokeUserShare(owner, file, username); + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/files/{fileId}/shares/self") + public ResponseEntity leaveUserShare(@PathVariable Long fileId) { + User user = fileStorageService.requireAuthenticatedUser(); + StoredFile file = fileStorageService.getAccessibleFile(user, fileId); + fileStorageService.leaveUserShare(user, file); + return ResponseEntity.noContent().build(); + } + + @PostMapping( + value = "/files/{fileId}/shares/links", + produces = MediaType.APPLICATION_JSON_VALUE) + public ShareLinkResponse createShareLink( + @PathVariable Long fileId, @RequestBody CreateShareLinkRequest request) { + User owner = fileStorageService.requireAuthenticatedUser(); + StoredFile file = fileStorageService.getOwnedFile(owner, fileId); + FileShare share = + fileStorageService.createShareLink( + owner, + file, + fileStorageService.normalizeShareRole( + request != null ? request.getAccessRole() : null)); + return ShareLinkResponse.builder() + .token(share.getShareToken()) + .accessRole( + share.getAccessRole() != null + ? share.getAccessRole().name().toLowerCase(Locale.ROOT) + : null) + .createdAt(share.getCreatedAt()) + .expiresAt(share.getExpiresAt()) + .build(); + } + + @DeleteMapping("/files/{fileId}/shares/links/{token}") + public ResponseEntity revokeShareLink( + @PathVariable Long fileId, @PathVariable String token) { + User owner = fileStorageService.requireAuthenticatedUser(); + StoredFile file = fileStorageService.getOwnedFile(owner, fileId); + fileStorageService.revokeShareLink(owner, file, token); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/share-links/{token}") + public ResponseEntity downloadShareLink( + @PathVariable String token, + Authentication authentication, + @RequestParam(name = "inline", defaultValue = "false") boolean inline) { + fileStorageService.ensureShareLinksEnabled(); + FileShare share = fileStorageService.getShareByToken(token); + if (!fileStorageService.canAccessShareLink(share, authentication)) { + HttpStatus status = + isAuthenticated(authentication) + ? HttpStatus.FORBIDDEN + : HttpStatus.UNAUTHORIZED; + String message = + status == HttpStatus.FORBIDDEN + ? "Access denied for this share link" + : "Authentication required for this share link"; + throw new ResponseStatusException(status, message); + } + fileStorageService.requireReadAccess(share); + fileStorageService.recordShareAccess(share, authentication, inline); + StoredFile file = share.getFile(); + return buildFileResponse(file, inline); + } + + @GetMapping("/share-links/{token}/metadata") + public ShareLinkMetadataResponse getShareLinkMetadata( + @PathVariable String token, Authentication authentication) { + fileStorageService.ensureShareLinksEnabled(); + FileShare share = fileStorageService.getShareByToken(token); + if (!fileStorageService.canAccessShareLink(share, authentication)) { + HttpStatus status = + isAuthenticated(authentication) + ? HttpStatus.FORBIDDEN + : HttpStatus.UNAUTHORIZED; + String message = + status == HttpStatus.FORBIDDEN + ? "Access denied for this share link" + : "Authentication required for this share link"; + throw new ResponseStatusException(status, message); + } + StoredFile file = share.getFile(); + User currentUser = fileStorageService.requireAuthenticatedUser(); + boolean ownedByCurrentUser = + currentUser != null + && file.getOwner() != null + && currentUser.getId().equals(file.getOwner().getId()); + return ShareLinkMetadataResponse.builder() + .shareToken(share.getShareToken()) + .fileId(file.getId()) + .fileName(file.getOriginalFilename()) + .owner(file.getOwner() != null ? file.getOwner().getUsername() : null) + .ownedByCurrentUser(ownedByCurrentUser) + .accessRole( + share.getAccessRole() != null + ? share.getAccessRole().name().toLowerCase(Locale.ROOT) + : null) + .createdAt(share.getCreatedAt()) + .expiresAt(share.getExpiresAt()) + .build(); + } + + @GetMapping("/share-links/accessed") + public List listAccessedShareLinks() { + fileStorageService.ensureShareLinksEnabled(); + User user = fileStorageService.requireAuthenticatedUser(); + return fileStorageService.listAccessedShareLinkResponses(user); + } + + @GetMapping("/files/{fileId}/shares/links/{token}/accesses") + public List listShareAccesses( + @PathVariable Long fileId, @PathVariable String token) { + fileStorageService.ensureShareLinksEnabled(); + User owner = fileStorageService.requireAuthenticatedUser(); + StoredFile file = fileStorageService.getOwnedFile(owner, fileId); + return fileStorageService.listShareAccessResponses(owner, file, token); + } + + private ResponseEntity buildFileResponse( + StoredFile file, boolean inline) { + org.springframework.core.io.Resource resource = fileStorageService.loadFile(file); + String contentType = + file.getContentType() == null + ? MediaType.APPLICATION_OCTET_STREAM_VALUE + : file.getContentType(); + ContentDisposition disposition = + ContentDisposition.builder(inline ? "inline" : "attachment") + .filename(file.getOriginalFilename()) + .build(); + HttpHeaders headers = new HttpHeaders(); + headers.setContentDisposition(disposition); + try { + headers.setContentType(MediaType.parseMediaType(contentType)); + } catch (IllegalArgumentException ex) { + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + } + headers.setContentLength(file.getSizeBytes()); + return ResponseEntity.ok().headers(headers).body(resource); + } + + private boolean isAuthenticated(Authentication authentication) { + return authentication != null + && authentication.isAuthenticated() + && !"anonymousUser".equals(authentication.getPrincipal()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java new file mode 100644 index 0000000000..1c9f7ab765 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java @@ -0,0 +1,81 @@ +package stirling.software.proprietary.storage.converter; + +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import lombok.extern.slf4j.Slf4j; + +/** + * JPA AttributeConverter for storing Map as JSON in database columns. + * + *

Converts between Java Map objects and JSON strings for PostgreSQL JSONB or TEXT columns. + * Includes backward compatibility handling for legacy double-encoded JSON data. + */ +@Converter +@Slf4j +public class JsonMapConverter implements AttributeConverter, String> { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(Map attribute) { + if (attribute == null || attribute.isEmpty()) { + return null; + } + + try { + return objectMapper.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + log.error("Failed to convert map to JSON", e); + throw new RuntimeException("Failed to convert map to JSON", e); + } + } + + @Override + public Map convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) { + return new HashMap<>(); + } + + try { + // Try normal parsing first + return objectMapper.readValue(dbData, new TypeReference>() {}); + } catch (JsonProcessingException e) { + // Fallback: try double-parsing for legacy double-encoded data + // This handles data that was stored as JSON strings instead of JSON objects + log.debug("Attempting double-decode fallback for legacy metadata format"); + try { + JsonNode node = objectMapper.readTree(dbData); + if (node.isTextual()) { + log.warn( + "╔════════════════════════════════════════════════════════════════════╗"); + log.warn( + "║ WARNING: DOUBLE-ENCODED JSON DETECTED - LEGACY DATA FOUND ║"); + log.warn( + "║ This should not occur in newly created records. ║"); + log.warn( + "║ Data preview: {}", + dbData.length() > 100 ? dbData.substring(0, 100) + "..." : dbData); + log.warn( + "╚════════════════════════════════════════════════════════════════════╝"); + return objectMapper.readValue( + node.asText(), new TypeReference>() {}); + } + } catch (JsonProcessingException e2) { + log.error("Failed to parse metadata even with double-decode fallback", e2); + } + + // If all parsing fails, return empty map to prevent application errors + log.error("Unable to parse JSON metadata, returning empty map", e); + return new HashMap<>(); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FilePurpose.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FilePurpose.java new file mode 100644 index 0000000000..8487abf50d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FilePurpose.java @@ -0,0 +1,19 @@ +package stirling.software.proprietary.storage.model; + +/** + * Defines the purpose classification for stored files. Used to categorize files based on their role + * in the system. + */ +public enum FilePurpose { + /** Regular file sharing - generic uploaded files */ + GENERIC, + + /** Original PDF in a signing session - the document to be signed */ + SIGNING_ORIGINAL, + + /** Final signed PDF - the completed document with all signatures applied */ + SIGNING_SIGNED, + + /** Audit trail for signing session - history and metadata */ + SIGNING_HISTORY +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java new file mode 100644 index 0000000000..1b0fd86f78 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java @@ -0,0 +1,77 @@ +package stirling.software.proprietary.storage.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.security.model.User; + +/** Represents a file sharing relationship between a file and a user or token. */ +@Entity +@Table( + name = "file_shares", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_file_share_user", + columnNames = {"stored_file_id", "shared_with_user_id"}), + @UniqueConstraint( + name = "uk_file_share_token", + columnNames = {"share_token"}) + }, + indexes = { + @Index(name = "idx_file_shares_file_id", columnList = "stored_file_id"), + @Index(name = "idx_file_shares_share_token", columnList = "share_token") + }) +@NoArgsConstructor +@Getter +@Setter +public class FileShare implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "file_share_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "stored_file_id", nullable = false) + private StoredFile file; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "shared_with_user_id") + private User sharedWithUser; + + @Column(name = "share_token", unique = true) + private String shareToken; + + @Enumerated(EnumType.STRING) + @Column(name = "access_role") + private ShareAccessRole accessRole; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java new file mode 100644 index 0000000000..49f75a4a4c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java @@ -0,0 +1,64 @@ +package stirling.software.proprietary.storage.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.security.model.User; + +@Entity +@Table( + name = "file_share_accesses", + indexes = { + @Index(name = "idx_share_access_file_share", columnList = "file_share_id"), + @Index(name = "idx_share_access_user", columnList = "user_id"), + @Index( + name = "idx_share_access_file_share_accessed", + columnList = "file_share_id, accessed_at") + }) +@NoArgsConstructor +@Getter +@Setter +public class FileShareAccess implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "file_share_access_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "file_share_id", nullable = false) + private FileShare fileShare; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "access_type", nullable = false) + private FileShareAccessType accessType; + + @CreationTimestamp + @Column(name = "accessed_at", updatable = false) + private LocalDateTime accessedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccessType.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccessType.java new file mode 100644 index 0000000000..43e105d4c3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccessType.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.storage.model; + +public enum FileShareAccessType { + VIEW, + DOWNLOAD +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/ShareAccessRole.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/ShareAccessRole.java new file mode 100644 index 0000000000..d80c08a1ae --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/ShareAccessRole.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.storage.model; + +public enum ShareAccessRole { + EDITOR, + COMMENTER, + VIEWER +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java new file mode 100644 index 0000000000..3158f4c041 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java @@ -0,0 +1,47 @@ +package stirling.software.proprietary.storage.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "storage_cleanup_entries") +@NoArgsConstructor +@Getter +@Setter +public class StorageCleanupEntry implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "cleanup_entry_id") + private Long id; + + @Column(name = "storage_key", nullable = false, length = 128) + private String storageKey; + + @Column(name = "attempt_count") + private int attemptCount; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java new file mode 100644 index 0000000000..0128a2dfdf --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java @@ -0,0 +1,116 @@ +package stirling.software.proprietary.storage.model; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.workflow.model.WorkflowSession; + +@Entity +@Table( + name = "stored_files", + indexes = { + @Index(name = "idx_stored_files_owner", columnList = "owner_id"), + @Index(name = "idx_stored_files_workflow", columnList = "workflow_session_id") + }) +@NoArgsConstructor +@Getter +@Setter +public class StoredFile implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "stored_file_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_id", nullable = false) + private User owner; + + @Column(name = "original_filename", nullable = false) + private String originalFilename; + + @Column(name = "content_type") + private String contentType; + + @Column(name = "size_bytes") + private long sizeBytes; + + @Column(name = "storage_key", nullable = false, unique = true) + private String storageKey; + + @Column(name = "history_filename") + private String historyFilename; + + @Column(name = "history_content_type") + private String historyContentType; + + @Column(name = "history_size_bytes") + private Long historySizeBytes; + + @Column(name = "history_storage_key", unique = true) + private String historyStorageKey; + + @Column(name = "audit_log_filename") + private String auditLogFilename; + + @Column(name = "audit_log_content_type") + private String auditLogContentType; + + @Column(name = "audit_log_size_bytes") + private Long auditLogSizeBytes; + + @Column(name = "audit_log_storage_key", unique = true) + private String auditLogStorageKey; + + // Link to workflow if this file is part of a workflow + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "workflow_session_id") + private WorkflowSession workflowSession; + + // Purpose classification + @Column(name = "file_purpose") + @Enumerated(EnumType.STRING) + private FilePurpose purpose; + + @OneToMany( + mappedBy = "file", + fetch = FetchType.LAZY, + cascade = CascadeType.ALL, + orphanRemoval = true) + private Set shares = new HashSet<>(); + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java new file mode 100644 index 0000000000..52ef1107fc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java @@ -0,0 +1,31 @@ +package stirling.software.proprietary.storage.model; + +import java.io.Serializable; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "stored_file_blobs") +@NoArgsConstructor +@Getter +@Setter +public class StoredFileBlob implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "storage_key", nullable = false, length = 128) + private String storageKey; + + @Lob + @Column(name = "data", nullable = false, columnDefinition = "BYTEA") + private byte[] data; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateShareLinkRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateShareLinkRequest.java new file mode 100644 index 0000000000..8653944ca3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/CreateShareLinkRequest.java @@ -0,0 +1,12 @@ +package stirling.software.proprietary.storage.model.api; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class CreateShareLinkRequest { + private String accessRole; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/ShareLinkAccessResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/ShareLinkAccessResponse.java new file mode 100644 index 0000000000..ead104ba48 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/ShareLinkAccessResponse.java @@ -0,0 +1,14 @@ +package stirling.software.proprietary.storage.model.api; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ShareLinkAccessResponse { + private final String username; + private final String accessType; + private final LocalDateTime accessedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/ShareLinkMetadataResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/ShareLinkMetadataResponse.java new file mode 100644 index 0000000000..0d6c9ed32a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/ShareLinkMetadataResponse.java @@ -0,0 +1,20 @@ +package stirling.software.proprietary.storage.model.api; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ShareLinkMetadataResponse { + private final String shareToken; + private final Long fileId; + private final String fileName; + private final String owner; + private final boolean ownedByCurrentUser; + private final String accessRole; + private final LocalDateTime createdAt; + private final LocalDateTime expiresAt; + private final LocalDateTime lastAccessedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/ShareLinkResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/ShareLinkResponse.java new file mode 100644 index 0000000000..89c53e999a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/ShareLinkResponse.java @@ -0,0 +1,15 @@ +package stirling.software.proprietary.storage.model.api; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ShareLinkResponse { + private final String token; + private final String accessRole; + private final LocalDateTime createdAt; + private final LocalDateTime expiresAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/ShareWithUserRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/ShareWithUserRequest.java new file mode 100644 index 0000000000..d7ae8cf94b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/ShareWithUserRequest.java @@ -0,0 +1,13 @@ +package stirling.software.proprietary.storage.model.api; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class ShareWithUserRequest { + private String username; + private String accessRole; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/SharedUserResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/SharedUserResponse.java new file mode 100644 index 0000000000..85f3a0d657 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/SharedUserResponse.java @@ -0,0 +1,11 @@ +package stirling.software.proprietary.storage.model.api; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class SharedUserResponse { + private final String username; + private final String accessRole; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java new file mode 100644 index 0000000000..1cf6a20c8e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/api/StoredFileResponse.java @@ -0,0 +1,25 @@ +package stirling.software.proprietary.storage.model.api; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class StoredFileResponse { + private final Long id; + private final String fileName; + private final String contentType; + private final long sizeBytes; + private final String owner; + private final boolean ownedByCurrentUser; + private final String accessRole; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + private final List sharedWithUsers; + private final List sharedUsers; + private final List shareLinks; + private final String filePurpose; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/DatabaseStorageProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/DatabaseStorageProvider.java new file mode 100644 index 0000000000..ed0fdfce58 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/DatabaseStorageProvider.java @@ -0,0 +1,53 @@ +package stirling.software.proprietary.storage.provider; + +import java.io.IOException; +import java.util.UUID; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.web.multipart.MultipartFile; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.StoredFileBlob; +import stirling.software.proprietary.storage.repository.StoredFileBlobRepository; + +@RequiredArgsConstructor +public class DatabaseStorageProvider implements StorageProvider { + + private final StoredFileBlobRepository storedFileBlobRepository; + + @Override + public StoredObject store(User owner, MultipartFile file) throws IOException { + String storageKey = UUID.randomUUID().toString(); + StoredFileBlob blob = new StoredFileBlob(); + blob.setStorageKey(storageKey); + blob.setData(file.getBytes()); + storedFileBlobRepository.save(blob); + + return StoredObject.builder() + .storageKey(storageKey) + .originalFilename(file.getOriginalFilename()) + .contentType(file.getContentType()) + .sizeBytes(file.getSize()) + .build(); + } + + @Override + public Resource load(String storageKey) throws IOException { + StoredFileBlob blob = + storedFileBlobRepository + .findById(storageKey) + .orElseThrow(() -> new IOException("File not found")); + return new ByteArrayResource(blob.getData()); + } + + @Override + public void delete(String storageKey) throws IOException { + if (!storedFileBlobRepository.existsById(storageKey)) { + return; + } + storedFileBlobRepository.deleteById(storageKey); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/LocalStorageProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/LocalStorageProvider.java new file mode 100644 index 0000000000..5790d43728 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/LocalStorageProvider.java @@ -0,0 +1,82 @@ +package stirling.software.proprietary.storage.provider; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.web.multipart.MultipartFile; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.security.model.User; + +@RequiredArgsConstructor +public class LocalStorageProvider implements StorageProvider { + + private final Path basePath; + + @Override + public StoredObject store(User owner, MultipartFile file) throws IOException { + String originalFilename = sanitizeFilename(file.getOriginalFilename()); + String storageKey = + owner.getId() + + "/" + + UUID.randomUUID() + + "_" + + Optional.ofNullable(originalFilename).orElse("file"); + Path targetPath = basePath.resolve(storageKey).normalize(); + + if (!targetPath.startsWith(basePath)) { + throw new IOException("Resolved storage path is outside the storage directory"); + } + + Files.createDirectories(targetPath.getParent()); + try (InputStream inputStream = file.getInputStream()) { + Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING); + } + + return StoredObject.builder() + .storageKey(storageKey) + .originalFilename(originalFilename) + .contentType(file.getContentType()) + .sizeBytes(file.getSize()) + .build(); + } + + @Override + public Resource load(String storageKey) throws IOException { + Path targetPath = basePath.resolve(storageKey).normalize(); + if (!targetPath.startsWith(basePath)) { + throw new IOException("Resolved storage path is outside the storage directory"); + } + + if (!Files.exists(targetPath)) { + throw new IOException("File not found"); + } + + return new FileSystemResource(targetPath.toFile()); + } + + @Override + public void delete(String storageKey) throws IOException { + Path targetPath = basePath.resolve(storageKey).normalize(); + if (!targetPath.startsWith(basePath)) { + throw new IOException("Resolved storage path is outside the storage directory"); + } + Files.deleteIfExists(targetPath); + } + + private String sanitizeFilename(String filename) { + if (filename == null || filename.isBlank()) { + return "file"; + } + return Paths.get(filename).getFileName().toString(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StorageProvider.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StorageProvider.java new file mode 100644 index 0000000000..5433e68ba4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StorageProvider.java @@ -0,0 +1,16 @@ +package stirling.software.proprietary.storage.provider; + +import java.io.IOException; + +import org.springframework.core.io.Resource; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.proprietary.security.model.User; + +public interface StorageProvider { + StoredObject store(User owner, MultipartFile file) throws IOException; + + Resource load(String storageKey) throws IOException; + + void delete(String storageKey) throws IOException; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StoredObject.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StoredObject.java new file mode 100644 index 0000000000..d58f223c75 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/provider/StoredObject.java @@ -0,0 +1,13 @@ +package stirling.software.proprietary.storage.provider; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class StoredObject { + private final String storageKey; + private final String originalFilename; + private final String contentType; + private final long sizeBytes; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareAccessRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareAccessRepository.java new file mode 100644 index 0000000000..1affa2229e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareAccessRepository.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.storage.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.FileShare; +import stirling.software.proprietary.storage.model.FileShareAccess; + +public interface FileShareAccessRepository extends JpaRepository { + @Query( + "SELECT a FROM FileShareAccess a " + + "LEFT JOIN FETCH a.user " + + "WHERE a.fileShare = :fileShare " + + "ORDER BY a.accessedAt DESC") + List findByFileShareWithUserOrderByAccessedAtDesc( + @Param("fileShare") FileShare fileShare); + + void deleteByFileShare(FileShare fileShare); + + void deleteByUser(User user); + + @Query( + "SELECT a FROM FileShareAccess a " + + "JOIN FETCH a.fileShare s " + + "JOIN FETCH s.file f " + + "LEFT JOIN FETCH f.owner " + + "WHERE a.user = :user " + + "ORDER BY a.accessedAt DESC") + List findByUserWithShareAndFile(@Param("user") User user); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareRepository.java new file mode 100644 index 0000000000..5855f893fd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/FileShareRepository.java @@ -0,0 +1,39 @@ +package stirling.software.proprietary.storage.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.FileShare; +import stirling.software.proprietary.storage.model.StoredFile; + +public interface FileShareRepository extends JpaRepository { + Optional findByFileAndSharedWithUser(StoredFile file, User sharedWithUser); + + Optional findByShareToken(String shareToken); + + @Query( + "SELECT s FROM FileShare s " + + "JOIN FETCH s.file f " + + "LEFT JOIN FETCH f.owner " + + "WHERE s.shareToken = :shareToken") + Optional findByShareTokenWithFile(@Param("shareToken") String shareToken); + + @Query("SELECT s FROM FileShare s WHERE s.file = :file AND s.shareToken IS NOT NULL") + List findShareLinks(@Param("file") StoredFile file); + + List findBySharedWithUser(User sharedWithUser); + + List findByExpiresAtBeforeAndShareTokenNotNull(java.time.LocalDateTime now); + + @Query( + "SELECT s FROM FileShare s " + + "JOIN FETCH s.file f " + + "WHERE s.sharedWithUser = :user AND f IN :files") + List findBySharedWithUserAndFileIn( + @Param("user") User user, @Param("files") List files); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StorageCleanupEntryRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StorageCleanupEntryRepository.java new file mode 100644 index 0000000000..cff3a1ac13 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StorageCleanupEntryRepository.java @@ -0,0 +1,11 @@ +package stirling.software.proprietary.storage.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import stirling.software.proprietary.storage.model.StorageCleanupEntry; + +public interface StorageCleanupEntryRepository extends JpaRepository { + List findTop50ByOrderByUpdatedAtAsc(); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileBlobRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileBlobRepository.java new file mode 100644 index 0000000000..811a464089 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileBlobRepository.java @@ -0,0 +1,7 @@ +package stirling.software.proprietary.storage.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import stirling.software.proprietary.storage.model.StoredFileBlob; + +public interface StoredFileBlobRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java new file mode 100644 index 0000000000..bebe65c684 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/repository/StoredFileRepository.java @@ -0,0 +1,69 @@ +package stirling.software.proprietary.storage.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.workflow.model.WorkflowSession; + +public interface StoredFileRepository extends JpaRepository { + Optional findByIdAndOwner(Long id, User owner); + + @Query( + "SELECT DISTINCT f FROM StoredFile f " + + "LEFT JOIN FETCH f.owner " + + "LEFT JOIN FETCH f.shares s " + + "LEFT JOIN FETCH s.sharedWithUser " + + "WHERE f.id = :id AND f.owner = :owner") + Optional findByIdAndOwnerWithShares( + @Param("id") Long id, @Param("owner") User owner); + + @Query( + "SELECT DISTINCT f FROM StoredFile f " + + "LEFT JOIN FETCH f.owner " + + "LEFT JOIN FETCH f.shares s " + + "LEFT JOIN FETCH s.sharedWithUser " + + "WHERE f.id = :id") + Optional findByIdWithShares(@Param("id") Long id); + + @Query( + "SELECT DISTINCT f FROM StoredFile f " + + "LEFT JOIN FETCH f.owner " + + "LEFT JOIN FETCH f.shares s " + + "LEFT JOIN FETCH s.sharedWithUser " + + "WHERE f.owner = :user " + + "OR s.sharedWithUser = :user") + List findAccessibleFiles(@Param("user") User user); + + @Query( + "SELECT COALESCE(SUM(f.sizeBytes + COALESCE(f.historySizeBytes, 0) " + + "+ COALESCE(f.auditLogSizeBytes, 0)), 0) " + + "FROM StoredFile f WHERE f.owner = :owner") + long sumStorageBytesByOwner(@Param("owner") User owner); + + @Query( + "SELECT COALESCE(SUM(f.sizeBytes + COALESCE(f.historySizeBytes, 0) " + + "+ COALESCE(f.auditLogSizeBytes, 0)), 0) " + + "FROM StoredFile f") + long sumStorageBytesTotal(); + + /** Finds all files associated with a workflow session. */ + List findByWorkflowSession(WorkflowSession workflowSession); + + List findAllByOwner(User owner); + + @Modifying + @Transactional + @Query( + "UPDATE StoredFile sf SET sf.workflowSession = null " + + "WHERE sf.workflowSession IN " + + "(SELECT ws FROM WorkflowSession ws WHERE ws.owner = :user)") + void clearWorkflowSessionReferencesByOwner(@Param("user") User user); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java new file mode 100644 index 0000000000..ba94cab79d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/FileStorageService.java @@ -0,0 +1,1181 @@ +package stirling.software.proprietary.storage.service; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import jakarta.mail.MessagingException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.EmailService; +import stirling.software.proprietary.storage.model.FileShare; +import stirling.software.proprietary.storage.model.FileShareAccess; +import stirling.software.proprietary.storage.model.FileShareAccessType; +import stirling.software.proprietary.storage.model.ShareAccessRole; +import stirling.software.proprietary.storage.model.StorageCleanupEntry; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.model.api.ShareLinkAccessResponse; +import stirling.software.proprietary.storage.model.api.ShareLinkMetadataResponse; +import stirling.software.proprietary.storage.model.api.ShareLinkResponse; +import stirling.software.proprietary.storage.model.api.SharedUserResponse; +import stirling.software.proprietary.storage.model.api.StoredFileResponse; +import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.provider.StoredObject; +import stirling.software.proprietary.storage.repository.FileShareAccessRepository; +import stirling.software.proprietary.storage.repository.FileShareRepository; +import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; + +@Service +@Transactional +@RequiredArgsConstructor +@Slf4j +public class FileStorageService { + + // Requires at least 2-character TLD; rejects obvious non-addresses like a@b.c + private static final Pattern EMAIL_PATTERN = + Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$"); + + private final StoredFileRepository storedFileRepository; + private final FileShareRepository fileShareRepository; + private final FileShareAccessRepository fileShareAccessRepository; + private final UserRepository userRepository; + private final ApplicationProperties applicationProperties; + private final StorageProvider storageProvider; + private final Optional emailService; + private final StorageCleanupEntryRepository storageCleanupEntryRepository; + + public void ensureStorageEnabled() { + if (!applicationProperties.getSecurity().isEnableLogin()) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Storage requires login to be enabled"); + } + if (!applicationProperties.getStorage().isEnabled()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Storage is disabled"); + } + } + + public User requireAuthenticatedUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null + || !authentication.isAuthenticated() + || "anonymousUser".equals(authentication.getPrincipal())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated"); + } + + Object principal = authentication.getPrincipal(); + if (principal instanceof User user) { + return user; + } + + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unsupported user principal"); + } + + /** + * Lists all files accessible to a user, excluding workflow-shared files from participants' + * view. + * + *

Returns: - All files owned by the user (including their workflow files) - Files shared + * with the user via regular file sharing + * + *

Explicitly excludes: - Files shared with the user as a workflow participant (these are + * accessible via workflow-specific endpoints instead) + * + * @param user The user to query accessible files for + * @return List of accessible StoredFile entities + */ + public List listAccessibleFiles(User user) { + ensureStorageEnabled(); + return storedFileRepository.findAccessibleFiles(user); + } + + public StoredFile storeFile(User owner, MultipartFile file) { + return storeFile(owner, file, null, null); + } + + public StoredFile storeFile( + User owner, MultipartFile file, MultipartFile historyBundle, MultipartFile auditLog) { + ensureStorageEnabled(); + validateMainUpload(file); + + long uploadBytes = calculateUploadBytes(file, historyBundle, auditLog); + enforceStorageQuotas(owner, uploadBytes, 0); + + StoredObject mainObject = null; + StoredObject historyObject = null; + StoredObject auditObject = null; + try { + mainObject = storageProvider.store(owner, file); + if (isValidUpload(historyBundle)) { + historyObject = storageProvider.store(owner, historyBundle); + } + if (isValidUpload(auditLog)) { + auditObject = storageProvider.store(owner, auditLog); + } + + StoredFile storedFile = new StoredFile(); + storedFile.setOwner(owner); + storedFile.setOriginalFilename(mainObject.getOriginalFilename()); + storedFile.setContentType(mainObject.getContentType()); + storedFile.setSizeBytes(mainObject.getSizeBytes()); + storedFile.setStorageKey(mainObject.getStorageKey()); + applyHistoryMetadata(storedFile, historyObject); + applyAuditMetadata(storedFile, auditObject); + try { + return storedFileRepository.save(storedFile); + } catch (RuntimeException saveError) { + cleanupStoredObject(mainObject); + cleanupStoredObject(historyObject); + cleanupStoredObject(auditObject); + throw saveError; + } + } catch (IOException e) { + cleanupStoredObject(mainObject); + cleanupStoredObject(historyObject); + cleanupStoredObject(auditObject); + log.error( + "Failed to store file for user {} (name: {}, size: {})", + owner != null ? owner.getId() : null, + file != null ? file.getOriginalFilename() : null, + file != null ? file.getSize() : null, + e); + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to store file", e); + } + } + + public StoredFile replaceFile(User owner, StoredFile existing, MultipartFile file) { + return replaceFile(owner, existing, file, null, null); + } + + public StoredFile replaceFile( + User owner, + StoredFile existing, + MultipartFile file, + MultipartFile historyBundle, + MultipartFile auditLog) { + ensureStorageEnabled(); + if (!isOwner(existing, owner)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the owner can update"); + } + validateMainUpload(file); + + long newTotalBytes = calculateUploadBytes(file, historyBundle, auditLog, existing); + enforceStorageQuotas(owner, newTotalBytes, totalStoredBytes(existing)); + + StoredObject mainObject = null; + StoredObject historyObject = null; + StoredObject auditObject = null; + String oldStorageKey = existing.getStorageKey(); + String oldHistoryKey = existing.getHistoryStorageKey(); + String oldAuditKey = existing.getAuditLogStorageKey(); + + try { + mainObject = storageProvider.store(owner, file); + if (isValidUpload(historyBundle)) { + historyObject = storageProvider.store(owner, historyBundle); + } + if (isValidUpload(auditLog)) { + auditObject = storageProvider.store(owner, auditLog); + } + + existing.setOriginalFilename(mainObject.getOriginalFilename()); + existing.setContentType(mainObject.getContentType()); + existing.setSizeBytes(mainObject.getSizeBytes()); + existing.setStorageKey(mainObject.getStorageKey()); + if (historyObject != null) { + applyHistoryMetadata(existing, historyObject); + } + if (auditObject != null) { + applyAuditMetadata(existing, auditObject); + } + + StoredFile updated; + try { + updated = storedFileRepository.save(existing); + } catch (RuntimeException saveError) { + cleanupStoredObject(mainObject); + cleanupStoredObject(historyObject); + cleanupStoredObject(auditObject); + throw saveError; + } + cleanupStoredKey(oldStorageKey); + if (historyObject != null) { + cleanupStoredKey(oldHistoryKey); + } + if (auditObject != null) { + cleanupStoredKey(oldAuditKey); + } + + return updated; + } catch (IOException e) { + cleanupStoredObject(mainObject); + cleanupStoredObject(historyObject); + cleanupStoredObject(auditObject); + log.error( + "Failed to update stored file {} for user {}", + existing.getId(), + owner != null ? owner.getId() : null, + e); + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to update file", e); + } + } + + public StoredFile getAccessibleFile(User user, Long fileId) { + ensureStorageEnabled(); + StoredFile file = + storedFileRepository + .findByIdWithShares(fileId) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "File not found")); + if (isOwner(file, user)) { + return file; + } + + boolean sharedWithUser = + file.getShares().stream() + .anyMatch( + share -> + share.getSharedWithUser() != null + && share.getSharedWithUser() + .getId() + .equals(user.getId())); + if (!sharedWithUser) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied"); + } + + return file; + } + + public void requireEditorAccess(User user, StoredFile file) { + if (isOwner(file, user)) { + return; + } + ShareAccessRole role = resolveUserShareRole(file, user); + if (role != ShareAccessRole.EDITOR) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Insufficient permissions to download"); + } + } + + public void requireEditorAccess(FileShare share) { + ShareAccessRole role = resolveShareRole(share); + if (role != ShareAccessRole.EDITOR) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Insufficient permissions to download"); + } + } + + public void requireReadAccess(User user, StoredFile file) { + if (isOwner(file, user)) { + return; + } + ShareAccessRole role = resolveUserShareRole(file, user); + if (!hasReadAccess(role)) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Insufficient permissions to access this file"); + } + } + + public void requireReadAccess(FileShare share) { + ShareAccessRole role = resolveShareRole(share); + if (!hasReadAccess(role)) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Insufficient permissions to access this file"); + } + } + + public StoredFile getOwnedFile(User owner, Long fileId) { + ensureStorageEnabled(); + return storedFileRepository + .findByIdAndOwnerWithShares(fileId, owner) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "File not found")); + } + + public StoredFileResponse storeFileResponse(User owner, MultipartFile file) { + return storeFileResponse(owner, file, null, null); + } + + public StoredFileResponse storeFileResponse( + User owner, MultipartFile file, MultipartFile historyBundle, MultipartFile auditLog) { + StoredFile storedFile = storeFile(owner, file, historyBundle, auditLog); + return buildResponse(storedFile, owner); + } + + public StoredFileResponse updateFileResponse(User owner, Long fileId, MultipartFile file) { + return updateFileResponse(owner, fileId, file, null, null); + } + + public StoredFileResponse updateFileResponse( + User owner, + Long fileId, + MultipartFile file, + MultipartFile historyBundle, + MultipartFile auditLog) { + StoredFile existing = getOwnedFile(owner, fileId); + StoredFile updated = replaceFile(owner, existing, file, historyBundle, auditLog); + return buildResponse(updated, owner); + } + + public List listAccessibleFileResponses(User user) { + List files = listAccessibleFiles(user); + Map roleByFileId = new HashMap<>(); + if (!files.isEmpty()) { + List shares = fileShareRepository.findBySharedWithUserAndFileIn(user, files); + for (FileShare share : shares) { + StoredFile sharedFile = share.getFile(); + if (sharedFile != null && sharedFile.getId() != null) { + roleByFileId.put(sharedFile.getId(), resolveShareRole(share)); + } + } + } + return files.stream() + .sorted(Comparator.comparing(StoredFile::getCreatedAt).reversed()) + .map(file -> buildResponse(file, user, roleByFileId.get(file.getId()))) + .collect(Collectors.toList()); + } + + public StoredFileResponse getAccessibleFileResponse(User user, Long fileId) { + StoredFile file = getAccessibleFile(user, fileId); + return buildResponse(file, user); + } + + public StoredFileResponse shareWithUserResponse( + User owner, Long fileId, String username, ShareAccessRole role) { + StoredFile file = getOwnedFile(owner, fileId); + shareWithUser(owner, file, username, role); + StoredFile updated = getOwnedFile(owner, fileId); + return buildResponse(updated, owner); + } + + private StoredFileResponse buildResponse(StoredFile file, User currentUser) { + return buildResponse(file, currentUser, null); + } + + private StoredFileResponse buildResponse( + StoredFile file, User currentUser, ShareAccessRole accessRoleOverride) { + boolean ownedByCurrentUser = + file.getOwner() != null + && Objects.equals(file.getOwner().getId(), currentUser.getId()); + String accessRole = + ownedByCurrentUser + ? ShareAccessRole.EDITOR.name().toLowerCase(Locale.ROOT) + : Optional.ofNullable(accessRoleOverride) + .orElseGet(() -> resolveUserShareRole(file, currentUser)) + .name() + .toLowerCase(Locale.ROOT); + List sharedWithUsers = + ownedByCurrentUser + ? file.getShares().stream() + .map(FileShare::getSharedWithUser) + .filter(Objects::nonNull) + .map(User::getUsername) + .sorted(String.CASE_INSENSITIVE_ORDER) + .collect(Collectors.toList()) + : List.of(); + List shareLinks = + ownedByCurrentUser && isShareLinksEnabled() + ? file.getShares().stream() + .filter(share -> share.getShareToken() != null) + .filter(share -> !isShareLinkExpired(share)) + .map( + share -> + ShareLinkResponse.builder() + .token(share.getShareToken()) + .accessRole( + resolveShareRole(share) + .name() + .toLowerCase(Locale.ROOT)) + .createdAt(share.getCreatedAt()) + .expiresAt(share.getExpiresAt()) + .build()) + .sorted(Comparator.comparing(ShareLinkResponse::getCreatedAt)) + .collect(Collectors.toList()) + : List.of(); + List sharedUsers = + ownedByCurrentUser + ? file.getShares().stream() + .filter(share -> share.getSharedWithUser() != null) + .map( + share -> + SharedUserResponse.builder() + .username( + share.getSharedWithUser() + .getUsername()) + .accessRole( + resolveShareRole(share) + .name() + .toLowerCase(Locale.ROOT)) + .build()) + .sorted( + Comparator.comparing( + SharedUserResponse::getUsername, + String.CASE_INSENSITIVE_ORDER)) + .collect(Collectors.toList()) + : List.of(); + return StoredFileResponse.builder() + .id(file.getId()) + .fileName(file.getOriginalFilename()) + .contentType(file.getContentType()) + .sizeBytes(file.getSizeBytes()) + .owner(file.getOwner() != null ? file.getOwner().getUsername() : null) + .ownedByCurrentUser(ownedByCurrentUser) + .accessRole(accessRole) + .createdAt(file.getCreatedAt()) + .updatedAt(file.getUpdatedAt()) + .sharedWithUsers(sharedWithUsers) + .sharedUsers(sharedUsers) + .shareLinks(shareLinks) + .filePurpose( + file.getPurpose() != null + ? file.getPurpose().name().toLowerCase(Locale.ROOT) + : null) + .build(); + } + + public ShareAccessRole normalizeShareRole(String role) { + if (role == null || role.isBlank()) { + return ShareAccessRole.EDITOR; + } + try { + return ShareAccessRole.valueOf(role.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid share role"); + } + } + + private ShareAccessRole resolveShareRole(FileShare share) { + if (share == null || share.getAccessRole() == null) { + return ShareAccessRole.EDITOR; + } + return share.getAccessRole(); + } + + private ShareAccessRole resolveUserShareRole(StoredFile file, User user) { + if (file == null || user == null) { + return ShareAccessRole.VIEWER; + } + Optional share = fileShareRepository.findByFileAndSharedWithUser(file, user); + return share.map(this::resolveShareRole).orElse(ShareAccessRole.VIEWER); + } + + public org.springframework.core.io.Resource loadFile(StoredFile file) { + ensureStorageEnabled(); + try { + return storageProvider.load(file.getStorageKey()); + } catch (IOException e) { + log.error( + "Failed to load stored file {} (key: {})", + file != null ? file.getId() : null, + file != null ? file.getStorageKey() : null, + e); + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to load file", e); + } + } + + public void deleteFile(User owner, StoredFile file) { + ensureStorageEnabled(); + if (!isOwner(file, owner)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the owner can delete"); + } + validateWorkflowDeletion(file, owner); + List storageKeys = collectStorageKeys(file); + List shareLinks = fileShareRepository.findShareLinks(file); + for (FileShare share : shareLinks) { + fileShareAccessRepository.deleteByFileShare(share); + } + storedFileRepository.delete(file); + for (String storageKey : storageKeys) { + cleanupStoredKey(storageKey); + } + } + + public FileShare shareWithUser( + User owner, StoredFile file, String username, ShareAccessRole role) { + ensureStorageEnabled(); + ensureSharingEnabled(); + if (!isOwner(file, owner)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the owner can share"); + } + + String normalizedUsername = username != null ? username.trim() : ""; + boolean isEmail = isEmailAddress(normalizedUsername); + + Optional targetUserOpt = userRepository.findByUsernameIgnoreCase(normalizedUsername); + if (targetUserOpt.isPresent()) { + User targetUser = targetUserOpt.get(); + if (targetUser.getId().equals(owner.getId())) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Cannot share with yourself"); + } + + FileShare share = + fileShareRepository + .findByFileAndSharedWithUser(file, targetUser) + .map( + existingShare -> { + existingShare.setAccessRole(role); + return fileShareRepository.save(existingShare); + }) + .orElseGet( + () -> { + FileShare newShare = new FileShare(); + newShare.setFile(file); + newShare.setSharedWithUser(targetUser); + newShare.setAccessRole(role); + return fileShareRepository.save(newShare); + }); + + if (isEmail) { + if (!isEmailSharingEnabled()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Email sharing is disabled"); + } + if (!isShareLinksEnabled()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Share links must be enabled for email sharing"); + } + String shareLinkUrl = null; + FileShare linkShare = createShareLink(owner, file, role); + shareLinkUrl = buildShareLinkUrl(linkShare); + sendShareNotification(owner, file, normalizedUsername, role, shareLinkUrl); + } + + return share; + } + + if (!isEmail) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"); + } + if (!isEmailSharingEnabled()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email sharing is disabled"); + } + if (!isShareLinksEnabled()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Share links must be enabled for email sharing"); + } + + FileShare linkShare = createShareLink(owner, file, role); + sendShareNotification(owner, file, normalizedUsername, role, buildShareLinkUrl(linkShare)); + return linkShare; + } + + public void revokeUserShare(User owner, StoredFile file, String username) { + ensureStorageEnabled(); + if (!isOwner(file, owner)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the owner can revoke"); + } + User targetUser = + userRepository + .findByUsernameIgnoreCase(username) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "User not found")); + fileShareRepository + .findByFileAndSharedWithUser(file, targetUser) + .ifPresent(fileShareRepository::delete); + } + + public void leaveUserShare(User user, StoredFile file) { + ensureStorageEnabled(); + if (isOwner(file, user)) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Owners cannot leave their own file"); + } + FileShare share = + fileShareRepository + .findByFileAndSharedWithUser(file, user) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "Share not found")); + fileShareRepository.delete(share); + } + + public FileShare createShareLink(User owner, StoredFile file, ShareAccessRole role) { + ensureStorageEnabled(); + ensureShareLinksEnabled(); + if (!isOwner(file, owner)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the owner can share"); + } + + FileShare share = new FileShare(); + share.setFile(file); + share.setShareToken(UUID.randomUUID().toString()); + share.setAccessRole(role); + share.setExpiresAt(resolveShareLinkExpiration()); + return fileShareRepository.save(share); + } + + public void revokeShareLink(User owner, StoredFile file, String token) { + ensureStorageEnabled(); + if (!isOwner(file, owner)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Only the owner can revoke"); + } + FileShare share = + fileShareRepository + .findByShareToken(token) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "Share link not found")); + if (!share.getFile().getId().equals(file.getId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Share link mismatch"); + } + fileShareAccessRepository.deleteByFileShare(share); + fileShareRepository.delete(share); + } + + public FileShare getShareByToken(String token) { + ensureStorageEnabled(); + FileShare share = + fileShareRepository + .findByShareTokenWithFile(token) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "Share link not found")); + if (isShareLinkExpired(share)) { + log.debug("Share link access denied: token is expired"); + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Share link not found"); + } + return share; + } + + public boolean canAccessShareLink(FileShare share, Authentication authentication) { + ensureStorageEnabled(); + if (!isShareLinksEnabled()) { + return false; + } + if (isShareLinkExpired(share)) { + return false; + } + if (authentication == null + || !authentication.isAuthenticated() + || "anonymousUser".equals(authentication.getPrincipal())) { + return false; + } + // If this is a user-specific share, the authenticated user must be the intended recipient + // or the file owner. Without this check any authenticated user who obtains the token can + // download the file (IDOR). + if (share.getSharedWithUser() != null) { + User currentUser = extractAuthenticatedUser(authentication); + if (currentUser == null) { + return false; + } + boolean isIntendedRecipient = + share.getSharedWithUser().getId().equals(currentUser.getId()); + boolean isFileOwner = + share.getFile() != null + && share.getFile().getOwner() != null + && share.getFile().getOwner().getId().equals(currentUser.getId()); + if (!isIntendedRecipient && !isFileOwner) { + return false; + } + } + return true; + } + + public void recordShareAccess(FileShare share, Authentication authentication, boolean inline) { + if (share == null) { + return; + } + if (isShareLinkExpired(share)) { + return; + } + if (!isShareLinksEnabled()) { + return; + } + User user = extractAuthenticatedUser(authentication); + if (user == null) { + return; + } + FileShareAccess access = new FileShareAccess(); + access.setFileShare(share); + access.setUser(user); + access.setAccessType(inline ? FileShareAccessType.VIEW : FileShareAccessType.DOWNLOAD); + fileShareAccessRepository.save(access); + } + + public List listShareAccesses(User owner, StoredFile file, String token) { + ensureStorageEnabled(); + if (!isOwner(file, owner)) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Only the owner can view access"); + } + FileShare share = + fileShareRepository + .findByShareToken(token) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "Share link not found")); + if (!share.getFile().getId().equals(file.getId())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Share link mismatch"); + } + return fileShareAccessRepository.findByFileShareWithUserOrderByAccessedAtDesc(share); + } + + public List listShareAccessResponses( + User owner, StoredFile file, String token) { + return listShareAccesses(owner, file, token).stream() + .map( + access -> + ShareLinkAccessResponse.builder() + .username( + access.getUser() != null + ? access.getUser().getUsername() + : null) + .accessType(access.getAccessType().name()) + .accessedAt(access.getAccessedAt()) + .build()) + .collect(Collectors.toList()); + } + + public List listAccessedShareLinks(User user) { + ensureStorageEnabled(); + List accesses = fileShareAccessRepository.findByUserWithShareAndFile(user); + Map latestByToken = new LinkedHashMap<>(); + for (FileShareAccess access : accesses) { + if (access.getFileShare() == null) { + continue; + } + String token = access.getFileShare().getShareToken(); + if (token == null || token.isBlank()) { + continue; + } + if (isShareLinkExpired(access.getFileShare())) { + continue; + } + if (!latestByToken.containsKey(token)) { + latestByToken.put(token, access); + } + } + return List.copyOf(latestByToken.values()); + } + + public List listAccessedShareLinkResponses(User user) { + return listAccessedShareLinks(user).stream() + .map( + access -> { + FileShare share = access.getFileShare(); + StoredFile file = share != null ? share.getFile() : null; + boolean ownedByCurrentUser = + file != null + && file.getOwner() != null + && file.getOwner().getId().equals(user.getId()); + return ShareLinkMetadataResponse.builder() + .shareToken(share != null ? share.getShareToken() : null) + .fileId(file != null ? file.getId() : null) + .fileName(file != null ? file.getOriginalFilename() : null) + .owner( + file != null && file.getOwner() != null + ? file.getOwner().getUsername() + : null) + .ownedByCurrentUser(ownedByCurrentUser) + .accessRole( + share != null + ? resolveShareRole(share) + .name() + .toLowerCase(Locale.ROOT) + : null) + .createdAt(share != null ? share.getCreatedAt() : null) + .expiresAt(share != null ? share.getExpiresAt() : null) + .lastAccessedAt(access.getAccessedAt()) + .build(); + }) + .filter(response -> response.getShareToken() != null) + .collect(Collectors.toList()); + } + + public void ensureSharingEnabled() { + ensureStorageEnabled(); + if (!applicationProperties.getStorage().getSharing().isEnabled()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Sharing is disabled"); + } + } + + public void ensureShareLinksEnabled() { + ensureSharingEnabled(); + if (!isShareLinksEnabled()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Share links are disabled"); + } + } + + private boolean isShareLinksEnabled() { + if (!applicationProperties.getStorage().getSharing().isLinkEnabled()) { + return false; + } + String frontendUrl = applicationProperties.getSystem().getFrontendUrl(); + return frontendUrl != null && !frontendUrl.trim().isEmpty(); + } + + private boolean isEmailSharingEnabled() { + return applicationProperties.getStorage().getSharing().isEmailEnabled() + && applicationProperties.getMail().isEnabled(); + } + + private boolean isEmailAddress(String value) { + return value != null && EMAIL_PATTERN.matcher(value.trim()).matches(); + } + + private boolean isOwner(StoredFile file, User owner) { + return file.getOwner() != null && file.getOwner().getId().equals(owner.getId()); + } + + private User extractAuthenticatedUser(Authentication authentication) { + Object principal = authentication.getPrincipal(); + if (principal instanceof User user) { + return user; + } + return null; + } + + private static final Set BLOCKED_CONTENT_TYPES = + Set.of( + "application/x-msdownload", + "application/x-executable", + "application/x-sh", + "application/x-bat", + "application/x-msdos-program", + "application/x-msi", + "application/x-java-archive", + "application/java-archive"); + + private void validateMainUpload(MultipartFile file) { + if (!isValidUpload(file)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File is required"); + } + String contentType = file.getContentType(); + if (contentType != null + && BLOCKED_CONTENT_TYPES.contains(contentType.toLowerCase(Locale.ROOT))) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "File type not permitted: " + contentType); + } + } + + private boolean isValidUpload(MultipartFile file) { + return file != null && !file.isEmpty(); + } + + private long calculateUploadBytes( + MultipartFile file, MultipartFile historyBundle, MultipartFile auditLog) { + return safeSize(file) + safeSize(historyBundle) + safeSize(auditLog); + } + + private long calculateUploadBytes( + MultipartFile file, + MultipartFile historyBundle, + MultipartFile auditLog, + StoredFile existing) { + long historyBytes = + isValidUpload(historyBundle) + ? safeSize(historyBundle) + : safeStoredBytes(existing.getHistorySizeBytes()); + long auditBytes = + isValidUpload(auditLog) + ? safeSize(auditLog) + : safeStoredBytes(existing.getAuditLogSizeBytes()); + return safeSize(file) + historyBytes + auditBytes; + } + + private long safeSize(MultipartFile file) { + if (file == null) { + return 0; + } + return Math.max(0, file.getSize()); + } + + private long totalStoredBytes(StoredFile file) { + if (file == null) { + return 0; + } + return file.getSizeBytes() + + safeStoredBytes(file.getHistorySizeBytes()) + + safeStoredBytes(file.getAuditLogSizeBytes()); + } + + private long safeStoredBytes(Long value) { + if (value == null) { + return 0; + } + return Math.max(0, value); + } + + private void enforceStorageQuotas(User owner, long newBytes, long existingBytes) { + ApplicationProperties.Storage.Quotas quotas = + applicationProperties.getStorage().getQuotas(); + if (quotas == null) { + return; + } + long maxFileBytes = toBytes(quotas.getMaxFileMb()); + if (maxFileBytes > 0 && newBytes > maxFileBytes) { + throw new ResponseStatusException( + HttpStatus.PAYLOAD_TOO_LARGE, "Stored file exceeds the maximum size"); + } + + long delta = newBytes - existingBytes; + if (delta <= 0) { + return; + } + + long maxUserBytes = toBytes(quotas.getMaxStorageMbPerUser()); + if (maxUserBytes > 0) { + long currentBytes = storedFileRepository.sumStorageBytesByOwner(owner); + if (currentBytes + delta > maxUserBytes) { + throw new ResponseStatusException( + HttpStatus.PAYLOAD_TOO_LARGE, "User storage quota exceeded"); + } + } + + long maxTotalBytes = toBytes(quotas.getMaxStorageMbTotal()); + if (maxTotalBytes > 0) { + long totalBytes = storedFileRepository.sumStorageBytesTotal(); + if (totalBytes + delta > maxTotalBytes) { + throw new ResponseStatusException( + HttpStatus.PAYLOAD_TOO_LARGE, "System storage quota exceeded"); + } + } + } + + private long toBytes(long megabytes) { + if (megabytes <= 0) { + return megabytes; + } + return megabytes * 1024L * 1024L; + } + + private void applyHistoryMetadata(StoredFile storedFile, StoredObject historyObject) { + if (storedFile == null || historyObject == null) { + return; + } + storedFile.setHistoryFilename(historyObject.getOriginalFilename()); + storedFile.setHistoryContentType(historyObject.getContentType()); + storedFile.setHistorySizeBytes(historyObject.getSizeBytes()); + storedFile.setHistoryStorageKey(historyObject.getStorageKey()); + } + + private void applyAuditMetadata(StoredFile storedFile, StoredObject auditObject) { + if (storedFile == null || auditObject == null) { + return; + } + storedFile.setAuditLogFilename(auditObject.getOriginalFilename()); + storedFile.setAuditLogContentType(auditObject.getContentType()); + storedFile.setAuditLogSizeBytes(auditObject.getSizeBytes()); + storedFile.setAuditLogStorageKey(auditObject.getStorageKey()); + } + + private List collectStorageKeys(StoredFile file) { + if (file == null) { + return List.of(); + } + return java.util.stream.Stream.of( + file.getStorageKey(), + file.getHistoryStorageKey(), + file.getAuditLogStorageKey()) + .filter(value -> value != null && !value.isBlank()) + .collect(Collectors.toList()); + } + + private void cleanupStoredObject(StoredObject storedObject) { + if (storedObject == null) { + return; + } + cleanupStoredKey(storedObject.getStorageKey()); + } + + private void cleanupStoredKey(String storageKey) { + if (storageKey == null || storageKey.isBlank()) { + return; + } + try { + storageProvider.delete(storageKey); + } catch (IOException e) { + log.warn("Failed to delete storage key {}. Scheduling cleanup.", storageKey, e); + StorageCleanupEntry entry = new StorageCleanupEntry(); + entry.setStorageKey(storageKey); + storageCleanupEntryRepository.save(entry); + } + } + + private String buildShareLinkUrl(FileShare share) { + if (share == null || share.getShareToken() == null) { + return null; + } + String frontendUrl = applicationProperties.getSystem().getFrontendUrl(); + if (frontendUrl == null || frontendUrl.trim().isEmpty()) { + return null; + } + String normalized = frontendUrl.trim(); + if (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized + "/share/" + share.getShareToken(); + } + + private void sendShareNotification( + User owner, StoredFile file, String email, ShareAccessRole role, String shareLinkUrl) { + if (emailService.isEmpty() || !applicationProperties.getMail().isEnabled()) { + log.warn("Email sharing configured but mail service is unavailable"); + return; + } + String ownerName = owner != null ? owner.getUsername() : "A user"; + String fileName = file != null ? file.getOriginalFilename() : "a file"; + String subject = "A file was shared with you"; + StringBuilder body = new StringBuilder(); + body.append(ownerName) + .append(" shared \"") + .append(fileName) + .append("\" with ") + .append(role != null ? role.name().toLowerCase(Locale.ROOT) : "viewer") + .append(" access.") + .append(System.lineSeparator()) + .append(System.lineSeparator()); + if (shareLinkUrl != null) { + body.append("Open the shared file: ") + .append(shareLinkUrl) + .append(System.lineSeparator()); + } else { + String frontendUrl = applicationProperties.getSystem().getFrontendUrl(); + if (frontendUrl != null && !frontendUrl.trim().isEmpty()) { + body.append("Sign in to access the file: ").append(frontendUrl.trim()); + } + } + try { + emailService.get().sendPlainEmail(email, subject, body.toString(), false); + } catch (MessagingException ex) { + log.warn("Failed to send share email to {}", email, ex); + } + } + + private LocalDateTime resolveShareLinkExpiration() { + int days = applicationProperties.getStorage().getSharing().getLinkExpirationDays(); + if (days <= 0) { + return null; + } + return LocalDateTime.now().plus(days, ChronoUnit.DAYS); + } + + private boolean isShareLinkExpired(FileShare share) { + if (share == null || share.getExpiresAt() == null) { + return false; + } + return LocalDateTime.now().isAfter(share.getExpiresAt()); + } + + private boolean hasReadAccess(ShareAccessRole role) { + return role == ShareAccessRole.EDITOR + || role == ShareAccessRole.COMMENTER + || role == ShareAccessRole.VIEWER; + } + + // Workflow-aware methods + + /** + * Stores a file as part of a workflow with specific purpose and workflow link. This enables + * tracking workflow files separately and applying workflow-specific logic. + * + * @param owner File owner (usually workflow session owner) + * @param file File to store + * @param purpose Purpose classification (SIGNING_ORIGINAL, SIGNING_SIGNED, etc.) + * @param workflowSession Workflow session to link file to (nullable) + * @return Stored file entity + */ + public StoredFile storeWorkflowFile( + User owner, + MultipartFile file, + stirling.software.proprietary.storage.model.FilePurpose purpose, + stirling.software.proprietary.workflow.model.WorkflowSession workflowSession) { + StoredFile storedFile = storeFile(owner, file); + storedFile.setPurpose(purpose); + storedFile.setWorkflowSession(workflowSession); + return storedFileRepository.save(storedFile); + } + + /** + * Checks if a stored file is part of an active workflow. Files in active workflows may have + * restricted operations (e.g., no deletion until workflow completes). + * + * @param file Stored file to check + * @return true if file is part of an active workflow + */ + public boolean isWorkflowFile(StoredFile file) { + if (file.getWorkflowSession() == null) { + return false; + } + return file.getWorkflowSession().isActive(); + } + + /** + * Retrieves all files associated with a workflow session. Includes original file, processed + * file, and any auxiliary files. + * + * @param workflowSession Workflow session + * @return List of all files linked to the workflow + */ + public List getWorkflowFiles( + stirling.software.proprietary.workflow.model.WorkflowSession workflowSession) { + return storedFileRepository.findByWorkflowSession(workflowSession); + } + + /** + * Counts total storage bytes used by a workflow session. Useful for quota tracking and + * reporting. + * + * @param workflowSession Workflow session + * @return Total bytes used by workflow files + */ + public long countWorkflowStorageBytes( + stirling.software.proprietary.workflow.model.WorkflowSession workflowSession) { + List files = getWorkflowFiles(workflowSession); + return files.stream().mapToLong(this::totalStoredBytes).sum(); + } + + /** + * Validates that a user can delete a file, considering workflow constraints. Files in active + * workflows cannot be deleted until the workflow completes. + * + * @param file File to validate + * @param user User attempting deletion + * @throws ResponseStatusException if deletion is not allowed + */ + public void validateWorkflowDeletion(StoredFile file, User user) { + if (isWorkflowFile(file)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Cannot delete file that is part of an active workflow"); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java new file mode 100644 index 0000000000..5ea5f28def --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/service/StorageCleanupService.java @@ -0,0 +1,73 @@ +package stirling.software.proprietary.storage.service; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.storage.model.StorageCleanupEntry; +import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.repository.FileShareRepository; +import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository; + +@Service +@RequiredArgsConstructor +@Slf4j +public class StorageCleanupService { + + private static final int MAX_CLEANUP_ATTEMPTS = 10; + + private final StorageProvider storageProvider; + private final StorageCleanupEntryRepository cleanupEntryRepository; + private final FileShareRepository fileShareRepository; + + @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + public void cleanupOrphanedStorage() { + List entries = cleanupEntryRepository.findTop50ByOrderByUpdatedAtAsc(); + if (entries.isEmpty()) { + return; + } + for (StorageCleanupEntry entry : entries) { + try { + storageProvider.delete(entry.getStorageKey()); + cleanupEntryRepository.delete(entry); + } catch (IOException ex) { + int attempts = entry.getAttemptCount() + 1; + if (attempts >= MAX_CLEANUP_ATTEMPTS) { + log.error( + "Abandoning cleanup for storage key {} after {} failed attempts." + + " The blob may be orphaned and require manual removal.", + entry.getStorageKey(), + attempts, + ex); + cleanupEntryRepository.delete(entry); + } else { + entry.setAttemptCount(attempts); + cleanupEntryRepository.save(entry); + log.warn( + "Failed to cleanup storage key {} (attempt {}/{})", + entry.getStorageKey(), + attempts, + MAX_CLEANUP_ATTEMPTS, + ex); + } + } + } + } + + @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS) + public void cleanupExpiredShareLinks() { + List expired = + fileShareRepository.findByExpiresAtBeforeAndShareTokenNotNull(LocalDateTime.now()); + if (expired.isEmpty()) { + return; + } + fileShareRepository.deleteAll(expired); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java new file mode 100644 index 0000000000..df45d07816 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -0,0 +1,408 @@ +package stirling.software.proprietary.workflow.controller; + +import java.security.Principal; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.validation.constraints.NotBlank; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.config.swagger.StandardPdfResponse; +import stirling.software.common.util.GeneralUtils; +import stirling.software.common.util.WebResponseUtils; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; +import stirling.software.proprietary.workflow.dto.ParticipantRequest; +import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.service.SigningFinalizationService; +import stirling.software.proprietary.workflow.service.WorkflowSessionService; + +@Slf4j +@RestController +@RequestMapping("/api/v1/security") +@Tag(name = "Security", description = "Security APIs - Signing Workflows") +@RequiredArgsConstructor +public class SigningSessionController { + + private final WorkflowSessionService workflowSessionService; + private final UserService userService; + private final SigningFinalizationService signingFinalizationService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Operation(summary = "List all signing sessions for current user") + @Transactional(readOnly = true) + @GetMapping(value = "/cert-sign/sessions") + public ResponseEntity listSessions(Principal principal) { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + } + try { + User user = getCurrentUser(principal); + List sessions = + workflowSessionService.listUserSessions(user); + List responses = + sessions.stream() + .map( + stirling.software.proprietary.workflow.util.WorkflowMapper + ::toResponse) + .collect(java.util.stream.Collectors.toList()); + return ResponseEntity.ok(responses); + } catch (Exception e) { + log.error("Error listing sessions for user {}", principal.getName(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Error listing sessions"); + } + } + + @PostMapping( + consumes = {MediaType.MULTIPART_FORM_DATA_VALUE}, + value = "/cert-sign/sessions", + produces = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Create a shared signing session", + description = + "Starts a collaboration session, distributes share links, and optionally notifies" + + " participants. Input:PDF Output:JSON Type:SISO") + public ResponseEntity createSession( + @org.springframework.web.bind.annotation.RequestParam("file") + org.springframework.web.multipart.MultipartFile file, + @ModelAttribute WorkflowCreationRequest request, + Principal principal) + throws Exception { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + } + + try { + User owner = getCurrentUser(principal); + WorkflowSession session = workflowSessionService.createSession(owner, file, request); + return ResponseEntity.ok( + stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse(session)); + } catch (Exception e) { + log.error("Error creating signing session", e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } + } + + @Operation(summary = "Fetch signing session details") + @Transactional(readOnly = true) + @GetMapping(value = "/cert-sign/sessions/{sessionId}") + public ResponseEntity getSession( + @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + } + try { + User owner = getCurrentUser(principal); + WorkflowSession session = workflowSessionService.getSessionForOwner(sessionId, owner); + // Include wet signatures in response for owner preview + return ResponseEntity.ok( + stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse( + session, objectMapper)); + } catch (Exception e) { + log.error("Error fetching session {}", sessionId, e); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Access denied or session not found"); + } + } + + @Operation(summary = "Delete a signing session") + @DeleteMapping(value = "/cert-sign/sessions/{sessionId}") + public ResponseEntity deleteSession( + @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + } + try { + User owner = getCurrentUser(principal); + workflowSessionService.deleteSession(sessionId, owner); + return ResponseEntity.noContent().build(); + } catch (Exception e) { + log.error("Error deleting session {}", sessionId, e); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Cannot delete session: " + e.getMessage()); + } + } + + @Operation(summary = "Add participants to an existing session") + @PostMapping(value = "/cert-sign/sessions/{sessionId}/participants") + public ResponseEntity addParticipants( + @PathVariable("sessionId") @NotBlank String sessionId, + @RequestBody List participants, + Principal principal) { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + } + try { + User owner = getCurrentUser(principal); + workflowSessionService.addParticipants(sessionId, participants, owner); + WorkflowSession session = + workflowSessionService.getSessionWithParticipantsForOwner(sessionId, owner); + return ResponseEntity.ok( + stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse(session)); + } catch (Exception e) { + log.error("Error adding participants to session {}", sessionId, e); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Cannot add participants: " + e.getMessage()); + } + } + + @Operation(summary = "Remove a participant from a session") + @DeleteMapping(value = "/cert-sign/sessions/{sessionId}/participants/{participantId}") + public ResponseEntity removeParticipant( + @PathVariable("sessionId") @NotBlank String sessionId, + @PathVariable("participantId") Long participantId, + Principal principal) { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + } + try { + User owner = getCurrentUser(principal); + workflowSessionService.removeParticipant(sessionId, participantId, owner); + return ResponseEntity.noContent().build(); + } catch (Exception e) { + log.error("Error removing participant {} from session {}", participantId, sessionId, e); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Cannot remove participant: " + e.getMessage()); + } + } + + @Operation(summary = "Get session PDF for participant view") + @GetMapping(value = "/cert-sign/sessions/{sessionId}/pdf") + public ResponseEntity getSessionPdf( + @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + try { + User owner = getCurrentUser(principal); + workflowSessionService.getSessionForOwner(sessionId, owner); + byte[] pdfBytes = workflowSessionService.getOriginalFile(sessionId); + return WebResponseUtils.bytesToWebResponse(pdfBytes, "document.pdf"); + } catch (Exception e) { + log.error("Error fetching PDF for session {}", sessionId, e); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + @PostMapping(value = "/cert-sign/sessions/{sessionId}/finalize") + @Operation( + summary = "Finalize signing session", + description = + "Applies collected wet signatures and digital certificates, then returns the" + + " signed document.") + @StandardPdfResponse + public ResponseEntity finalizeSession( + @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) + throws Exception { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + try { + User owner = getCurrentUser(principal); + WorkflowSession session = + workflowSessionService.getSessionWithParticipantsForOwner(sessionId, owner); + + byte[] originalPdf = workflowSessionService.getOriginalFile(sessionId); + byte[] pdf = signingFinalizationService.finalizeDocument(session, originalPdf); + + String filename = session.getDocumentName().replace(".pdf", "") + "_shared_signed.pdf"; + workflowSessionService.storeProcessedFile(session, pdf, filename); + workflowSessionService.finalizeSession(sessionId, owner); + workflowSessionService.deleteOriginalFile(session); + + try { + signingFinalizationService.clearSensitiveMetadata(session); + } catch (Exception e) { + log.error( + "SECURITY: Failed to clear sensitive metadata for session {} " + + "(participants: {}). Keystore credentials may remain in the " + + "database until manual cleanup.", + sessionId, + session.getParticipants() != null + ? session.getParticipants().stream().map(p -> p.getEmail()).toList() + : "unknown", + e); + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Document signed successfully but post-signing cleanup failed. " + + "Contact your administrator to complete the cleanup."); + } + + return WebResponseUtils.bytesToWebResponse(pdf, filename); + } catch (Exception e) { + log.error("Error finalizing session {}", sessionId, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @Operation(summary = "Get signed PDF from finalized session") + @GetMapping(value = "/cert-sign/sessions/{sessionId}/signed-pdf") + @StandardPdfResponse + public ResponseEntity getSignedPdf( + @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + try { + User owner = getCurrentUser(principal); + byte[] signedPdf = workflowSessionService.getProcessedFile(sessionId, owner); + if (signedPdf == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body("Session not finalized".getBytes()); + } + WorkflowSession session = workflowSessionService.getSessionForOwner(sessionId, owner); + return WebResponseUtils.bytesToWebResponse( + signedPdf, + GeneralUtils.generateFilename(session.getDocumentName(), "_shared_signed.pdf")); + } catch (Exception e) { + log.error("Error fetching signed PDF for session {}", sessionId, e); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + // ===== SIGN REQUESTS (Participant View) ===== + + @Operation(summary = "List sign requests for authenticated user") + @Transactional(readOnly = true) + @GetMapping(value = "/cert-sign/sign-requests") + public ResponseEntity listSignRequests(Principal principal) { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + } + try { + User user = getCurrentUser(principal); + return ResponseEntity.ok(workflowSessionService.listSignRequests(user)); + } catch (Exception e) { + log.error("Error listing sign requests for user {}", principal.getName(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Cannot list sign requests: " + e.getMessage()); + } + } + + @Transactional(readOnly = true) + @Operation(summary = "Get sign request detail for participant") + @GetMapping(value = "/cert-sign/sign-requests/{sessionId}") + public ResponseEntity getSignRequestDetail( + @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + } + try { + User user = getCurrentUser(principal); + return ResponseEntity.ok(workflowSessionService.getSignRequestDetail(sessionId, user)); + } catch (Exception e) { + log.error("Error fetching sign request detail for session {}", sessionId, e); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Access denied or sign request not found: " + e.getMessage()); + } + } + + @Operation(summary = "Get document for sign request") + @GetMapping(value = "/cert-sign/sign-requests/{sessionId}/document") + public ResponseEntity getSignRequestDocument( + @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + try { + User user = getCurrentUser(principal); + byte[] document = workflowSessionService.getSignRequestDocument(sessionId, user); + return WebResponseUtils.bytesToWebResponse(document, "document.pdf"); + } catch (Exception e) { + log.error("Error fetching document for sign request {}", sessionId, e); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + @Operation(summary = "Sign a document with certificate and optional wet signature") + @PostMapping( + value = "/cert-sign/sign-requests/{sessionId}/sign", + consumes = { + MediaType.MULTIPART_FORM_DATA_VALUE, + MediaType.APPLICATION_FORM_URLENCODED_VALUE + }) + public ResponseEntity signDocument( + @PathVariable("sessionId") @NotBlank String sessionId, + @ModelAttribute stirling.software.proprietary.workflow.dto.SignDocumentRequest request, + Principal principal) { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + } + try { + User user = getCurrentUser(principal); + workflowSessionService.signDocument(sessionId, user, request); + return ResponseEntity.noContent().build(); + } catch (IllegalArgumentException e) { + log.error("Invalid sign request for session {}", sessionId, e); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage()); + } catch (Exception e) { + log.error("Error signing document for session {}", sessionId, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Cannot sign document: " + e.getMessage()); + } + } + + @Operation(summary = "Decline a sign request") + @PostMapping(value = "/cert-sign/sign-requests/{sessionId}/decline") + public ResponseEntity declineSignRequest( + @PathVariable("sessionId") @NotBlank String sessionId, Principal principal) { + workflowSessionService.ensureSigningEnabled(); + if (principal == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required"); + } + try { + User user = getCurrentUser(principal); + workflowSessionService.declineSignRequest(sessionId, user); + return ResponseEntity.noContent().build(); + } catch (Exception e) { + log.error("Error declining sign request for session {}", sessionId, e); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body("Cannot decline sign request: " + e.getMessage()); + } + } + + // ===== HELPER METHODS ===== + + private User getCurrentUser(Principal principal) { + return userService + .findByUsernameIgnoreCase(principal.getName()) + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized")); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java new file mode 100644 index 0000000000..66f3a48afe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java @@ -0,0 +1,328 @@ +package stirling.software.proprietary.workflow.controller; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.workflow.dto.ParticipantResponse; +import stirling.software.proprietary.workflow.dto.SignatureSubmissionRequest; +import stirling.software.proprietary.workflow.dto.WetSignatureMetadata; +import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse; +import stirling.software.proprietary.workflow.model.ParticipantStatus; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; +import stirling.software.proprietary.workflow.service.MetadataEncryptionService; +import stirling.software.proprietary.workflow.service.WorkflowSessionService; +import stirling.software.proprietary.workflow.util.WorkflowMapper; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +/** + * REST controller for workflow participant actions. Handles participant-facing operations like + * viewing sessions, submitting signatures, and updating participant status. + * + *

Access is controlled via share tokens, not requiring authentication. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/workflow/participant") +@Tag(name = "Workflow Participant", description = "Participant Action APIs") +@RequiredArgsConstructor +public class WorkflowParticipantController { + + private final WorkflowSessionService workflowSessionService; + private final WorkflowParticipantRepository participantRepository; + private final ObjectMapper objectMapper; + private final MetadataEncryptionService metadataEncryptionService; + + @Operation( + summary = "Get workflow session details by participant token", + description = "Allows participants to view session details using their share token") + @GetMapping(value = "/session", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getSessionByToken( + @RequestParam("token") @NotBlank String token) { + + workflowSessionService.ensureSigningEnabled(); + + WorkflowParticipant participant = + participantRepository + .findByShareToken(token) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Invalid or expired participant token")); + + // Check if participant is expired + if (participant.isExpired()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired"); + } + + // Mark as viewed if not already + if (participant.getStatus() == ParticipantStatus.PENDING + || participant.getStatus() == ParticipantStatus.NOTIFIED) { + workflowSessionService.updateParticipantStatus( + participant.getId(), ParticipantStatus.VIEWED); + } + + WorkflowSession session = participant.getWorkflowSession(); + return ResponseEntity.ok(WorkflowMapper.toResponse(session)); + } + + @Operation( + summary = "Get participant details by token", + description = "Returns participant-specific information") + @GetMapping(value = "/details", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getParticipantDetails( + @RequestParam("token") @NotBlank String token) { + + workflowSessionService.ensureSigningEnabled(); + + WorkflowParticipant participant = + participantRepository + .findByShareToken(token) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Invalid or expired participant token")); + + return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant)); + } + + @Operation( + summary = "Submit signature (wet signature and/or certificate)", + description = + "Participants submit their signature data and certificate information for signing") + @PostMapping( + value = "/submit-signature", + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity submitSignature( + @ModelAttribute SignatureSubmissionRequest request) { + + workflowSessionService.ensureSigningEnabled(); + + if (request.getParticipantToken() == null || request.getParticipantToken().isBlank()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Participant token is required"); + } + + WorkflowParticipant participant = + participantRepository + .findByShareToken(request.getParticipantToken()) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Invalid or expired participant token")); + + // Check if participant can still submit + if (participant.isExpired()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired"); + } + + if (participant.hasCompleted()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Participant has already completed their action"); + } + + if (!participant.getWorkflowSession().isActive()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Workflow session is no longer active"); + } + + try { + // Build metadata map with certificate and wet signature data + Map metadata = buildSubmissionMetadata(request); + participant.setParticipantMetadata(metadata); + + // Update status to SIGNED + participant.setStatus(ParticipantStatus.SIGNED); + participant = participantRepository.save(participant); + + log.info( + "Participant {} submitted signature for session {}", + participant.getEmail(), + participant.getWorkflowSession().getSessionId()); + + return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant)); + + } catch (Exception e) { + log.error("Error submitting signature for participant {}", participant.getEmail(), e); + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to submit signature", e); + } + } + + @Operation( + summary = "Decline participation", + description = "Participant declines to sign or participate in the workflow") + @PostMapping(value = "/decline", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity declineParticipation( + @RequestParam("token") @NotBlank String token, + @RequestParam(value = "reason", required = false) @Size(max = 500) String reason) { + + workflowSessionService.ensureSigningEnabled(); + + WorkflowParticipant participant = + participantRepository + .findByShareToken(token) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Invalid or expired participant token")); + + if (participant.hasCompleted()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Participant has already completed their action"); + } + + // Update status to DECLINED + participant.setStatus(ParticipantStatus.DECLINED); + + // Add decline reason to notifications + if (reason != null && !reason.isBlank()) { + workflowSessionService.addParticipantNotification( + participant.getId(), "Declined: " + reason); + } else { + workflowSessionService.addParticipantNotification( + participant.getId(), "Declined participation"); + } + + participant = participantRepository.save(participant); + + log.info( + "Participant {} declined workflow session {}", + participant.getEmail(), + participant.getWorkflowSession().getSessionId()); + + return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant)); + } + + @Operation( + summary = "Get original PDF for review", + description = "Participant downloads the original document") + @GetMapping(value = "/document", produces = MediaType.APPLICATION_PDF_VALUE) + public ResponseEntity getDocument(@RequestParam("token") @NotBlank String token) { + + workflowSessionService.ensureSigningEnabled(); + + WorkflowParticipant participant = + participantRepository + .findByShareToken(token) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.FORBIDDEN, + "Invalid or expired participant token")); + + if (participant.isExpired()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired"); + } + + try { + WorkflowSession session = participant.getWorkflowSession(); + byte[] pdf = workflowSessionService.getOriginalFile(session.getSessionId()); + + return ResponseEntity.ok() + .header( + HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment() + .filename(session.getDocumentName(), StandardCharsets.UTF_8) + .build() + .toString()) + .contentType(org.springframework.http.MediaType.APPLICATION_PDF) + .body(pdf); + + } catch (IOException e) { + log.error("Error retrieving document for participant", e); + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to retrieve document", e); + } + } + + /** + * Builds metadata map from signature submission request. Includes certificate submission and + * wet signature data. + */ + private Map buildSubmissionMetadata(SignatureSubmissionRequest request) + throws IOException { + Map metadata = new HashMap<>(); + + // Add certificate submission if provided + if (request.getCertType() != null) { + Map certSubmission = new HashMap<>(); + certSubmission.put("certType", request.getCertType()); + certSubmission.put( + "password", metadataEncryptionService.encrypt(request.getPassword())); + certSubmission.put("showSignature", request.getShowSignature()); + certSubmission.put("pageNumber", request.getPageNumber()); + certSubmission.put("location", request.getLocation()); + certSubmission.put("reason", request.getReason()); + certSubmission.put("showLogo", request.getShowLogo()); + + // Store certificate files as base64 + if (request.getP12File() != null && !request.getP12File().isEmpty()) { + certSubmission.put( + "p12Keystore", + java.util.Base64.getEncoder() + .encodeToString(request.getP12File().getBytes())); + } + if (request.getJksFile() != null && !request.getJksFile().isEmpty()) { + certSubmission.put( + "jksKeystore", + java.util.Base64.getEncoder() + .encodeToString(request.getJksFile().getBytes())); + } + + metadata.put("certificateSubmission", certSubmission); + } + + // Add wet signatures data if provided - parse once and store as List directly + if (request.getWetSignaturesData() != null && !request.getWetSignaturesData().isBlank()) { + if (request.getWetSignaturesData().length() > 5 * 1024 * 1024) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Wet signatures data exceeds maximum allowed size"); + } + @SuppressWarnings("unchecked") + java.util.List> wetSigs = + objectMapper.readValue( + request.getWetSignaturesData(), + new TypeReference>>() {}); + if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Too many wet signatures submitted"); + } + metadata.put("wetSignatures", wetSigs); + } + + return metadata; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/CertificateSubmission.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/CertificateSubmission.java new file mode 100644 index 0000000000..29565ed28c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/CertificateSubmission.java @@ -0,0 +1,46 @@ +package stirling.software.proprietary.workflow.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Certificate submission details extracted from a participant's stored metadata. Contains the + * certificate type, optional keystore bytes (decoded from base64), password, and per-participant + * signature appearance overrides. + */ +@Getter +@Setter +@NoArgsConstructor +public class CertificateSubmission { + + /** Certificate type: P12, JKS, SERVER, or USER_CERT */ + private String certType; + + /** + * Keystore password. Stored encrypted at rest; decrypted by MetadataEncryptionService before + * use. Cleared from the database after finalization. + */ + private String password; + + /** PKCS12 keystore bytes, decoded from the base64 stored in participant metadata. */ + private byte[] p12Keystore; + + /** JKS keystore bytes, decoded from the base64 stored in participant metadata. */ + private byte[] jksKeystore; + + /** Whether to show a visible digital signature block on the page. */ + private Boolean showSignature; + + /** 1-indexed page number for the digital signature block (session-level default). */ + private Integer pageNumber; + + /** Participant's location when signing (included in digital signature metadata). */ + private String location; + + /** Participant's reason for signing (included in digital signature metadata). */ + private String reason; + + /** Whether to show the Stirling logo in the digital signature block. */ + private Boolean showLogo; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/ParticipantRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/ParticipantRequest.java new file mode 100644 index 0000000000..9f47f45b90 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/ParticipantRequest.java @@ -0,0 +1,43 @@ +package stirling.software.proprietary.workflow.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.storage.model.ShareAccessRole; + +/** + * Request DTO for adding or configuring a workflow participant. Supports both registered users and + * external email participants. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ParticipantRequest { + + /** User ID if participant is a registered user */ + private Long userId; + + /** Email address (required for external users, optional for registered users) */ + private String email; + + /** Display name for the participant */ + private String name; + + /** Access role for the participant (EDITOR, COMMENTER, VIEWER) */ + private ShareAccessRole accessRole; + + /** Optional expiration timestamp for participant access */ + private LocalDateTime expiresAt; + + /** Participant-specific metadata (JSON string) */ + private String participantMetadata; + + /** Whether to send notification immediately */ + private boolean sendNotification = true; + + /** Owner-set default reason for this participant's signature */ + private String defaultReason; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/ParticipantResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/ParticipantResponse.java new file mode 100644 index 0000000000..4b9092b930 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/ParticipantResponse.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.workflow.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.storage.model.ShareAccessRole; +import stirling.software.proprietary.workflow.model.ParticipantStatus; + +/** + * Response DTO for workflow participant details. Used in API responses to provide participant + * information. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ParticipantResponse { + + private Long id; + private Long userId; + private String email; + private String name; + private ParticipantStatus status; + private String shareToken; + private ShareAccessRole accessRole; + private LocalDateTime expiresAt; + private LocalDateTime lastUpdated; + private boolean hasCompleted; + private boolean isExpired; + private List wetSignatures; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java new file mode 100644 index 0000000000..fcc8684057 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignDocumentRequest.java @@ -0,0 +1,72 @@ +package stirling.software.proprietary.workflow.dto; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Request object for signing a document. Combines certificate submission data with optional wet + * signature (visual signature) metadata. Supports multiple wet signatures. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SignDocumentRequest { + + // Certificate-related fields + @NotNull(message = "Certificate type is required") + @Pattern( + regexp = "SERVER|USER_CERT|UPLOAD|PEM|PKCS12|PFX|JKS", + message = "Invalid certificate type") + private String certType; + + private MultipartFile p12File; + private String password; + private MultipartFile privateKeyFile; + private MultipartFile certFile; + + // Signature metadata (participant can override owner defaults) + private String reason; // Participant's reason for signing + private String location; // Participant's location when signing + + // Wet signatures as JSON string (from frontend FormData) + private String wetSignaturesData; + + // Parsed wet signatures (populated by controller/service) + private List wetSignatures; + + /** + * Checks if this request includes wet signature metadata. + * + * @return true if wet signatures list is not empty + */ + public boolean hasWetSignatures() { + return wetSignatures != null && !wetSignatures.isEmpty(); + } + + /** + * Extracts and validates wet signature metadata. + * + * @return List of validated WetSignatureMetadata objects + */ + public List extractWetSignatureMetadata() { + List signatures = new ArrayList<>(); + + if (hasWetSignatures()) { + for (WetSignatureMetadata signature : wetSignatures) { + signature.validate(); + signatures.add(signature); + } + } + + return signatures; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignRequestDetailDTO.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignRequestDetailDTO.java new file mode 100644 index 0000000000..159623d858 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignRequestDetailDTO.java @@ -0,0 +1,27 @@ +package stirling.software.proprietary.workflow.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.workflow.model.ParticipantStatus; + +/** DTO for sign request detail (participant view) */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SignRequestDetailDTO { + private String sessionId; + private String documentName; + private String ownerUsername; + private String message; + private String dueDate; + private String createdAt; + private ParticipantStatus myStatus; + // Signature appearance settings (read-only, configured by owner) + private Boolean showSignature; + private Integer pageNumber; + private String reason; + private String location; + private Boolean showLogo; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignRequestSummaryDTO.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignRequestSummaryDTO.java new file mode 100644 index 0000000000..884cdb3843 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignRequestSummaryDTO.java @@ -0,0 +1,20 @@ +package stirling.software.proprietary.workflow.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.workflow.model.ParticipantStatus; + +/** DTO for sign request summary (participant view) */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SignRequestSummaryDTO { + private String sessionId; + private String documentName; + private String ownerUsername; + private String createdAt; + private String dueDate; + private ParticipantStatus myStatus; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignatureSubmissionRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignatureSubmissionRequest.java new file mode 100644 index 0000000000..c5d2e95c68 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/SignatureSubmissionRequest.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.workflow.dto; + +import org.springframework.web.multipart.MultipartFile; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Request DTO for submitting a signature (wet signature or certificate). Used when a participant + * completes their signing action. Supports multiple wet signatures. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SignatureSubmissionRequest { + + // Certificate submission fields + private String certType; // P12, JKS, SERVER, USER_CERT + private String password; + private MultipartFile p12File; + private MultipartFile jksFile; + private Boolean showSignature; + private Integer pageNumber; + private String location; + private String reason; + private Boolean showLogo; + + // Wet signatures (JSON array string with coordinates and image data) + private String wetSignaturesData; + + // Participant identification + private String participantToken; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/WetSignatureMetadata.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/WetSignatureMetadata.java new file mode 100644 index 0000000000..3da7f5d17f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/WetSignatureMetadata.java @@ -0,0 +1,112 @@ +package stirling.software.proprietary.workflow.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Data Transfer Object for wet signature (visual signature) metadata. Contains information about a + * signature annotation placed by a participant on the PDF. This data is used to overlay the + * signature on the PDF during finalization. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class WetSignatureMetadata { + + /** Maximum number of wet signatures allowed per participant submission. */ + public static final int MAX_SIGNATURES_PER_PARTICIPANT = 50; + + /** Type of wet signature: "canvas" (drawn), "image" (uploaded), or "text" (typed) */ + @NotNull(message = "Wet signature type is required") + @Pattern( + regexp = "canvas|image|text", + message = "Wet signature type must be canvas, image, or text") + private String type; + + /** + * Base64-encoded image data or text content for the signature. For canvas/image types: + * data:image/png;base64,... format For text type: plain text string + */ + @NotNull(message = "Wet signature data is required") + @Size(max = 5_000_000, message = "Wet signature data exceeds maximum size of 5MB") + private String data; + + /** Zero-indexed page number where the signature is placed */ + @NotNull(message = "Page number is required") + @PositiveOrZero(message = "Page number must be zero or positive") + private Integer page; + + /** X position as a fraction (0–1) of page width, measured from left edge */ + @NotNull(message = "X coordinate is required") + @PositiveOrZero(message = "X coordinate must be zero or positive") + @DecimalMax(value = "1.0", message = "X coordinate must not exceed 1.0 (page width)") + private Double x; + + /** + * Y position as a fraction (0–1) of page height, measured from top edge. Note: This is UI + * coordinate system (top-left origin). Will be converted to PDF coordinate system (bottom-left + * origin) during overlay. + */ + @NotNull(message = "Y coordinate is required") + @PositiveOrZero(message = "Y coordinate must be zero or positive") + @DecimalMax(value = "1.0", message = "Y coordinate must not exceed 1.0 (page height)") + private Double y; + + /** Width of the signature rectangle as a fraction (0–1) of page width */ + @NotNull(message = "Width is required") + @Positive(message = "Width must be positive") + @DecimalMax(value = "1.0", message = "Width must not exceed 1.0 (page width)") + private Double width; + + /** Height of the signature rectangle as a fraction (0–1) of page height */ + @NotNull(message = "Height is required") + @Positive(message = "Height must be positive") + @DecimalMax(value = "1.0", message = "Height must not exceed 1.0 (page height)") + private Double height; + + /** + * Validates that the wet signature data is properly formatted based on type. For image types, + * ensures data starts with data:image prefix. + * + * @return true if validation passes + * @throws IllegalArgumentException if validation fails + */ + public boolean validate() { + if (type.equals("canvas") || type.equals("image")) { + if (!data.startsWith("data:image/")) { + throw new IllegalArgumentException( + "Image wet signature data must start with data:image/ prefix"); + } + } + if (x != null && width != null && x + width > 1.0) { + throw new IllegalArgumentException( + "Signature extends beyond the right edge of the page (x + width > 1.0)"); + } + if (y != null && height != null && y + height > 1.0) { + throw new IllegalArgumentException( + "Signature extends beyond the bottom edge of the page (y + height > 1.0)"); + } + return true; + } + + /** + * Extracts just the base64 data portion from a data URL. Removes the "data:image/png;base64," + * prefix. + * + * @return pure base64 string without data URL prefix + */ + public String extractBase64Data() { + if (data != null && data.contains(",")) { + return data.substring(data.indexOf(",") + 1); + } + return data; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/WorkflowCreationRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/WorkflowCreationRequest.java new file mode 100644 index 0000000000..0e9b0bef83 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/WorkflowCreationRequest.java @@ -0,0 +1,43 @@ +package stirling.software.proprietary.workflow.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.workflow.model.WorkflowType; + +/** + * Request DTO for creating a new workflow session. Used to initialize workflow sessions with + * participants and settings. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class WorkflowCreationRequest { + + /** Type of workflow to create (SIGNING, REVIEW, APPROVAL) */ + private WorkflowType workflowType; + + /** Display name for the document in the workflow */ + private String documentName; + + /** Owner's email address (optional, used for notifications) */ + private String ownerEmail; + + /** Message/instructions for participants */ + private String message; + + /** Due date for workflow completion (flexible string format) */ + private String dueDate; + + /** List of participant user IDs (for registered users) */ + private List participantUserIds; + + /** List of participant email addresses (for external/unregistered users) */ + private List participantEmails; + + /** Workflow-specific metadata (JSON string) */ + private String workflowMetadata; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/WorkflowSessionResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/WorkflowSessionResponse.java new file mode 100644 index 0000000000..eaf36e98ab --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/dto/WorkflowSessionResponse.java @@ -0,0 +1,40 @@ +package stirling.software.proprietary.workflow.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.workflow.model.WorkflowStatus; +import stirling.software.proprietary.workflow.model.WorkflowType; + +/** + * Response DTO for workflow session details. Used in API responses to provide session information + * to clients. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class WorkflowSessionResponse { + + private String sessionId; + private Long ownerId; + private String ownerUsername; + private WorkflowType workflowType; + private String documentName; + private String ownerEmail; + private String message; + private String dueDate; + private WorkflowStatus status; + private boolean finalized; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private List participants; + private int participantCount; + private int signedCount; + private boolean hasProcessedFile; + private Long originalFileId; + private Long processedFileId; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/CertificateType.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/CertificateType.java new file mode 100644 index 0000000000..41efb21b0d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/CertificateType.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.workflow.model; + +public enum CertificateType { + AUTO_GENERATED, + USER_UPLOADED +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/ParticipantStatus.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/ParticipantStatus.java new file mode 100644 index 0000000000..822562a626 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/ParticipantStatus.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.workflow.model; + +/** + * Defines the status of a participant in a workflow session. Tracks participant progress through + * the workflow lifecycle. + */ +public enum ParticipantStatus { + /** Participant has been added but not yet notified */ + PENDING, + + /** Participant has been notified via email or other means */ + NOTIFIED, + + /** Participant has viewed the document */ + VIEWED, + + /** Participant has completed their action (e.g., signed the document) */ + SIGNED, + + /** Participant has declined to participate or rejected the action */ + DECLINED +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/UserServerCertificateEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/UserServerCertificateEntity.java new file mode 100644 index 0000000000..0ad30a3bf7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/UserServerCertificateEntity.java @@ -0,0 +1,73 @@ +package stirling.software.proprietary.workflow.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import jakarta.persistence.*; + +import lombok.*; + +import stirling.software.proprietary.security.model.User; + +@Entity +@Table(name = "user_server_certificates") +@NoArgsConstructor +@Getter +@Setter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +@ToString(onlyExplicitlyIncluded = true) +public class UserServerCertificateEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + @EqualsAndHashCode.Include + @ToString.Include + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", unique = true, nullable = false) + @JsonIgnore + private User user; + + @Lob + @Basic(fetch = FetchType.EAGER) + @Column(name = "keystore_data", nullable = false, columnDefinition = "bytea") + @JsonIgnore + private byte[] keystoreData; + + @Column(name = "keystore_password", nullable = false) + @JsonIgnore + private String keystorePassword; + + @Enumerated(EnumType.STRING) + @Column(name = "certificate_type", nullable = false, length = 50) + private CertificateType certificateType; + + @Column(name = "subject_dn", length = 500) + private String subjectDn; + + @Column(name = "issuer_dn", length = 500) + private String issuerDn; + + @Column(name = "valid_from") + private LocalDateTime validFrom; + + @Column(name = "valid_to") + private LocalDateTime validTo; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java new file mode 100644 index 0000000000..2e6091b963 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java @@ -0,0 +1,141 @@ +package stirling.software.proprietary.workflow.model; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.ShareAccessRole; + +/** + * Represents a participant in a workflow session. Replaces SigningParticipantEntity with broader + * workflow support. + * + *

Integrates with FileShare for access control - each participant gets a FileShare entry linked + * to this participant record for unified access control. + */ +@Entity +@Table( + name = "workflow_participants", + indexes = { + @Index(name = "idx_workflow_participants_session", columnList = "workflow_session_id"), + @Index(name = "idx_workflow_participants_token", columnList = "share_token"), + @Index(name = "idx_workflow_participants_user", columnList = "user_id") + }) +@NoArgsConstructor +@Getter +@Setter +public class WorkflowParticipant implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "workflow_session_id", nullable = false) + private WorkflowSession workflowSession; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "email") + private String email; + + @Column(name = "name") + private String name; + + // Workflow progress tracking + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private ParticipantStatus status = ParticipantStatus.PENDING; + + // Access control (unified with FileShare) + @Column(name = "share_token", unique = true, length = 36) + private String shareToken; + + @Enumerated(EnumType.STRING) + @Column(name = "access_role", nullable = false, length = 20) + private ShareAccessRole accessRole; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + // Workflow-specific data stored as JSON for flexibility + // For signing: wet signature coordinates, signature appearance settings + // For review: assigned review sections, comment preferences + // For approval: decision criteria, approval authority level + @org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON) + @Column(name = "participant_metadata", columnDefinition = "jsonb") + private Map participantMetadata = new HashMap<>(); + + // Notification history + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable( + name = "participant_notifications", + joinColumns = @JoinColumn(name = "participant_id")) + @Column(name = "notification_message", columnDefinition = "text") + private List notifications = new ArrayList<>(); + + @UpdateTimestamp + @Column(name = "last_updated") + private LocalDateTime lastUpdated; + + // Helper methods + + public void addNotification(String message) { + notifications.add(message); + } + + public boolean isExpired() { + return expiresAt != null && LocalDateTime.now().isAfter(expiresAt); + } + + public boolean hasCompleted() { + return status == ParticipantStatus.SIGNED || status == ParticipantStatus.DECLINED; + } + + /** + * Determines the effective access role based on participant status. After completion + * (signed/declined), downgrade to VIEWER. + */ + public ShareAccessRole getEffectiveRole() { + if (hasCompleted()) { + return ShareAccessRole.VIEWER; + } + return accessRole; + } + + public boolean canEdit() { + return !hasCompleted() + && !isExpired() + && (accessRole == ShareAccessRole.EDITOR + || accessRole == ShareAccessRole.COMMENTER); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java new file mode 100644 index 0000000000..3fc6b53b44 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java @@ -0,0 +1,143 @@ +package stirling.software.proprietary.workflow.model; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.StoredFile; + +/** + * Represents a workflow session for multi-participant document processing. Replaces + * SigningSessionEntity with a more generic workflow abstraction that supports signing, review, + * approval, and other collaborative workflows. + * + *

This entity coordinates the workflow lifecycle and links to StoredFile for actual document + * storage (no more direct BLOBs). + */ +@Entity +@Table( + name = "workflow_sessions", + indexes = { + @Index(name = "idx_workflow_sessions_owner", columnList = "owner_id"), + @Index(name = "idx_workflow_sessions_session_id", columnList = "session_id") + }) +@NoArgsConstructor +@Getter +@Setter +public class WorkflowSession implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "session_id", unique = true, nullable = false, length = 36) + private String sessionId = UUID.randomUUID().toString(); + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner_id", nullable = false) + private User owner; + + @Column(name = "workflow_type", nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private WorkflowType workflowType; + + @Column(name = "document_name", nullable = false) + private String documentName; + + // Replaces BLOB storage with StoredFile reference + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "original_file_id", nullable = false) + private StoredFile originalFile; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "processed_file_id") + private StoredFile processedFile; + + @Column(name = "owner_email") + private String ownerEmail; + + @Column(name = "message", columnDefinition = "text") + private String message; + + @Column(name = "due_date", length = 50) + private String dueDate; + + @Column(name = "status", nullable = false, length = 20) + @Enumerated(EnumType.STRING) + private WorkflowStatus status = WorkflowStatus.IN_PROGRESS; + + @Column(name = "finalized", nullable = false) + private boolean finalized = false; + + @OneToMany( + mappedBy = "workflowSession", + cascade = CascadeType.ALL, + orphanRemoval = true, + fetch = FetchType.LAZY) + private List participants = new ArrayList<>(); + + // Workflow-specific settings stored as JSON for flexibility + // For signing: signature appearance settings, wet signature metadata + // For review: review guidelines, comment templates + // For approval: approval criteria, decision options + @org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON) + @Column(name = "workflow_metadata", columnDefinition = "jsonb") + private Map workflowMetadata = new HashMap<>(); + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + // Helper methods + + public void addParticipant(WorkflowParticipant participant) { + participants.add(participant); + participant.setWorkflowSession(this); + } + + public void removeParticipant(WorkflowParticipant participant) { + participants.remove(participant); + participant.setWorkflowSession(null); + } + + public boolean isActive() { + return status == WorkflowStatus.IN_PROGRESS && !finalized; + } + + public boolean hasProcessedFile() { + return processedFile != null; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowStatus.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowStatus.java new file mode 100644 index 0000000000..4fee9060b1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowStatus.java @@ -0,0 +1,16 @@ +package stirling.software.proprietary.workflow.model; + +/** + * Defines the overall status of a workflow session. Tracks the lifecycle from creation through + * completion or cancellation. + */ +public enum WorkflowStatus { + /** Workflow is active and awaiting participant actions */ + IN_PROGRESS, + + /** Workflow has been successfully completed by all participants */ + COMPLETED, + + /** Workflow has been cancelled by the owner or system */ + CANCELLED +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowType.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowType.java new file mode 100644 index 0000000000..a9f4d4fdb2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowType.java @@ -0,0 +1,16 @@ +package stirling.software.proprietary.workflow.model; + +/** + * Defines the type of workflow being executed. Determines the business logic and lifecycle for the + * workflow session. + */ +public enum WorkflowType { + /** Document signing workflow - participants sign a PDF with digital certificates */ + SIGNING, + + /** Document review workflow - participants review and comment on a document */ + REVIEW, + + /** Document approval workflow - participants approve or reject a document */ + APPROVAL +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/UserServerCertificateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/UserServerCertificateRepository.java new file mode 100644 index 0000000000..fead8da9b9 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/UserServerCertificateRepository.java @@ -0,0 +1,23 @@ +package stirling.software.proprietary.workflow.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.workflow.model.UserServerCertificateEntity; + +@Repository +public interface UserServerCertificateRepository + extends JpaRepository { + + @Query("SELECT c FROM UserServerCertificateEntity c WHERE c.user.id = :userId") + Optional findByUserId(@Param("userId") Long userId); + + @Query("SELECT c FROM UserServerCertificateEntity c WHERE c.user.username = :username") + Optional findByUsername(@Param("username") String username); + + boolean existsByUserId(Long userId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowParticipantRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowParticipantRepository.java new file mode 100644 index 0000000000..8f31d8c4a4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowParticipantRepository.java @@ -0,0 +1,75 @@ +package stirling.software.proprietary.workflow.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.workflow.model.ParticipantStatus; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.model.WorkflowSession; + +@Repository +public interface WorkflowParticipantRepository extends JpaRepository { + + /** Find participant by share token */ + Optional findByShareToken(String shareToken); + + /** Find all participants in a workflow session */ + List findByWorkflowSession(WorkflowSession session); + + /** Find participant by session and user */ + Optional findByWorkflowSessionAndUser(WorkflowSession session, User user); + + /** Find participant by session and email */ + Optional findByWorkflowSessionAndEmail( + WorkflowSession session, String email); + + /** Find all participants with a specific status in a session */ + List findByWorkflowSessionAndStatus( + WorkflowSession session, ParticipantStatus status); + + /** Find all sessions where a user is a participant */ + List findByUserOrderByLastUpdatedDesc(User user); + + /** Find all sessions where an email is a participant */ + List findByEmailOrderByLastUpdatedDesc(String email); + + /** Check if a participant exists by share token */ + boolean existsByShareToken(String shareToken); + + /** Count participants in a session by status */ + long countByWorkflowSessionAndStatus(WorkflowSession session, ParticipantStatus status); + + /** Find expired participants that haven't completed */ + @Query( + "SELECT p FROM WorkflowParticipant p WHERE p.expiresAt < CURRENT_TIMESTAMP AND p.status NOT IN ('SIGNED', 'DECLINED')") + List findExpiredIncompleteParticipants(); + + /** Find all participants pending notification */ + @Query( + "SELECT p FROM WorkflowParticipant p WHERE p.status = 'PENDING' AND p.workflowSession.status = 'IN_PROGRESS'") + List findPendingNotifications(); + + /** Delete participant by ID and session owner (for authorization) */ + @Query( + "DELETE FROM WorkflowParticipant p WHERE p.id = :participantId AND p.workflowSession.owner = :owner") + void deleteByIdAndSessionOwner( + @Param("participantId") Long participantId, @Param("owner") User owner); + + /** + * Null out the user reference for all participants linked to the given user. Used during user + * deletion to preserve workflow audit history while removing the personal data link. + * Participants in sessions owned by others are retained but de-linked from the deleted account. + */ + @Modifying + @Transactional + @Query("UPDATE WorkflowParticipant wp SET wp.user = null WHERE wp.user = :user") + void clearUserReferences(@Param("user") User user); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowSessionRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowSessionRepository.java new file mode 100644 index 0000000000..7605caab8b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/repository/WorkflowSessionRepository.java @@ -0,0 +1,61 @@ +package stirling.software.proprietary.workflow.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.model.WorkflowStatus; +import stirling.software.proprietary.workflow.model.WorkflowType; + +@Repository +public interface WorkflowSessionRepository extends JpaRepository { + + /** Find workflow session by unique session ID */ + Optional findBySessionId(String sessionId); + + /** Find workflow session by unique session ID with participants eagerly loaded */ + @Query( + "SELECT ws FROM WorkflowSession ws LEFT JOIN FETCH ws.participants WHERE ws.sessionId = :sessionId") + Optional findBySessionIdWithParticipants(@Param("sessionId") String sessionId); + + /** Find all workflow sessions owned by a specific user */ + List findByOwnerOrderByCreatedAtDesc(User owner); + + /** Find all workflow sessions of a specific type for a user */ + List findByOwnerAndWorkflowTypeOrderByCreatedAtDesc( + User owner, WorkflowType workflowType); + + /** Find all workflow sessions with a specific status */ + List findByStatusOrderByCreatedAtDesc(WorkflowStatus status); + + /** Find all active (non-finalized, in-progress) sessions for a user */ + @Query( + "SELECT ws FROM WorkflowSession ws WHERE ws.owner = :owner AND ws.status = 'IN_PROGRESS' AND ws.finalized = false ORDER BY ws.createdAt DESC") + List findActiveSessionsByOwner(@Param("owner") User owner); + + /** Find all finalized sessions for a user */ + List findByOwnerAndFinalizedTrueOrderByCreatedAtDesc(User owner); + + /** Check if a session exists by session ID */ + boolean existsBySessionId(String sessionId); + + /** Find sessions that need cleanup (e.g., old cancelled sessions) */ + @Query( + "SELECT ws FROM WorkflowSession ws WHERE ws.status = 'CANCELLED' AND ws.updatedAt < :cutoffDate") + List findCancelledSessionsOlderThan( + @Param("cutoffDate") java.time.LocalDateTime cutoffDate); + + /** Count active sessions for a user */ + @Query( + "SELECT COUNT(ws) FROM WorkflowSession ws WHERE ws.owner = :owner AND ws.status = 'IN_PROGRESS' AND ws.finalized = false") + long countActiveSessionsByOwner(@Param("owner") User owner); + + /** Delete session by session ID and owner (for authorization) */ + void deleteBySessionIdAndOwner(String sessionId, User owner); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/MetadataEncryptionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/MetadataEncryptionService.java new file mode 100644 index 0000000000..9241a9ca4e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/MetadataEncryptionService.java @@ -0,0 +1,119 @@ +package stirling.software.proprietary.workflow.service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; + +/** + * Provides AES-256-GCM encryption for sensitive fields stored in JSONB metadata columns (e.g. + * keystore passwords). The encryption key is derived from the application's + * AutomaticallyGenerated.key, which is persisted in settings on first run. + * + *

Encrypted values are prefixed with {@value #ENC_PREFIX} so that legacy plaintext values + * written before this service was introduced can still be decrypted transparently. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class MetadataEncryptionService { + + static final String ENC_PREFIX = "enc:"; + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_IV_LENGTH = 12; + private static final int GCM_TAG_LENGTH = 128; // bits + + private final ApplicationProperties applicationProperties; + + // ── Public API ────────────────────────────────────────────────────────── + + /** + * Encrypts {@code plaintext} with AES-256-GCM and returns a Base64-encoded ciphertext prefixed + * with {@value #ENC_PREFIX}. + */ + public String encrypt(String plaintext) { + if (plaintext == null) { + return null; + } + try { + SecretKeySpec keySpec = deriveKey(); + byte[] iv = generateIv(); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH, iv)); + + byte[] cipherBytes = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + // Prepend IV to ciphertext for storage: [12-byte IV][ciphertext+tag] + byte[] combined = new byte[iv.length + cipherBytes.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(cipherBytes, 0, combined, iv.length, cipherBytes.length); + + return ENC_PREFIX + Base64.getEncoder().encodeToString(combined); + } catch (Exception e) { + throw new IllegalStateException("Failed to encrypt metadata field", e); + } + } + + /** + * Decrypts a value produced by {@link #encrypt}. If the value does not start with {@value + * #ENC_PREFIX} it is returned as-is to preserve backwards compatibility with plaintext values + * written before this service existed. + */ + public String decrypt(String value) { + if (value == null) { + return null; + } + if (!value.startsWith(ENC_PREFIX)) { + // Legacy plaintext – return unchanged + return value; + } + try { + SecretKeySpec keySpec = deriveKey(); + byte[] combined = Base64.getDecoder().decode(value.substring(ENC_PREFIX.length())); + + byte[] iv = Arrays.copyOfRange(combined, 0, GCM_IV_LENGTH); + byte[] cipherBytes = Arrays.copyOfRange(combined, GCM_IV_LENGTH, combined.length); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH, iv)); + + return new String(cipher.doFinal(cipherBytes), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalStateException("Failed to decrypt metadata field", e); + } + } + + // ── Internals ─────────────────────────────────────────────────────────── + + private SecretKeySpec deriveKey() throws Exception { + String rawKey = applicationProperties.getAutomaticallyGenerated().getKey(); + if (rawKey == null || rawKey.isBlank()) { + throw new IllegalStateException( + "AutomaticallyGenerated.key is not initialised — cannot derive encryption key"); + } + // SHA-256 of the raw key gives a stable 32-byte AES-256 key + byte[] hash = + MessageDigest.getInstance("SHA-256") + .digest(rawKey.getBytes(StandardCharsets.UTF_8)); + return new SecretKeySpec(hash, "AES"); + } + + private static byte[] generateIv() { + byte[] iv = new byte[GCM_IV_LENGTH]; + new SecureRandom().nextBytes(iv); + return iv; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java new file mode 100644 index 0000000000..11475053e4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java @@ -0,0 +1,1308 @@ +package stirling.software.proprietary.workflow.service; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.Map; + +import javax.imageio.ImageIO; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts.FontName; +import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x500.style.IETFUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfSigningService; +import stirling.software.common.service.ServerCertificateServiceInterface; +import stirling.software.proprietary.workflow.dto.CertificateSubmission; +import stirling.software.proprietary.workflow.dto.WetSignatureMetadata; +import stirling.software.proprietary.workflow.model.ParticipantStatus; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; + +import tools.jackson.databind.ObjectMapper; + +/** + * Service responsible for finalizing a signing session. Encapsulates all PDF manipulation logic + * (wet signatures, summary page, digital certificate application) that was previously spread across + * the controller. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class SigningFinalizationService { + + private final WorkflowParticipantRepository participantRepository; + private final CustomPDFDocumentFactory pdfDocumentFactory; + private final ObjectMapper objectMapper; + private final PdfSigningService pdfSigningService; + private final MetadataEncryptionService metadataEncryptionService; + + @Autowired(required = false) + private final ServerCertificateServiceInterface serverCertificateService; + + @Autowired(required = false) + private final UserServerCertificateService userServerCertificateService; + + // ===== PUBLIC API ===== + + /** + * Runs all finalization steps and returns the fully signed PDF bytes. + * + *

Order: 1. Apply wet signature image overlays 2. Append signature summary page (if enabled) + * 3. Apply digital certificates per participant + * + * @param session Session with participants loaded + * @param originalPdf The original PDF bytes to sign + * @return Signed PDF bytes + */ + public byte[] finalizeDocument(WorkflowSession session, byte[] originalPdf) throws Exception { + byte[] pdf = originalPdf; + + // Step 1: Apply wet signatures (visual annotations) FIRST + // This must succeed before digital certificates are applied — continuing after a wet + // signature failure would produce a document that appears fully signed but is missing + // one or more participant signatures. + pdf = applyWetSignatures(pdf, session); + + // Extract session-level settings + SessionSignatureSettings settings = extractSessionSettings(session); + + // Step 1.5: Add summary page BEFORE digital signing (if enabled) + // CRITICAL: Must be done before signing to avoid invalidating signatures + if (Boolean.TRUE.equals(settings.includeSummaryPage)) { + log.info( + "Adding summary page before digital signing for session {}", + session.getSessionId()); + pdf = appendSignatureSummaryPage(pdf, session); + } + + // Suppress digital certificate visual block when summary page is enabled + // (wet signatures already applied in Step 1 and will still appear) + Boolean showVisualSignature = + Boolean.TRUE.equals(settings.includeSummaryPage) ? false : settings.showSignature; + + log.info( + "Finalization settings: includeSummaryPage={}, showVisualSignature={}", + settings.includeSummaryPage, + showVisualSignature); + + // Step 2: Apply digital certificates per SIGNED participant + for (WorkflowParticipant participant : session.getParticipants()) { + if (participant.getStatus() != ParticipantStatus.SIGNED) { + log.debug( + "Skipping participant {} - status is {}", + participant.getEmail(), + participant.getStatus()); + continue; + } + + // Reload from DB to get fresh metadata + WorkflowParticipant fresh = + participantRepository + .findById(participant.getId()) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Participant not found: " + + participant.getId())); + + CertificateSubmission submission = extractCertificateSubmission(fresh); + if (submission == null) { + log.warn( + "No certificate submission found for participant {}, skipping", + fresh.getEmail()); + continue; + } + + ParticipantSignatureMetadata sigMeta = + extractParticipantSignatureMetadata(fresh, submission); + + log.info( + "Applying signature for {} with reason='{}', location='{}'", + fresh.getEmail(), + sigMeta.reason, + sigMeta.location); + + pdf = + applyDigitalSignature( + pdf, + fresh, + submission, + showVisualSignature, + settings.pageNumber, + sigMeta.reason, + sigMeta.location, + settings.showLogo); + } + + return pdf; + } + + /** + * Clears sensitive metadata from all participants after finalization (GDPR compliance). Removes + * wet signature image data and certificate submission data (keystores + passwords). + */ + public void clearSensitiveMetadata(WorkflowSession session) { + log.info("Clearing sensitive metadata for session {}", session.getSessionId()); + + for (WorkflowParticipant participant : session.getParticipants()) { + Map metadata = participant.getParticipantMetadata(); + if (metadata == null || metadata.isEmpty()) { + continue; + } + + boolean modified = false; + if (metadata.containsKey("wetSignatures")) { + metadata.remove("wetSignatures"); + modified = true; + } + if (metadata.containsKey("certificateSubmission")) { + metadata.remove("certificateSubmission"); + modified = true; + } + if (modified) { + participant.setParticipantMetadata(metadata); + participantRepository.save(participant); + log.debug("Cleared sensitive metadata for participant {}", participant.getEmail()); + } + } + } + + // ===== WET SIGNATURE APPLICATION ===== + + private byte[] applyWetSignatures(byte[] pdfBytes, WorkflowSession session) throws Exception { + log.info("Starting wet signature extraction for session {}", session.getSessionId()); + List wetSignatures = extractAllWetSignatures(session); + if (wetSignatures.isEmpty()) { + log.warn( + "No wet signatures to apply for session {} - participants may not have placed signatures", + session.getSessionId()); + return pdfBytes; + } + + log.info( + "Applying {} wet signature(s) to session {}", + wetSignatures.size(), + session.getSessionId()); + + PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes)); + try { + for (WetSignatureMetadata wetSig : wetSignatures) { + applyWetSignatureToPage(document, wetSig); + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return baos.toByteArray(); + } finally { + document.close(); + } + } + + private void applyWetSignatureToPage(PDDocument document, WetSignatureMetadata wetSig) + throws Exception { + int pageIndex = wetSig.getPage(); + if (pageIndex >= document.getNumberOfPages()) { + log.warn( + "Wet signature page {} exceeds document pages {}, skipping", + pageIndex, + document.getNumberOfPages()); + return; + } + + PDPage page = document.getPage(pageIndex); + PDPageContentStream contentStream = + new PDPageContentStream( + document, page, PDPageContentStream.AppendMode.APPEND, true, true); + + try { + // Use WetSignatureMetadata.extractBase64Data() to strip data URL prefix + String base64Data = wetSig.extractBase64Data(); + if (base64Data == null || base64Data.isBlank()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Wet signature image data is missing or empty for participant"); + } + byte[] imageBytes = java.util.Base64.getDecoder().decode(base64Data); + + PDImageXObject image = + PDImageXObject.createFromByteArray(document, imageBytes, "signature"); + + // Coordinates are stored as fractions (0–1) of the page dimensions. + // Multiply by page size to get absolute PDF points, then convert Y from + // UI top-left origin to PDF bottom-left origin. + float pageWidth = page.getMediaBox().getWidth(); + float pageHeight = page.getMediaBox().getHeight(); + float x = wetSig.getX().floatValue() * pageWidth; + float y = wetSig.getY().floatValue() * pageHeight; + float width = wetSig.getWidth().floatValue() * pageWidth; + float height = wetSig.getHeight().floatValue() * pageHeight; + float pdfY = pageHeight - y - height; + + contentStream.drawImage(image, x, pdfY, width, height); + + log.info( + "Applied wet signature at page {} coordinates ({}, {}) size {}x{}", + pageIndex, + x, + pdfY, + width, + height); + } finally { + contentStream.close(); + } + } + + // ===== SUMMARY PAGE ===== + + private byte[] appendSignatureSummaryPage(byte[] pdfBytes, WorkflowSession session) + throws Exception { + log.info("Appending signature summary page to session {}", session.getSessionId()); + + // ---- Color palette — monochrome except badge accents and logo ---- + // Only signedGreen and declinedRed carry colour; everything else is neutral gray. + final Color headerDark = new Color(31, 41, 55); // --gray-800 (#1f2937) + final Color signedGreen = new Color(16, 185, 129); // --category-color-signing + final Color declinedRed = new Color(239, 68, 68); // --category-color-removal + final Color cardBg = new Color(249, 250, 251); // --color-gray-50 + final Color cardBorder = new Color(229, 231, 235); // --color-gray-200 + final Color stripBg = new Color(243, 244, 246); // --color-gray-100 + final Color textDark = new Color(17, 24, 39); // --gray-900 + final Color textMuted = new Color(107, 114, 128); // --gray-500 + final Color sectionLabel = new Color(55, 65, 81); // --gray-700 + final Color columnLabel = new Color(107, 114, 128); // --gray-500 + final Color headerSubtle = new Color(209, 213, 219); // --gray-300 + + // ---- Fonts ---- + final PDFont fontBold = new PDType1Font(FontName.HELVETICA_BOLD); + final PDFont fontReg = new PDType1Font(FontName.HELVETICA); + + // ---- Page geometry ---- + final float PAGE_W = PDRectangle.A4.getWidth(); // 595.3 + final float PAGE_H = PDRectangle.A4.getHeight(); // 841.9 + final float MARGIN = 40f; + final float CONTENT_W = PAGE_W - 2 * MARGIN; + + // ---- Section heights ---- + final float HEADER_H = 72f; + final float STRIP_H = 36f; + + // ---- Card geometry ---- + final float CARD_PADDING = 10f; + final float ACCENT_W = 4f; + final float INNER_W = CONTENT_W - ACCENT_W - CARD_PADDING * 2; + final float COL_W = (INNER_W - CARD_PADDING) / 2f; + final float LINE_H = 14f; + + // ---- Date formatters ---- + java.time.format.DateTimeFormatter tsFormatter = + java.time.format.DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm:ss"); + + try (PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes))) { + PDPage summaryPage = new PDPage(PDRectangle.A4); + document.addPage(summaryPage); + + PDPageContentStream cs = + new PDPageContentStream( + document, + summaryPage, + PDPageContentStream.AppendMode.APPEND, + true, + true); + + try { + float yPos = PAGE_H; + + // ========== 1. HEADER BAR ========== + cs.setNonStrokingColor(headerDark); + cs.addRect(0, PAGE_H - HEADER_H, PAGE_W, HEADER_H); + cs.fill(); + + // Landscape wordmark logo (white text PNG, aspect 118:26) + // Rendered at 30pt height → ~136pt wide + final float LOGO_H = 30f; + final float LOGO_W = LOGO_H * (118f / 26f); // preserve aspect ratio + final float logoX = MARGIN; + final float logoY = PAGE_H - HEADER_H + (HEADER_H - LOGO_H) / 2f; + try { + ClassPathResource logoRes = + new ClassPathResource("static/images/stirling-logo-white.png"); + try (InputStream logoIn = logoRes.getInputStream()) { + BufferedImage logoImg = ImageIO.read(logoIn); + if (logoImg != null) { + PDImageXObject pdLogo = + LosslessFactory.createFromImage(document, logoImg); + cs.drawImage(pdLogo, logoX, logoY, LOGO_W, LOGO_H); + } + } + } catch (Exception e) { + // Fallback: just draw the name as text + log.debug( + "Could not load Stirling-PDF logo for summary page: {}", + e.getMessage()); + cs.setNonStrokingColor(Color.WHITE); + cs.beginText(); + cs.setFont(fontBold, 13); + cs.newLineAtOffset(logoX, PAGE_H - 28); + cs.showText("Stirling PDF"); + cs.endText(); + } + + // "Signature Summary" below the wordmark logo + cs.setNonStrokingColor(headerSubtle); + cs.beginText(); + cs.setFont(fontReg, 10); + cs.newLineAtOffset(MARGIN, PAGE_H - HEADER_H + 10); + cs.showText("Signature Summary"); + cs.endText(); + + // Document name (right-aligned, muted) + String docName = session.getDocumentName() != null ? session.getDocumentName() : ""; + float maxDocW = CONTENT_W * 0.45f; + while (docName.length() > 4 + && fontReg.getStringWidth(docName) / 1000f * 9 > maxDocW) { + docName = docName.substring(0, docName.length() - 4) + "..."; + } + float docNameW = fontReg.getStringWidth(docName) / 1000f * 9; + cs.setNonStrokingColor(headerSubtle); + cs.beginText(); + cs.setFont(fontReg, 9); + cs.newLineAtOffset(PAGE_W - MARGIN - docNameW, PAGE_H - 26); + cs.showText(docName); + cs.endText(); + + // Finalized timestamp (right-aligned, below doc name) + String finalizedStr = + "Finalized: " + java.time.LocalDateTime.now().format(tsFormatter); + float finalizedW = fontReg.getStringWidth(finalizedStr) / 1000f * 8; + cs.setNonStrokingColor(new Color(156, 163, 175)); // --gray-400 + cs.beginText(); + cs.setFont(fontReg, 8); + cs.newLineAtOffset(PAGE_W - MARGIN - finalizedW, PAGE_H - 42); + cs.showText(finalizedStr); + cs.endText(); + + yPos = PAGE_H - HEADER_H; + + // ========== 2. INFO STRIP ========== + cs.setNonStrokingColor(stripBg); + cs.addRect(0, yPos - STRIP_H, PAGE_W, STRIP_H); + cs.fill(); + + cs.setNonStrokingColor(textDark); + cs.beginText(); + cs.setFont(fontReg, 9); + cs.newLineAtOffset(MARGIN, yPos - STRIP_H + 13); + cs.showText("Session Owner: " + session.getOwner().getUsername()); + cs.endText(); + + long signedCount = + session.getParticipants().stream() + .filter(p -> p.getStatus() == ParticipantStatus.SIGNED) + .count(); + long totalCount = session.getParticipants().size(); + String countStr = signedCount + " of " + totalCount + " participant(s) signed"; + float countW = fontReg.getStringWidth(countStr) / 1000f * 9; + cs.setNonStrokingColor(textDark); + cs.beginText(); + cs.setFont(fontReg, 9); + cs.newLineAtOffset(PAGE_W - MARGIN - countW, yPos - STRIP_H + 13); + cs.showText(countStr); + cs.endText(); + + yPos -= STRIP_H + 14; + + // ========== 3. DIVIDER ========== + cs.setStrokingColor(cardBorder); + cs.setLineWidth(0.5f); + cs.moveTo(MARGIN, yPos); + cs.lineTo(PAGE_W - MARGIN, yPos); + cs.stroke(); + yPos -= 16; + + // ========== 4. SECTION HEADER ========== + cs.setNonStrokingColor(sectionLabel); + cs.beginText(); + cs.setFont(fontBold, 13); + cs.newLineAtOffset(MARGIN, yPos); + cs.showText("Signatories"); + cs.endText(); + yPos -= 20; + + // ========== 5. SIGNER CARDS ========== + for (WorkflowParticipant participant : session.getParticipants()) { + if (participant.getStatus() != ParticipantStatus.SIGNED + && participant.getStatus() != ParticipantStatus.DECLINED) { + continue; + } + + boolean isSigned = participant.getStatus() == ParticipantStatus.SIGNED; + Color statusColor = isSigned ? signedGreen : declinedRed; + String statusLabel = isSigned ? "SIGNED" : "DECLINED"; + + // Gather data before measuring card height + CertificateSubmission submission = + isSigned ? extractCertificateSubmission(participant) : null; + ParticipantSignatureMetadata meta = null; + CertificateInfo certInfo = null; + if (isSigned && submission != null) { + meta = extractParticipantSignatureMetadata(participant, submission); + certInfo = extractCertificateInfo(submission, participant); + } + + // Dynamic card height: + // header row (28) + inner divider (10) + body + top/bottom padding + boolean hasLocation = + meta != null && meta.location != null && !meta.location.isEmpty(); + boolean hasReason = + meta != null + && meta.reason != null + && !meta.reason.isEmpty() + && !"Document Signing".equals(meta.reason); + // left col: column label row + data rows + int leftDataRows = 0; + if (isSigned) leftDataRows++; // timestamp + if (hasReason) leftDataRows++; + if (hasLocation) leftDataRows++; + if (submission != null) leftDataRows++; // cert type + // right col: 6 data rows (subjectCN, issuerCN, serial, validFrom, validUntil, + // algorithm) + int rightDataRows = certInfo != null ? 6 : 0; + int bodyRows = + 1 + Math.max(leftDataRows, rightDataRows); // +1 for column label row + float cardBodyH = bodyRows * LINE_H + CARD_PADDING; + float cardH = 28 + 10 + cardBodyH + CARD_PADDING; + + // Overflow to new page + if (yPos - cardH < 50) { + cs.close(); + summaryPage = new PDPage(PDRectangle.A4); + document.addPage(summaryPage); + cs = + new PDPageContentStream( + document, + summaryPage, + PDPageContentStream.AppendMode.APPEND, + true, + true); + yPos = PAGE_H - MARGIN; + } + + float cardLeft = MARGIN; + float cardTop = yPos; + + // Card background + cs.setNonStrokingColor(cardBg); + cs.addRect(cardLeft, cardTop - cardH, CONTENT_W, cardH); + cs.fill(); + + // Card border + cs.setStrokingColor(cardBorder); + cs.setLineWidth(0.5f); + cs.addRect(cardLeft, cardTop - cardH, CONTENT_W, cardH); + cs.stroke(); + + // Left accent bar + cs.setNonStrokingColor(statusColor); + cs.addRect(cardLeft, cardTop - cardH, ACCENT_W, cardH); + cs.fill(); + + // Card header: Name + float headerY = cardTop - CARD_PADDING - 14; + String nameStr = + participant.getName() != null ? participant.getName() : "Unknown"; + cs.setNonStrokingColor(textDark); + cs.beginText(); + cs.setFont(fontBold, 11); + cs.newLineAtOffset(cardLeft + ACCENT_W + CARD_PADDING, headerY); + cs.showText(nameStr); + cs.endText(); + + // Email (muted, same line) + float nameW = fontBold.getStringWidth(nameStr) / 1000f * 11; + String emailStr = + participant.getEmail() != null + ? "<" + participant.getEmail() + ">" + : ""; + cs.setNonStrokingColor(textMuted); + cs.beginText(); + cs.setFont(fontReg, 9); + cs.newLineAtOffset(cardLeft + ACCENT_W + CARD_PADDING + nameW + 5, headerY); + cs.showText(emailStr); + cs.endText(); + + // Status badge (filled rect with white text) + float badgeW = fontBold.getStringWidth(statusLabel) / 1000f * 7 + 10; + float badgeX = cardLeft + CONTENT_W - badgeW - CARD_PADDING; + float badgeY = headerY - 3; + cs.setNonStrokingColor(statusColor); + cs.addRect(badgeX, badgeY, badgeW, 13); + cs.fill(); + cs.setNonStrokingColor(Color.WHITE); + cs.beginText(); + cs.setFont(fontBold, 7); + cs.newLineAtOffset(badgeX + 4, badgeY + 3); + cs.showText(statusLabel); + cs.endText(); + + // Inner card divider + float divY = cardTop - CARD_PADDING - 26; + cs.setStrokingColor(cardBorder); + cs.setLineWidth(0.5f); + cs.moveTo(cardLeft + ACCENT_W + CARD_PADDING, divY); + cs.lineTo(cardLeft + CONTENT_W - CARD_PADDING, divY); + cs.stroke(); + + // Two-column body + float bodyTopY = divY - LINE_H; + float leftColX = cardLeft + ACCENT_W + CARD_PADDING; + float rightColX = leftColX + COL_W + CARD_PADDING; + float rowY = bodyTopY; + + // Left column label + cs.setNonStrokingColor(columnLabel); + cs.beginText(); + cs.setFont(fontBold, 8); + cs.newLineAtOffset(leftColX, rowY); + cs.showText("Signature Details"); + cs.endText(); + rowY -= LINE_H; + + if (isSigned && participant.getLastUpdated() != null) { + drawLabelValue( + cs, + fontBold, + fontReg, + leftColX, + rowY, + textDark, + textMuted, + "Signed:", + participant.getLastUpdated().format(tsFormatter)); + rowY -= LINE_H; + } else if (!isSigned) { + drawLabelValue( + cs, + fontBold, + fontReg, + leftColX, + rowY, + textDark, + textMuted, + "Status:", + "Declined signing"); + rowY -= LINE_H; + } + + if (hasReason) { + drawLabelValue( + cs, + fontBold, + fontReg, + leftColX, + rowY, + textDark, + textMuted, + "Reason:", + meta.reason); + rowY -= LINE_H; + } + if (hasLocation) { + drawLabelValue( + cs, + fontBold, + fontReg, + leftColX, + rowY, + textDark, + textMuted, + "Location:", + meta.location); + rowY -= LINE_H; + } + if (submission != null && submission.getCertType() != null) { + drawLabelValue( + cs, + fontBold, + fontReg, + leftColX, + rowY, + textDark, + textMuted, + "Cert Type:", + submission.getCertType()); + } + + // Right column: Certificate Details + if (certInfo != null) { + float rRowY = bodyTopY; + cs.setNonStrokingColor(columnLabel); + cs.beginText(); + cs.setFont(fontBold, 8); + cs.newLineAtOffset(rightColX, rRowY); + cs.showText("Certificate Details"); + cs.endText(); + rRowY -= LINE_H; + + drawLabelValue( + cs, + fontBold, + fontReg, + rightColX, + rRowY, + textDark, + textMuted, + "Subject:", + certInfo.subjectCN); + rRowY -= LINE_H; + drawLabelValue( + cs, + fontBold, + fontReg, + rightColX, + rRowY, + textDark, + textMuted, + "Issuer:", + certInfo.issuerCN); + rRowY -= LINE_H; + drawLabelValue( + cs, + fontBold, + fontReg, + rightColX, + rRowY, + textDark, + textMuted, + "Serial:", + certInfo.serialNumber); + rRowY -= LINE_H; + drawLabelValue( + cs, + fontBold, + fontReg, + rightColX, + rRowY, + textDark, + textMuted, + "Valid From:", + certInfo.validFrom); + rRowY -= LINE_H; + drawLabelValue( + cs, + fontBold, + fontReg, + rightColX, + rRowY, + textDark, + textMuted, + "Valid Until:", + certInfo.validUntil); + rRowY -= LINE_H; + drawLabelValue( + cs, + fontBold, + fontReg, + rightColX, + rRowY, + textDark, + textMuted, + "Algorithm:", + certInfo.algorithm); + } + + yPos -= cardH + 12; + } + + // ========== 6. FOOTER ========== + cs.setStrokingColor(cardBorder); + cs.setLineWidth(0.5f); + cs.moveTo(MARGIN, 44); + cs.lineTo(PAGE_W - MARGIN, 44); + cs.stroke(); + + String footerText = "Generated by Stirling-PDF"; + float footerTextW = fontReg.getStringWidth(footerText) / 1000f * 9; + cs.setNonStrokingColor(textMuted); + cs.beginText(); + cs.setFont(fontReg, 9); + cs.newLineAtOffset((PAGE_W - footerTextW) / 2f, 30); + cs.showText(footerText); + cs.endText(); + + } finally { + cs.close(); + } + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + document.save(baos); + return baos.toByteArray(); + } + } + + // ===== DIGITAL SIGNATURE APPLICATION ===== + + private byte[] applyDigitalSignature( + byte[] pdfBytes, + WorkflowParticipant participant, + CertificateSubmission submission, + Boolean showSignature, + Integer pageNumber, + String reason, + String location, + Boolean showLogo) + throws Exception { + + log.info( + "Applying digital signature for participant {} - showSignature={}, pageNumber={}, reason='{}', location='{}', showLogo={}", + participant.getEmail(), + showSignature, + pageNumber, + reason, + location, + showLogo); + + KeyStore keystore = buildKeystore(submission, participant); + validateCertificateNotExpired(keystore, participant.getEmail()); + String password = getKeystorePassword(submission, participant); + + byte[] signed = + pdfSigningService.signWithKeystore( + pdfBytes, + keystore, + password != null ? password.toCharArray() : new char[0], + showSignature != null ? showSignature : false, + pageNumber != null ? pageNumber - 1 : null, + participant.getName() != null ? participant.getName() : "Shared Signing", + location != null ? location : "", + reason != null ? reason : "Document Signing", + showLogo != null ? showLogo : false); + + log.info( + "Digital signature applied for {} using cert type {}", + participant.getEmail(), + submission.getCertType()); + + return signed; + } + + private KeyStore buildKeystore( + CertificateSubmission submission, WorkflowParticipant participant) throws Exception { + String certType = submission.getCertType(); + String password = submission.getPassword(); + + switch (certType) { + case "P12": + if (submission.getP12Keystore() == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "P12 keystore data is required"); + } + try { + KeyStore p12Store = KeyStore.getInstance("PKCS12"); + p12Store.load( + new ByteArrayInputStream(submission.getP12Keystore()), + password != null ? password.toCharArray() : new char[0]); + return p12Store; + } catch (Exception e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Failed to open P12 keystore — check that the file is valid and the password is correct"); + } + + case "JKS": + if (submission.getJksKeystore() == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "JKS keystore data is required"); + } + try { + KeyStore jksStore = KeyStore.getInstance("JKS"); + jksStore.load( + new ByteArrayInputStream(submission.getJksKeystore()), + password != null ? password.toCharArray() : new char[0]); + return jksStore; + } catch (Exception e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Failed to open JKS keystore — check that the file is valid and the password is correct"); + } + + case "SERVER": + if (serverCertificateService == null + || !serverCertificateService.isEnabled() + || !serverCertificateService.hasServerCertificate()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Server certificate is not available or not configured"); + } + return serverCertificateService.getServerKeyStore(); + + case "USER_CERT": + if (userServerCertificateService == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "User certificate service is not available"); + } + if (participant.getUser() == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "User certificate requires authenticated user"); + } + try { + userServerCertificateService.getOrCreateUserCertificate( + participant.getUser().getId()); + return userServerCertificateService.getUserKeyStore( + participant.getUser().getId()); + } catch (Exception e) { + log.error( + "Failed to get user certificate for user {}: {}", + participant.getUser().getId(), + e.getMessage()); + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Failed to generate or retrieve user certificate: " + e.getMessage()); + } + + default: + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Invalid certificate type: " + certType); + } + } + + private void validateCertificateNotExpired(KeyStore keystore, String participantEmail) + throws Exception { + Enumeration aliases = keystore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + java.security.cert.Certificate cert = keystore.getCertificate(alias); + if (cert instanceof java.security.cert.X509Certificate x509) { + try { + x509.checkValidity(); + } catch (java.security.cert.CertificateExpiredException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Certificate for participant '" + + participantEmail + + "' has expired. Please upload a valid certificate."); + } catch (java.security.cert.CertificateNotYetValidException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Certificate for participant '" + + participantEmail + + "' is not yet valid."); + } + } + } + } + + private String getKeystorePassword( + CertificateSubmission submission, WorkflowParticipant participant) { + String certType = submission.getCertType(); + + if ("SERVER".equalsIgnoreCase(certType) && serverCertificateService != null) { + return serverCertificateService.getServerCertificatePassword(); + } + + if ("USER_CERT".equalsIgnoreCase(certType) + && userServerCertificateService != null + && participant.getUser() != null) { + try { + return userServerCertificateService.getUserKeystorePassword( + participant.getUser().getId()); + } catch (Exception e) { + log.error("Failed to get user certificate password", e); + return null; + } + } + + return submission.getPassword(); + } + + // ===== METADATA EXTRACTION ===== + + private SessionSignatureSettings extractSessionSettings(WorkflowSession session) { + Map workflowMetadata = session.getWorkflowMetadata(); + + Boolean showSignature = false; + Integer pageNumber = null; + Boolean showLogo = false; + Boolean includeSummaryPage = false; + + if (workflowMetadata != null && !workflowMetadata.isEmpty()) { + showSignature = + workflowMetadata.containsKey("showSignature") + ? (Boolean) workflowMetadata.get("showSignature") + : false; + pageNumber = + workflowMetadata.containsKey("pageNumber") + ? ((Number) workflowMetadata.get("pageNumber")).intValue() + : null; + showLogo = + workflowMetadata.containsKey("showLogo") + ? (Boolean) workflowMetadata.get("showLogo") + : false; + includeSummaryPage = + workflowMetadata.containsKey("includeSummaryPage") + ? (Boolean) workflowMetadata.get("includeSummaryPage") + : false; + } + + return new SessionSignatureSettings( + showSignature, pageNumber, showLogo, includeSummaryPage); + } + + /** + * Resolves reason and location for a participant's digital signature. Reason: participant + * override → owner default → "Document Signing" Location: participant-provided only (no + * default) + */ + private ParticipantSignatureMetadata extractParticipantSignatureMetadata( + WorkflowParticipant participant, CertificateSubmission submission) { + + String reason = "Document Signing"; + if (submission != null + && submission.getReason() != null + && !submission.getReason().isBlank()) { + reason = submission.getReason(); + } else { + Map metadata = participant.getParticipantMetadata(); + if (metadata != null && metadata.containsKey("defaultReason")) { + reason = (String) metadata.get("defaultReason"); + } + } + + String location = + (submission != null && submission.getLocation() != null) + ? submission.getLocation() + : ""; + + return new ParticipantSignatureMetadata(reason, location); + } + + private CertificateSubmission extractCertificateSubmission(WorkflowParticipant participant) { + log.info( + "Extracting certificate for participant ID: {}, email: {}", + participant.getId(), + participant.getEmail()); + Map metadata = participant.getParticipantMetadata(); + if (metadata == null || metadata.isEmpty()) { + log.info("No metadata found for participant {}", participant.getEmail()); + return null; + } + if (!metadata.containsKey("certificateSubmission")) { + log.info( + "certificateSubmission key not found for participant {}", + participant.getEmail()); + return null; + } + + try { + var node = objectMapper.valueToTree(metadata); + if (node.has("certificateSubmission")) { + CertificateSubmission submission = + objectMapper.treeToValue( + node.get("certificateSubmission"), CertificateSubmission.class); + + // Decrypt password (supports both legacy plaintext and encrypted values) + if (submission.getPassword() != null) { + submission.setPassword( + metadataEncryptionService.decrypt(submission.getPassword())); + } + + // Decode base64 keystore bytes + var certNode = node.get("certificateSubmission"); + if (certNode.has("p12Keystore")) { + submission.setP12Keystore( + java.util.Base64.getDecoder() + .decode(certNode.get("p12Keystore").asText())); + } + if (certNode.has("jksKeystore")) { + submission.setJksKeystore( + java.util.Base64.getDecoder() + .decode(certNode.get("jksKeystore").asText())); + } + return submission; + } + } catch (Exception e) { + log.error( + "Failed to parse certificate submission for participant {}: {}", + participant.getEmail(), + e.getMessage(), + e); + } + return null; + } + + /** + * Extracts X509 certificate fields from a participant's keystore for display on the summary + * page. Returns null gracefully if the certificate cannot be loaded (e.g. missing data). + */ + private CertificateInfo extractCertificateInfo( + CertificateSubmission submission, WorkflowParticipant participant) { + try { + KeyStore keystore = buildKeystore(submission, participant); + Enumeration aliases = keystore.aliases(); + if (!aliases.hasMoreElements()) { + return null; + } + String alias = aliases.nextElement(); + Certificate cert = keystore.getCertificate(alias); + if (!(cert instanceof X509Certificate)) { + return null; + } + X509Certificate x509 = (X509Certificate) cert; + + String subjectCN = extractCN(x509.getSubjectX500Principal().getName()); + String issuerCN = extractCN(x509.getIssuerX500Principal().getName()); + + java.time.format.DateTimeFormatter dtf = + java.time.format.DateTimeFormatter.ofPattern("dd MMM yyyy"); + String validFrom = + x509.getNotBefore() + .toInstant() + .atZone(ZoneOffset.UTC) + .toLocalDate() + .format(dtf); + String validUntil = + x509.getNotAfter().toInstant().atZone(ZoneOffset.UTC).toLocalDate().format(dtf); + + String serial = x509.getSerialNumber().toString(16).toUpperCase(); + if (serial.length() > 20) { + serial = serial.substring(0, 17) + "..."; + } + + return new CertificateInfo( + subjectCN, issuerCN, serial, validFrom, validUntil, x509.getSigAlgName()); + + } catch (Exception e) { + log.warn( + "Could not extract certificate info for {}: {}", + participant.getEmail(), + e.getMessage(), + e); + return null; + } + } + + /** Extracts the CN value from an RFC 2253 DN string. Uses BouncyCastle with simple fallback. */ + private String extractCN(String dnString) { + try { + X500Name x500Name = new X500Name(dnString); + RDN[] cns = x500Name.getRDNs(BCStyle.CN); + if (cns.length > 0) { + return IETFUtils.valueToString(cns[0].getFirst().getValue()); + } + } catch (Exception ignored) { + // fall through to simple parse + } + for (String part : dnString.split(",")) { + String trimmed = part.trim(); + if (trimmed.startsWith("CN=")) { + return trimmed.substring(3); + } + } + return dnString; + } + + /** + * Draws a bold label followed by a regular-weight value on the same baseline. Clamps value to + * 26 characters to prevent overflow into adjacent column. + */ + private void drawLabelValue( + PDPageContentStream cs, + PDFont labelFont, + PDFont valueFont, + float x, + float y, + Color labelColor, + Color valueColor, + String label, + String value) + throws java.io.IOException { + String safeValue = value != null ? value : ""; + if (safeValue.length() > 26) { + safeValue = safeValue.substring(0, 23) + "..."; + } + cs.setNonStrokingColor(labelColor); + cs.beginText(); + cs.setFont(labelFont, 8); + cs.newLineAtOffset(x, y); + cs.showText(label); + cs.endText(); + + float labelW = labelFont.getStringWidth(label) / 1000f * 8; + cs.setNonStrokingColor(valueColor); + cs.beginText(); + cs.setFont(valueFont, 8); + cs.newLineAtOffset(x + labelW + 3, y); + cs.showText(safeValue); + cs.endText(); + } + + private List extractAllWetSignatures(WorkflowSession session) { + List signatures = new ArrayList<>(); + + for (WorkflowParticipant participant : session.getParticipants()) { + // Reload from DB for fresh metadata + WorkflowParticipant fresh; + try { + fresh = + participantRepository + .findById(participant.getId()) + .orElseThrow( + () -> + new RuntimeException( + "Participant not found: " + + participant.getId())); + } catch (Exception e) { + log.error( + "Failed to reload participant {}: {}", + participant.getEmail(), + e.getMessage()); + continue; + } + + Map metadata = fresh.getParticipantMetadata(); + if (metadata == null || metadata.isEmpty() || !metadata.containsKey("wetSignatures")) { + continue; + } + + try { + Object wetSigsRaw = metadata.get("wetSignatures"); + if (!(wetSigsRaw instanceof List)) { + log.warn( + "wetSignatures for participant {} is not a List (was {}), skipping", + fresh.getEmail(), + wetSigsRaw == null ? "null" : wetSigsRaw.getClass().getName()); + continue; + } + @SuppressWarnings("unchecked") + List> wetSigsList = (List>) wetSigsRaw; + log.info( + "Found {} wet signature(s) for participant {}", + wetSigsList.size(), + fresh.getEmail()); + for (Map sigMap : wetSigsList) { + WetSignatureMetadata wetSig = mapToWetSignature(sigMap); + if (wetSig != null) { + signatures.add(wetSig); + } + } + } catch (Exception e) { + log.error("Failed to parse wet signatures for participant {}", fresh.getEmail(), e); + } + } + + log.info("Total wet signatures extracted: {}", signatures.size()); + return signatures; + } + + /** + * Converts a raw metadata map entry to a WetSignatureMetadata object. Uses direct map access to + * avoid any Jackson version-specific POJO deserialization issues. + */ + private WetSignatureMetadata mapToWetSignature(Map sigMap) { + if (sigMap == null) { + return null; + } + try { + WetSignatureMetadata wetSig = new WetSignatureMetadata(); + wetSig.setType((String) sigMap.get("type")); + wetSig.setData((String) sigMap.get("data")); + Object page = sigMap.get("page"); + wetSig.setPage(page instanceof Number ? ((Number) page).intValue() : null); + Object x = sigMap.get("x"); + wetSig.setX(x instanceof Number ? ((Number) x).doubleValue() : null); + Object y = sigMap.get("y"); + wetSig.setY(y instanceof Number ? ((Number) y).doubleValue() : null); + Object width = sigMap.get("width"); + wetSig.setWidth(width instanceof Number ? ((Number) width).doubleValue() : null); + Object height = sigMap.get("height"); + wetSig.setHeight(height instanceof Number ? ((Number) height).doubleValue() : null); + return wetSig; + } catch (Exception e) { + log.error("Failed to map wet signature entry {}", sigMap, e); + return null; + } + } + + // ===== PRIVATE INNER TYPES ===== + + private static class SessionSignatureSettings { + final Boolean showSignature; + final Integer pageNumber; + final Boolean showLogo; + final Boolean includeSummaryPage; + + SessionSignatureSettings( + Boolean showSignature, + Integer pageNumber, + Boolean showLogo, + Boolean includeSummaryPage) { + this.showSignature = showSignature; + this.pageNumber = pageNumber; + this.showLogo = showLogo; + this.includeSummaryPage = includeSummaryPage; + } + } + + private static class ParticipantSignatureMetadata { + final String reason; + final String location; + + ParticipantSignatureMetadata(String reason, String location) { + this.reason = reason; + this.location = location; + } + } + + private static class CertificateInfo { + final String subjectCN; + final String issuerCN; + final String serialNumber; + final String validFrom; + final String validUntil; + final String algorithm; + + CertificateInfo( + String subjectCN, + String issuerCN, + String serialNumber, + String validFrom, + String validUntil, + String algorithm) { + this.subjectCN = subjectCN; + this.issuerCN = issuerCN; + this.serialNumber = serialNumber; + this.validFrom = validFrom; + this.validUntil = validUntil; + this.algorithm = algorithm; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlService.java new file mode 100644 index 0000000000..57ad1c97e7 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlService.java @@ -0,0 +1,233 @@ +package stirling.software.proprietary.workflow.service; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.FileShare; +import stirling.software.proprietary.storage.model.ShareAccessRole; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.repository.FileShareRepository; +import stirling.software.proprietary.workflow.model.ParticipantStatus; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; + +/** + * Unified access control service that consolidates validation logic for both generic file shares + * and workflow participants. + * + *

This service bridges the gap between the file sharing infrastructure and workflow-specific + * access control. + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class UnifiedAccessControlService { + + private final FileShareRepository fileShareRepository; + private final WorkflowParticipantRepository workflowParticipantRepository; + + /** + * Validates a share token and returns access validation result. Works for both generic file + * shares and workflow participant shares. + */ + public AccessValidationResult validateToken(String token, User user) { + log.debug("Validating access token: {}", token); + + // First try as file share token + Optional fileShareOpt = fileShareRepository.findByShareTokenWithFile(token); + if (fileShareOpt.isPresent()) { + return validateGenericShare(fileShareOpt.get(), user); + } + + // Try as workflow participant token + Optional participantOpt = + workflowParticipantRepository.findByShareToken(token); + if (participantOpt.isPresent()) { + return validateParticipant(participantOpt.get(), user); + } + + log.warn("Invalid or expired token: {}", token); + return AccessValidationResult.denied("Invalid or expired access token"); + } + + /** Validates a generic file share (non-workflow) */ + private AccessValidationResult validateGenericShare(FileShare share, User user) { + // Check expiration + if (share.getExpiresAt() != null && LocalDateTime.now().isAfter(share.getExpiresAt())) { + log.warn("Share token expired: {}", share.getShareToken()); + return AccessValidationResult.denied("Access link has expired"); + } + + // Check if user matches (if share is user-specific) + if (share.getSharedWithUser() != null && !share.getSharedWithUser().equals(user)) { + log.warn( + "User mismatch for share: expected {}, got {}", + share.getSharedWithUser().getId(), + user != null ? user.getId() : "null"); + return AccessValidationResult.denied("Access denied for this user"); + } + + return AccessValidationResult.allowed(share.getFile(), share.getAccessRole(), null, false); + } + + /** Validates a workflow participant by token */ + private AccessValidationResult validateParticipant(WorkflowParticipant participant, User user) { + // Check expiration + if (participant.isExpired()) { + log.warn("Workflow participant access expired: {}", participant.getShareToken()); + return AccessValidationResult.denied("Workflow access has expired"); + } + + // Check if workflow is still active + if (!participant.getWorkflowSession().isActive()) { + log.info( + "Workflow session no longer active: {}", + participant.getWorkflowSession().getSessionId()); + return AccessValidationResult.denied("Workflow session is no longer active"); + } + + // Check user authorization + if (participant.getUser() != null && !participant.getUser().equals(user)) { + log.warn( + "User mismatch for participant: expected {}, got {}", + participant.getUser().getId(), + user != null ? user.getId() : "null"); + return AccessValidationResult.denied("Access denied for this user"); + } + + // Get effective role based on participant status + ShareAccessRole effectiveRole = getEffectiveRole(participant); + + // Get the file from the workflow session + StoredFile file = participant.getWorkflowSession().getOriginalFile(); + + return AccessValidationResult.allowed(file, effectiveRole, participant, true); + } + + /** + * Maps participant status to effective access role. After completion (signed/declined), + * downgrade to VIEWER. + */ + public ShareAccessRole getEffectiveRole(WorkflowParticipant participant) { + ParticipantStatus status = participant.getStatus(); + + switch (status) { + case SIGNED: + case DECLINED: + // After action completed, downgrade to read-only + return ShareAccessRole.VIEWER; + case PENDING: + case NOTIFIED: + case VIEWED: + // Active participants retain their assigned role + return participant.getAccessRole(); + default: + log.warn("Unknown participant status: {}", status); + return ShareAccessRole.VIEWER; + } + } + + /** Checks if a user can access a specific file */ + public boolean canAccessFile(User user, StoredFile file) { + // Owner always has access + if (file.getOwner().equals(user)) { + return true; + } + + // Check for file share + Optional share = fileShareRepository.findByFileAndSharedWithUser(file, user); + if (share.isPresent() && !isExpired(share.get())) { + return true; + } + + // Check for workflow participant access + if (file.getWorkflowSession() != null) { + Optional participant = + workflowParticipantRepository.findByWorkflowSessionAndUser( + file.getWorkflowSession(), user); + return participant.isPresent() + && !participant.get().isExpired() + && participant.get().getWorkflowSession().isActive(); + } + + return false; + } + + private boolean isExpired(FileShare share) { + return share.getExpiresAt() != null && LocalDateTime.now().isAfter(share.getExpiresAt()); + } + + /** Result of access validation */ + public static class AccessValidationResult { + private final boolean allowed; + private final String denialReason; + private final StoredFile file; + private final ShareAccessRole role; + private final WorkflowParticipant participant; + private final boolean isWorkflowAccess; + + private AccessValidationResult( + boolean allowed, + String denialReason, + StoredFile file, + ShareAccessRole role, + WorkflowParticipant participant, + boolean isWorkflowAccess) { + this.allowed = allowed; + this.denialReason = denialReason; + this.file = file; + this.role = role; + this.participant = participant; + this.isWorkflowAccess = isWorkflowAccess; + } + + public static AccessValidationResult allowed( + StoredFile file, + ShareAccessRole role, + WorkflowParticipant participant, + boolean isWorkflowAccess) { + return new AccessValidationResult( + true, null, file, role, participant, isWorkflowAccess); + } + + public static AccessValidationResult denied(String reason) { + return new AccessValidationResult(false, reason, null, null, null, false); + } + + public boolean isAllowed() { + return allowed; + } + + public String getDenialReason() { + return denialReason; + } + + public StoredFile getFile() { + return file; + } + + public ShareAccessRole getRole() { + return role; + } + + public WorkflowParticipant getParticipant() { + return participant; + } + + public boolean isWorkflowAccess() { + return isWorkflowAccess; + } + + public boolean canEdit() { + return allowed && (role == ShareAccessRole.EDITOR || role == ShareAccessRole.COMMENTER); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UserServerCertificateService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UserServerCertificateService.java new file mode 100644 index 0000000000..5f73deb322 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/UserServerCertificateService.java @@ -0,0 +1,258 @@ +package stirling.software.proprietary.workflow.service; + +import java.io.*; +import java.math.BigInteger; +import java.security.*; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; +import java.util.Optional; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.workflow.model.CertificateType; +import stirling.software.proprietary.workflow.model.UserServerCertificateEntity; +import stirling.software.proprietary.workflow.repository.UserServerCertificateRepository; + +@Service +@Slf4j +@RequiredArgsConstructor +public class UserServerCertificateService { + + private static final String KEYSTORE_ALIAS = "stirling-pdf-user-cert"; + private static final String DEFAULT_PASSWORD_PREFIX = "stirling-user-cert-"; + private static final int VALIDITY_DAYS = 365; + + private final UserServerCertificateRepository certificateRepository; + private final UserRepository userRepository; + private final MetadataEncryptionService metadataEncryptionService; + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + /** Get or create user certificate (auto-generate if not exists) */ + @Transactional + public UserServerCertificateEntity getOrCreateUserCertificate(Long userId) throws Exception { + Optional existing = certificateRepository.findByUserId(userId); + if (existing.isPresent()) { + return existing.get(); + } + + User user = + userRepository + .findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + return generateUserCertificate(user); + } + + /** Generate new certificate for user */ + @Transactional + public UserServerCertificateEntity generateUserCertificate(User user) throws Exception { + log.info("Generating server certificate for user: {}", user.getUsername()); + + // Generate key pair + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); + keyPairGenerator.initialize(2048, new SecureRandom()); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // Certificate details with username + String username = user.getUsername(); + X500Name subject = new X500Name("CN=" + username + ", O=Stirling-PDF User, C=US"); + BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis()); + Date notBefore = new Date(); + Date notAfter = + new Date(notBefore.getTime() + ((long) VALIDITY_DAYS * 24 * 60 * 60 * 1000)); + + // Build certificate + JcaX509v3CertificateBuilder certBuilder = + new JcaX509v3CertificateBuilder( + subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic()); + + // Add PDF-specific certificate extensions + JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils(); + + // End-entity certificate, not a CA + certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + + // Key usage for PDF digital signatures + certBuilder.addExtension( + Extension.keyUsage, + true, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation)); + + // Extended key usage for document signing + certBuilder.addExtension( + Extension.extendedKeyUsage, + false, + new ExtendedKeyUsage(KeyPurposeId.id_kp_codeSigning)); + + // Subject Key Identifier + certBuilder.addExtension( + Extension.subjectKeyIdentifier, + false, + extUtils.createSubjectKeyIdentifier(keyPair.getPublic())); + + // Authority Key Identifier for self-signed cert + certBuilder.addExtension( + Extension.authorityKeyIdentifier, + false, + extUtils.createAuthorityKeyIdentifier(keyPair.getPublic())); + + // Sign certificate + ContentSigner signer = + new JcaContentSignerBuilder("SHA256WithRSA") + .setProvider("BC") + .build(keyPair.getPrivate()); + + X509CertificateHolder certHolder = certBuilder.build(signer); + X509Certificate cert = + new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder); + + // Create keystore + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + String password = generateUserPassword(user.getId()); + keyStore.setKeyEntry( + KEYSTORE_ALIAS, + keyPair.getPrivate(), + password.toCharArray(), + new Certificate[] {cert}); + + // Store keystore bytes + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + keyStore.store(baos, password.toCharArray()); + byte[] keystoreBytes = baos.toByteArray(); + + // Create entity + UserServerCertificateEntity entity = new UserServerCertificateEntity(); + entity.setUser(user); + entity.setKeystoreData(keystoreBytes); + entity.setKeystorePassword(metadataEncryptionService.encrypt(password)); + entity.setCertificateType(CertificateType.AUTO_GENERATED); + entity.setSubjectDn(cert.getSubjectX500Principal().getName()); + entity.setIssuerDn(cert.getIssuerX500Principal().getName()); + entity.setValidFrom( + LocalDateTime.ofInstant(cert.getNotBefore().toInstant(), ZoneId.systemDefault())); + entity.setValidTo( + LocalDateTime.ofInstant(cert.getNotAfter().toInstant(), ZoneId.systemDefault())); + + return certificateRepository.save(entity); + } + + /** Upload user-provided certificate */ + @Transactional + public UserServerCertificateEntity uploadUserCertificate( + User user, InputStream p12Stream, String password) throws Exception { + log.info("Uploading user certificate for user: {}", user.getUsername()); + + // Validate keystore + byte[] keystoreBytes = p12Stream.readNBytes(10 * 1024 * 1024 + 1); // read at most 10 MB + 1 + if (keystoreBytes.length > 10 * 1024 * 1024) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Keystore file exceeds maximum allowed size of 10 MB"); + } + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(new ByteArrayInputStream(keystoreBytes), password.toCharArray()); + + // Extract certificate info + String alias = keyStore.aliases().nextElement(); + X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias); + + if (cert == null) { + throw new IllegalArgumentException("No certificate found in keystore"); + } + + // Create or update entity + UserServerCertificateEntity entity = + certificateRepository + .findByUserId(user.getId()) + .orElse(new UserServerCertificateEntity()); + + entity.setUser(user); + entity.setKeystoreData(keystoreBytes); + entity.setKeystorePassword(metadataEncryptionService.encrypt(password)); + entity.setCertificateType(CertificateType.USER_UPLOADED); + entity.setSubjectDn(cert.getSubjectX500Principal().getName()); + entity.setIssuerDn(cert.getIssuerX500Principal().getName()); + entity.setValidFrom( + LocalDateTime.ofInstant(cert.getNotBefore().toInstant(), ZoneId.systemDefault())); + entity.setValidTo( + LocalDateTime.ofInstant(cert.getNotAfter().toInstant(), ZoneId.systemDefault())); + + return certificateRepository.save(entity); + } + + /** Get user's KeyStore for signing operations */ + @Transactional(readOnly = true) + public KeyStore getUserKeyStore(Long userId) throws Exception { + UserServerCertificateEntity cert = + certificateRepository + .findByUserId(userId) + .orElseThrow( + () -> new IllegalArgumentException("User certificate not found")); + + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load( + new ByteArrayInputStream(cert.getKeystoreData()), + metadataEncryptionService.decrypt(cert.getKeystorePassword()).toCharArray()); + return keyStore; + } + + /** Get user's keystore password */ + @Transactional(readOnly = true) + public String getUserKeystorePassword(Long userId) { + UserServerCertificateEntity cert = + certificateRepository + .findByUserId(userId) + .orElseThrow( + () -> new IllegalArgumentException("User certificate not found")); + return metadataEncryptionService.decrypt(cert.getKeystorePassword()); + } + + /** Delete user certificate */ + @Transactional + public void deleteUserCertificate(Long userId) { + certificateRepository.findByUserId(userId).ifPresent(certificateRepository::delete); + } + + /** Check if user has certificate */ + @Transactional(readOnly = true) + public boolean hasUserCertificate(Long userId) { + return certificateRepository.findByUserId(userId).isPresent(); + } + + /** Get certificate info (without keystore data) */ + @Transactional(readOnly = true) + public Optional getCertificateInfo(Long userId) { + return certificateRepository.findByUserId(userId); + } + + /** Generate consistent password for user (based on user ID) */ + private String generateUserPassword(Long userId) { + return DEFAULT_PASSWORD_PREFIX + userId; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java new file mode 100644 index 0000000000..a8110b0cfd --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -0,0 +1,869 @@ +package stirling.software.proprietary.workflow.service; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.FilePurpose; +import stirling.software.proprietary.storage.model.ShareAccessRole; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.provider.StoredObject; +import stirling.software.proprietary.storage.repository.StoredFileRepository; +import stirling.software.proprietary.workflow.dto.ParticipantRequest; +import stirling.software.proprietary.workflow.dto.WetSignatureMetadata; +import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest; +import stirling.software.proprietary.workflow.model.ParticipantStatus; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.model.WorkflowStatus; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; +import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +/** + * Core service for workflow session management. Handles creation, participant management, and + * lifecycle coordination. + * + *

Delegates file storage to FileStorageService/StorageProvider and integrates with the file + * sharing infrastructure. + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class WorkflowSessionService { + + private final WorkflowSessionRepository workflowSessionRepository; + private final WorkflowParticipantRepository workflowParticipantRepository; + private final StoredFileRepository storedFileRepository; + private final UserRepository userRepository; + private final StorageProvider storageProvider; + private final ObjectMapper objectMapper; + private final ApplicationProperties applicationProperties; + private final MetadataEncryptionService metadataEncryptionService; + + public void ensureSigningEnabled() { + if (!applicationProperties.getStorage().isEnabled() + || !applicationProperties.getStorage().getSigning().isEnabled()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Group signing is disabled"); + } + } + + /** + * Creates a new workflow session with participants. Stores the original file using + * StorageProvider. + */ + public WorkflowSession createSession( + User owner, MultipartFile file, WorkflowCreationRequest request) throws IOException { + log.info( + "Creating workflow session for user {} with type {}", + owner.getUsername(), + request.getWorkflowType()); + + // Validate request + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File is required"); + } + + if (request.getWorkflowType() == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Workflow type is required"); + } + + // Store original file using StorageProvider + StoredFile originalFile = storeWorkflowFile(owner, file, FilePurpose.SIGNING_ORIGINAL); + + // Create workflow session + WorkflowSession session = new WorkflowSession(); + session.setSessionId(UUID.randomUUID().toString()); + session.setOwner(owner); + session.setWorkflowType(request.getWorkflowType()); + session.setDocumentName( + request.getDocumentName() != null + ? request.getDocumentName() + : file.getOriginalFilename()); + session.setOriginalFile(originalFile); + session.setOwnerEmail(request.getOwnerEmail()); + session.setMessage(request.getMessage()); + session.setDueDate(request.getDueDate()); + session.setStatus(WorkflowStatus.IN_PROGRESS); + + // Parse workflow metadata from JSON string to Map + if (request.getWorkflowMetadata() != null && !request.getWorkflowMetadata().isBlank()) { + try { + @SuppressWarnings("unchecked") + Map metadataMap = + objectMapper.readValue(request.getWorkflowMetadata(), Map.class); + session.setWorkflowMetadata(metadataMap); + } catch (JacksonException e) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Invalid workflowMetadata: must be a valid JSON object"); + } + } + + // Link file back to session + originalFile.setWorkflowSession(session); + originalFile.setPurpose(FilePurpose.SIGNING_ORIGINAL); + + session = workflowSessionRepository.save(session); + storedFileRepository.save(originalFile); + + // Add participants + List participants = new ArrayList<>(); + + if (request.getParticipantUserIds() != null) { + for (Long userId : request.getParticipantUserIds()) { + ParticipantRequest pr = new ParticipantRequest(); + pr.setUserId(userId); + pr.setAccessRole(ShareAccessRole.EDITOR); + participants.add(pr); + } + } + + if (request.getParticipantEmails() != null) { + for (String email : request.getParticipantEmails()) { + ParticipantRequest pr = new ParticipantRequest(); + pr.setEmail(email); + pr.setAccessRole(ShareAccessRole.EDITOR); + participants.add(pr); + } + } + + if (!participants.isEmpty()) { + addParticipantsToSession(session, participants); + } + + log.info( + "Created workflow session {} with {} participants", + session.getSessionId(), + session.getParticipants().size()); + return session; + } + + /** Adds participants to a workflow session. */ + private void addParticipantsToSession( + WorkflowSession session, List participantRequests) { + for (ParticipantRequest request : participantRequests) { + WorkflowParticipant participant = new WorkflowParticipant(); + participant.setShareToken(UUID.randomUUID().toString()); + participant.setAccessRole( + request.getAccessRole() != null + ? request.getAccessRole() + : ShareAccessRole.EDITOR); + participant.setExpiresAt(request.getExpiresAt()); + + // Parse participant metadata from JSON string to Map + if (request.getParticipantMetadata() != null + && !request.getParticipantMetadata().isBlank()) { + try { + @SuppressWarnings("unchecked") + Map metadataMap = + objectMapper.readValue(request.getParticipantMetadata(), Map.class); + participant.setParticipantMetadata(metadataMap); + } catch (JacksonException e) { + log.warn( + "Failed to parse participant metadata for {}, using empty map", + request.getEmail(), + e); + participant.setParticipantMetadata(new HashMap<>()); + } + } + + // Store defaultReason in participant metadata if provided + if (request.getDefaultReason() != null && !request.getDefaultReason().isBlank()) { + Map metadata = participant.getParticipantMetadata(); + if (metadata == null) { + metadata = new HashMap<>(); + } + metadata.put("defaultReason", request.getDefaultReason()); + participant.setParticipantMetadata(metadata); + log.debug( + "Set default reason for participant {}: {}", + request.getEmail(), + request.getDefaultReason()); + } + + participant.setStatus(ParticipantStatus.PENDING); + + // Set user or email + if (request.getUserId() != null) { + User user = + userRepository + .findById(request.getUserId()) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, + "User not found: " + request.getUserId())); + participant.setUser(user); + participant.setEmail(user.getUsername()); // User entity uses username, not email + participant.setName(user.getUsername()); + } else if (request.getEmail() != null) { + participant.setEmail(request.getEmail()); + participant.setName( + request.getName() != null ? request.getName() : request.getEmail()); + } else { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Participant must have either userId or email"); + } + + session.addParticipant(participant); + participant = workflowParticipantRepository.save(participant); + } + } + + /** Stores a file as part of a workflow using the StorageProvider. */ + private StoredFile storeWorkflowFile(User owner, MultipartFile file, FilePurpose purpose) + throws IOException { + // Store file content (storage provider generates the key) + StoredObject storedObject = storageProvider.store(owner, file); + + // Create StoredFile entity + StoredFile storedFile = new StoredFile(); + storedFile.setOwner(owner); + storedFile.setOriginalFilename(storedObject.getOriginalFilename()); + storedFile.setContentType(storedObject.getContentType()); + storedFile.setSizeBytes(storedObject.getSizeBytes()); + storedFile.setStorageKey(storedObject.getStorageKey()); + storedFile.setPurpose(purpose); + + return storedFileRepository.save(storedFile); + } + + /** Retrieves a workflow session by session ID. */ + @Transactional(readOnly = true) + public WorkflowSession getSession(String sessionId) { + return workflowSessionRepository + .findBySessionId(sessionId) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, + "Workflow session not found: " + sessionId)); + } + + /** Retrieves a workflow session with authorization check. */ + @Transactional(readOnly = true) + public WorkflowSession getSessionForOwner(String sessionId, User owner) { + WorkflowSession session = getSession(sessionId); + if (!session.getOwner().equals(owner)) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Not authorized to access this workflow session"); + } + return session; + } + + /** Retrieves a workflow session with participants eagerly loaded for finalization. */ + @Transactional(readOnly = true) + public WorkflowSession getSessionWithParticipants(String sessionId) { + return workflowSessionRepository + .findBySessionIdWithParticipants(sessionId) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, + "Workflow session not found: " + sessionId)); + } + + /** Retrieves a workflow session with participants, with authorization check. */ + @Transactional(readOnly = true) + public WorkflowSession getSessionWithParticipantsForOwner(String sessionId, User owner) { + WorkflowSession session = getSessionWithParticipants(sessionId); + if (!session.getOwner().equals(owner)) { + throw new ResponseStatusException( + HttpStatus.FORBIDDEN, "Not authorized to access this workflow session"); + } + return session; + } + + /** Lists all workflow sessions owned by a user. */ + @Transactional(readOnly = true) + public List listUserSessions(User owner) { + return workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(owner); + } + + /** Lists active workflow sessions for a user. */ + @Transactional(readOnly = true) + public List listActiveSessions(User owner) { + return workflowSessionRepository.findActiveSessionsByOwner(owner); + } + + /** Adds additional participants to an existing session. */ + @Transactional + public void addParticipants( + String sessionId, List participants, User owner) { + WorkflowSession session = getSessionForOwner(sessionId, owner); + + if (!session.isActive()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Cannot add participants to inactive workflow"); + } + + addParticipantsToSession(session, participants); + log.info("Added {} participants to session {}", participants.size(), sessionId); + } + + /** Removes a participant from a workflow session. */ + @Transactional + public void removeParticipant(String sessionId, Long participantId, User owner) { + WorkflowSession session = getSessionForOwner(sessionId, owner); + + WorkflowParticipant participant = + workflowParticipantRepository + .findById(participantId) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, + "Participant not found: " + participantId)); + + if (!participant.getWorkflowSession().equals(session)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Participant not in this workflow session"); + } + + session.removeParticipant(participant); + workflowParticipantRepository.delete(participant); + log.info("Removed participant {} from session {}", participantId, sessionId); + } + + /** Updates participant status (e.g., NOTIFIED, VIEWED, SIGNED). */ + public void updateParticipantStatus(Long participantId, ParticipantStatus newStatus) { + WorkflowParticipant participant = + workflowParticipantRepository + .findById(participantId) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, + "Participant not found: " + participantId)); + + participant.setStatus(newStatus); + workflowParticipantRepository.save(participant); + log.debug("Updated participant {} status to {}", participantId, newStatus); + } + + /** Adds a notification message to a participant's history. */ + public void addParticipantNotification(Long participantId, String message) { + WorkflowParticipant participant = + workflowParticipantRepository + .findById(participantId) + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, + "Participant not found: " + participantId)); + + String timestampedMessage = LocalDateTime.now().toString() + ": " + message; + participant.addNotification(timestampedMessage); + workflowParticipantRepository.save(participant); + } + + /** Stores the processed/finalized file for a workflow session. */ + public void storeProcessedFile(WorkflowSession session, byte[] fileData, String filename) + throws IOException { + log.info("Storing processed file for session {}", session.getSessionId()); + + // Create a temporary multipart file wrapper + MultipartFile processedFile = new ByteArrayMultipartFile(fileData, filename); + + // Store using StorageProvider + StoredFile storedFile = + storeWorkflowFile(session.getOwner(), processedFile, FilePurpose.SIGNING_SIGNED); + + // Link to session + storedFile.setWorkflowSession(session); + session.setProcessedFile(storedFile); + + storedFileRepository.save(storedFile); + workflowSessionRepository.save(session); + } + + /** Marks a workflow session as finalized. */ + public void finalizeSession(String sessionId, User owner) { + WorkflowSession session = getSessionForOwner(sessionId, owner); + + if (session.isFinalized()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Workflow session already finalized"); + } + + session.setFinalized(true); + session.setStatus(WorkflowStatus.COMPLETED); + workflowSessionRepository.save(session); + + log.info("Finalized workflow session {}", sessionId); + } + + /** Retrieves the processed file data for a workflow session. */ + @Transactional(readOnly = true) + public byte[] getProcessedFile(String sessionId, User owner) throws IOException { + WorkflowSession session = getSessionForOwner(sessionId, owner); + + if (session.getProcessedFile() == null) { + throw new ResponseStatusException( + HttpStatus.NOT_FOUND, "No processed file available for this session"); + } + + String storageKey = session.getProcessedFile().getStorageKey(); + org.springframework.core.io.Resource resource = storageProvider.load(storageKey); + return resource.getContentAsByteArray(); + } + + /** Retrieves the original file data for a workflow session. */ + @Transactional(readOnly = true) + public byte[] getOriginalFile(String sessionId) throws IOException { + WorkflowSession session = getSession(sessionId); + if (session.getOriginalFile() == null) { + throw new ResponseStatusException( + HttpStatus.NOT_FOUND, + "Original file no longer available (session may be finalized)"); + } + String storageKey = session.getOriginalFile().getStorageKey(); + org.springframework.core.io.Resource resource = storageProvider.load(storageKey); + return resource.getContentAsByteArray(); + } + + /** Deletes a workflow session and associated files. */ + @Transactional + public void deleteSession(String sessionId, User owner) { + WorkflowSession session = getSessionForOwner(sessionId, owner); + + if (session.isFinalized()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Cannot delete a finalized session. The signed PDF remains accessible from your session history."); + } + + // Delete physical storage files (non-fatal; may already be absent) + try { + if (session.getOriginalFile() != null) { + storageProvider.delete(session.getOriginalFile().getStorageKey()); + } + if (session.getProcessedFile() != null) { + storageProvider.delete(session.getProcessedFile().getStorageKey()); + } + } catch (Exception e) { + log.error("Error deleting files for session {}", sessionId, e); + } + + // Clear only the StoredFile → WorkflowSession back-reference before deleting. + // + // We do NOT null session.originalFile here because that would emit an UPDATE with + // original_file_id=NULL, violating the NOT NULL constraint. There is no need to — a + // DELETE statement removes the row entirely, so the NOT NULL constraint never triggers. + // + // We DO null StoredFile.workflowSession (workflow_session_id IS nullable) so that + // Hibernate does not see a persistent StoredFile referencing a "removed" WorkflowSession + // during flush, which would throw TransientPropertyValueException. + StoredFile originalFile = session.getOriginalFile(); + StoredFile processedFile = session.getProcessedFile(); + if (originalFile != null) { + originalFile.setWorkflowSession(null); + storedFileRepository.save(originalFile); + } + if (processedFile != null) { + processedFile.setWorkflowSession(null); + storedFileRepository.save(processedFile); + } + + // Delete the session row. Cascades to WorkflowParticipant via orphanRemoval=true. + workflowSessionRepository.delete(session); + + // StoredFile rows can now be deleted — the workflow_sessions FK is gone. + if (originalFile != null) storedFileRepository.delete(originalFile); + if (processedFile != null) storedFileRepository.delete(processedFile); + log.info("Deleted workflow session {}", sessionId); + } + + /** + * Deletes the original (presigned) file from storage after finalization. The original file is + * no longer needed once the signed document has been stored. Non-fatal: logs errors but does + * not fail finalization. + */ + public void deleteOriginalFile(WorkflowSession session) { + if (session.getOriginalFile() == null) { + return; + } + try { + storageProvider.delete(session.getOriginalFile().getStorageKey()); + StoredFile originalFile = session.getOriginalFile(); + session.setOriginalFile(null); + workflowSessionRepository.save(session); + storedFileRepository.delete(originalFile); + log.info("Deleted original presigned file for session {}", session.getSessionId()); + } catch (Exception e) { + log.error( + "Failed to delete original file for session {}: {}", + session.getSessionId(), + e.getMessage()); + } + } + + // ===== SIGN REQUEST METHODS (Participant View) ===== + + /** + * List all sign requests where the user is a participant. + * + * @param user The participant user + * @return List of sign request summaries + */ + @Transactional(readOnly = true) + public List listSignRequests( + User user) { + List participations = + workflowParticipantRepository.findByUserOrderByLastUpdatedDesc(user); + + return participations.stream() + .map( + p -> { + WorkflowSession session = p.getWorkflowSession(); + stirling.software.proprietary.workflow.dto.SignRequestSummaryDTO dto = + new stirling.software.proprietary.workflow.dto + .SignRequestSummaryDTO(); + dto.setSessionId(session.getSessionId()); + dto.setDocumentName(session.getDocumentName()); + dto.setOwnerUsername(session.getOwner().getUsername()); + dto.setCreatedAt(session.getCreatedAt().toString()); + dto.setDueDate( + session.getDueDate() != null + ? session.getDueDate().toString() + : null); + dto.setMyStatus(p.getStatus()); + return dto; + }) + .collect(java.util.stream.Collectors.toList()); + } + + /** + * Get detailed information about a sign request. + * + * @param sessionId The session ID + * @param user The participant user + * @return Sign request detail + */ + @Transactional(readOnly = true) + public stirling.software.proprietary.workflow.dto.SignRequestDetailDTO getSignRequestDetail( + String sessionId, User user) { + WorkflowSession session = getSession(sessionId); + WorkflowParticipant participant = getParticipantForUser(session, user); + + stirling.software.proprietary.workflow.dto.SignRequestDetailDTO dto = + new stirling.software.proprietary.workflow.dto.SignRequestDetailDTO(); + dto.setSessionId(session.getSessionId()); + dto.setDocumentName(session.getDocumentName()); + dto.setOwnerUsername(session.getOwner().getUsername()); + dto.setMessage(session.getMessage()); + dto.setDueDate(session.getDueDate()); + dto.setCreatedAt(session.getCreatedAt().toString()); + dto.setMyStatus(participant.getStatus()); + + // Load signature appearance settings from workflow metadata + Map metadata = session.getWorkflowMetadata(); + if (metadata != null && !metadata.isEmpty()) { + dto.setShowSignature( + metadata.containsKey("showSignature") + ? (Boolean) metadata.get("showSignature") + : false); + dto.setPageNumber( + metadata.containsKey("pageNumber") + ? ((Number) metadata.get("pageNumber")).intValue() + : null); + dto.setReason(metadata.containsKey("reason") ? (String) metadata.get("reason") : null); + dto.setLocation( + metadata.containsKey("location") ? (String) metadata.get("location") : null); + dto.setShowLogo( + metadata.containsKey("showLogo") ? (Boolean) metadata.get("showLogo") : false); + } else { + // Default values if no metadata + dto.setShowSignature(false); + dto.setPageNumber(null); + dto.setReason(null); + dto.setLocation(null); + dto.setShowLogo(false); + } + + // Update status to VIEWED if it was NOTIFIED + if (participant.getStatus() == ParticipantStatus.NOTIFIED) { + participant.setStatus(ParticipantStatus.VIEWED); + workflowParticipantRepository.save(participant); + } + + return dto; + } + + /** + * Get the document for a sign request. + * + *

After finalization, returns the signed document. Before finalization, returns the + * original. + * + * @param sessionId The session ID + * @param user The participant user + * @return PDF document bytes + */ + @Transactional(readOnly = true) + public byte[] getSignRequestDocument(String sessionId, User user) { + WorkflowSession session = getSession(sessionId); + getParticipantForUser(session, user); // Verify participant access + + // After finalization, serve the signed document instead of the original + StoredFile fileToServe = + (session.isFinalized() && session.getProcessedFile() != null) + ? session.getProcessedFile() + : session.getOriginalFile(); + + if (fileToServe == null) { + throw new ResponseStatusException( + HttpStatus.NOT_FOUND, "Document not available for this session"); + } + + try { + org.springframework.core.io.Resource resource = + storageProvider.load(fileToServe.getStorageKey()); + return resource.getContentAsByteArray(); + } catch (IOException e) { + log.error("Failed to retrieve document for session {}", sessionId, e); + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, "Failed to retrieve document"); + } + } + + /** + * Sign a document in a workflow session. + * + * @param sessionId The session ID + * @param user The participant user + * @param request Sign document request with certificate and optional wet signature + */ + public void signDocument( + String sessionId, + User user, + stirling.software.proprietary.workflow.dto.SignDocumentRequest request) { + WorkflowSession session = getSession(sessionId); + WorkflowParticipant participant = getParticipantForUser(session, user); + + if (participant.getStatus() == ParticipantStatus.SIGNED) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Document already signed by this user"); + } + + if (participant.getStatus() == ParticipantStatus.DECLINED) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Cannot sign after declining"); + } + + // Build metadata JSON containing certificate submission and wet signature data + // Merge with existing metadata if present (preserves owner-configured appearance + // settings) + Map metadata = new HashMap<>(); + + // Get existing metadata if present + Map existingMetadata = participant.getParticipantMetadata(); + if (existingMetadata != null && !existingMetadata.isEmpty()) { + metadata = new HashMap<>(existingMetadata); + } + + // 1. Store certificate submission data + Map certSubmission = new HashMap<>(); + certSubmission.put("certType", request.getCertType()); + certSubmission.put("password", metadataEncryptionService.encrypt(request.getPassword())); + + // Store keystore files as base64 if provided + if (request.getP12File() != null && !request.getP12File().isEmpty()) { + try { + byte[] keystoreBytes = request.getP12File().getBytes(); + String base64Keystore = java.util.Base64.getEncoder().encodeToString(keystoreBytes); + certSubmission.put("p12Keystore", base64Keystore); + } catch (IOException e) { + log.error("Failed to read P12 keystore file", e); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Failed to process certificate file"); + } + } + + // Note: Signature appearance settings (showSignature, pageNumber, location, reason, + // showLogo) + // may already be in metadata if owner configured them when adding participant. + // If not present, the finalization process will use defaults. + + metadata.put("certificateSubmission", certSubmission); + + // 2. Parse wet signatures from JSON string if provided + if (request.getWetSignaturesData() != null && !request.getWetSignaturesData().isBlank()) { + try { + List wetSigs = + objectMapper.readValue( + request.getWetSignaturesData(), + new TypeReference>() {}); + if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Too many wet signatures submitted"); + } + request.setWetSignatures(wetSigs); + log.info("Parsed {} wet signatures from wetSignaturesData", wetSigs.size()); + } catch (JacksonException e) { + log.error("Failed to parse wetSignaturesData: {}", e.getMessage()); + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Invalid wet signatures data"); + } + } + + // 3. Store wet signatures metadata if provided (supports multiple signatures) + if (request.hasWetSignatures()) { + List wetSigs = request.extractWetSignatureMetadata(); + List> wetSignatures = new ArrayList<>(); + + for (WetSignatureMetadata wetSig : wetSigs) { + Map wetSignature = new HashMap<>(); + wetSignature.put("type", wetSig.getType()); + wetSignature.put("data", wetSig.getData()); + wetSignature.put("page", wetSig.getPage()); + wetSignature.put("x", wetSig.getX()); + wetSignature.put("y", wetSig.getY()); + wetSignature.put("width", wetSig.getWidth()); + wetSignature.put("height", wetSig.getHeight()); + wetSignatures.add(wetSignature); + } + + // Always store as array + metadata.put("wetSignatures", wetSignatures); + + log.info( + "Stored {} wet signature(s) metadata for participant {}", + wetSignatures.size(), + user.getUsername()); + } + + // 4. Store metadata in participant (JPA converter handles JSON serialization) + participant.setParticipantMetadata(metadata); + log.info( + "Stored signature metadata for participant ID {}, email {}: {} wet signatures, cert type: {}", + participant.getId(), + user.getUsername(), + metadata.containsKey("wetSignatures") + ? ((List) metadata.get("wetSignatures")).size() + : 0, + ((Map) metadata.get("certificateSubmission")).get("certType")); + + // 5. Update participant status + participant.setStatus(ParticipantStatus.SIGNED); + workflowParticipantRepository.save(participant); + + log.info( + "User {} signed document in session {} - certificate and signature data stored", + user.getUsername(), + sessionId); + } + + /** + * Decline a sign request. + * + * @param sessionId The session ID + * @param user The participant user + */ + public void declineSignRequest(String sessionId, User user) { + WorkflowSession session = getSession(sessionId); + WorkflowParticipant participant = getParticipantForUser(session, user); + + if (participant.getStatus() == ParticipantStatus.SIGNED) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Cannot decline after signing"); + } + + participant.setStatus(ParticipantStatus.DECLINED); + workflowParticipantRepository.save(participant); // updatedAt is auto-updated + + log.info("User {} declined sign request for session {}", user.getUsername(), sessionId); + } + + /** + * Get participant record for a user in a session. + * + * @param session The workflow session + * @param user The user + * @return Participant record + * @throws ResponseStatusException if user is not a participant + */ + private WorkflowParticipant getParticipantForUser(WorkflowSession session, User user) { + return session.getParticipants().stream() + .filter(p -> p.getUser() != null && p.getUser().equals(user)) + .findFirst() + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.FORBIDDEN, + "User is not a participant in this session")); + } + + /** Helper class to wrap byte array as MultipartFile. */ + private static class ByteArrayMultipartFile implements MultipartFile { + private final byte[] content; + private final String filename; + + public ByteArrayMultipartFile(byte[] content, String filename) { + this.content = content; + this.filename = filename; + } + + @Override + public String getName() { + return "file"; + } + + @Override + public String getOriginalFilename() { + return filename; + } + + @Override + public String getContentType() { + return "application/pdf"; + } + + @Override + public boolean isEmpty() { + return content == null || content.length == 0; + } + + @Override + public long getSize() { + return content.length; + } + + @Override + public byte[] getBytes() { + return content; + } + + @Override + public java.io.InputStream getInputStream() { + return new java.io.ByteArrayInputStream(content); + } + + @Override + public void transferTo(java.io.File dest) throws IOException { + java.nio.file.Files.write(dest.toPath(), content); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java new file mode 100644 index 0000000000..82cc5bd198 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java @@ -0,0 +1,169 @@ +package stirling.software.proprietary.workflow.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import stirling.software.proprietary.workflow.dto.ParticipantResponse; +import stirling.software.proprietary.workflow.dto.WetSignatureMetadata; +import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.model.WorkflowSession; + +/** + * Utility class for mapping workflow entities to DTOs. Centralizes conversion logic for consistent + * API responses. + */ +public class WorkflowMapper { + + /** Converts a WorkflowSession entity to a response DTO. */ + public static WorkflowSessionResponse toResponse(WorkflowSession session) { + return toResponse(session, null); + } + + /** + * Converts a WorkflowSession entity to a response DTO with optional wet signature extraction. + * + * @param session The workflow session entity + * @param objectMapper ObjectMapper for JSON processing (null to skip wet signature extraction) + * @return WorkflowSessionResponse with participants (and wet signatures if objectMapper + * provided) + */ + public static WorkflowSessionResponse toResponse( + WorkflowSession session, ObjectMapper objectMapper) { + if (session == null) { + return null; + } + + WorkflowSessionResponse response = new WorkflowSessionResponse(); + response.setSessionId(session.getSessionId()); + response.setOwnerId(session.getOwner().getId()); + response.setOwnerUsername(session.getOwner().getUsername()); + response.setWorkflowType(session.getWorkflowType()); + response.setDocumentName(session.getDocumentName()); + response.setOwnerEmail(session.getOwnerEmail()); + response.setMessage(session.getMessage()); + response.setDueDate(session.getDueDate()); + response.setStatus(session.getStatus()); + response.setFinalized(session.isFinalized()); + response.setCreatedAt(session.getCreatedAt()); + response.setUpdatedAt(session.getUpdatedAt()); + response.setHasProcessedFile(session.hasProcessedFile()); + + if (session.getOriginalFile() != null) { + response.setOriginalFileId(session.getOriginalFile().getId()); + } + if (session.getProcessedFile() != null) { + response.setProcessedFileId(session.getProcessedFile().getId()); + } + + // Convert participants (with wet signatures if objectMapper provided) + if (objectMapper != null) { + response.setParticipants( + session.getParticipants().stream() + .map(p -> toParticipantResponse(p, objectMapper)) + .collect(Collectors.toList())); + } else { + response.setParticipants( + session.getParticipants().stream() + .map(WorkflowMapper::toParticipantResponse) + .collect(Collectors.toList())); + } + + // Calculate participant counts + response.setParticipantCount(session.getParticipants().size()); + response.setSignedCount( + (int) + session.getParticipants().stream() + .filter( + p -> + p.getStatus() + == stirling.software.proprietary.workflow + .model.ParticipantStatus.SIGNED) + .count()); + + return response; + } + + /** Converts a WorkflowParticipant entity to a response DTO. */ + public static ParticipantResponse toParticipantResponse(WorkflowParticipant participant) { + if (participant == null) { + return null; + } + + ParticipantResponse response = new ParticipantResponse(); + response.setId(participant.getId()); + if (participant.getUser() != null) { + response.setUserId(participant.getUser().getId()); + } + response.setEmail(participant.getEmail()); + response.setName(participant.getName()); + response.setStatus(participant.getStatus()); + response.setShareToken(participant.getShareToken()); + response.setAccessRole(participant.getAccessRole()); + response.setExpiresAt(participant.getExpiresAt()); + response.setLastUpdated(participant.getLastUpdated()); + response.setHasCompleted(participant.hasCompleted()); + response.setExpired( + participant.isExpired()); // Lombok generates setExpired() for isExpired field + + return response; + } + + /** + * Converts a WorkflowParticipant entity to a response DTO with wet signatures extracted. + * + * @param participant The participant entity + * @param objectMapper ObjectMapper for JSON processing + * @return ParticipantResponse with wet signatures included + */ + public static ParticipantResponse toParticipantResponse( + WorkflowParticipant participant, ObjectMapper objectMapper) { + ParticipantResponse response = toParticipantResponse(participant); + if (response != null) { + response.setWetSignatures(extractWetSignatures(participant, objectMapper)); + } + return response; + } + + /** + * Extracts wet signature metadata from a participant's metadata JSON field. + * + * @param participant The participant entity + * @param objectMapper ObjectMapper for JSON processing + * @return List of wet signatures, empty if none found + */ + private static List extractWetSignatures( + WorkflowParticipant participant, ObjectMapper objectMapper) { + List signatures = new ArrayList<>(); + + Map metadata = participant.getParticipantMetadata(); + if (metadata == null || metadata.isEmpty() || !metadata.containsKey("wetSignatures")) { + return signatures; + } + + try { + // Convert metadata to JsonNode for processing + var node = objectMapper.valueToTree(metadata); + if (node.has("wetSignatures")) { + var wetSigsNode = node.get("wetSignatures"); + if (wetSigsNode.isArray()) { + for (var wetSigNode : wetSigsNode) { + WetSignatureMetadata wetSig = + objectMapper.treeToValue(wetSigNode, WetSignatureMetadata.class); + signatures.add(wetSig); + } + } + } + } catch (Exception e) { + // Log error but don't fail the entire response + // In production, you might want to use a logger here + return signatures; + } + + return signatures; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java index 5b4575e648..713fd4b9c3 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java @@ -6,7 +6,10 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.*; import java.sql.SQLException; +import java.util.HashSet; +import java.util.List; import java.util.Optional; +import java.util.Set; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -23,11 +26,23 @@ import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.AuthorityRepository; +import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.AuthenticationType; +import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.session.SessionPersistentRegistry; +import stirling.software.proprietary.storage.model.FileShare; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.repository.FileShareAccessRepository; +import stirling.software.proprietary.storage.repository.FileShareRepository; +import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; +import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository; +import stirling.software.proprietary.workflow.service.UserServerCertificateService; @ExtendWith(MockitoExtension.class) class UserServiceTest { @@ -40,6 +55,14 @@ class UserServiceTest { @Mock private SessionPersistentRegistry sessionRegistry; @Mock private DatabaseServiceInterface databaseService; @Mock private ApplicationProperties.Security.OAUTH2 oAuth2; + @Mock private PersistentLoginRepository persistentLoginRepository; + @Mock private UserServerCertificateService userServerCertificateService; + @Mock private WorkflowParticipantRepository workflowParticipantRepository; + @Mock private WorkflowSessionRepository workflowSessionRepository; + @Mock private StoredFileRepository storedFileRepository; + @Mock private StorageCleanupEntryRepository storageCleanupEntryRepository; + @Mock private FileShareRepository fileShareRepository; + @Mock private FileShareAccessRepository fileShareAccessRepository; @Spy @InjectMocks private UserService userService; @@ -185,4 +208,86 @@ class UserServiceTest { assertFalse(userService.isUsernameValid("ALL_USERS")); assertTrue(userService.isUsernameValid("valid@example.com")); } + + @Test + void deleteUser_withRelatedData_cleansUpInCorrectOrder() { + User user = new User(); + user.setId(1L); + user.setUsername("target"); + + FileShare share = new FileShare(); + StoredFile ownedFile = new StoredFile(); + ownedFile.setOwner(user); + ownedFile.setStorageKey("key-main"); + ownedFile.setHistoryStorageKey("key-history"); + Set shares = new HashSet<>(); + shares.add(share); + ownedFile.setShares(shares); + + WorkflowSession session = new WorkflowSession(); + session.setOwner(user); + + FileShare inboundShare = new FileShare(); + when(userRepository.findByUsernameIgnoreCase("target")).thenReturn(Optional.of(user)); + when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user)) + .thenReturn(List.of(session)); + when(storedFileRepository.findAllByOwner(user)).thenReturn(List.of(ownedFile)); + when(fileShareRepository.findBySharedWithUser(user)).thenReturn(List.of(inboundShare)); + + userService.deleteUser("target"); + + verify(userServerCertificateService).deleteUserCertificate(1L); + verify(fileShareAccessRepository).deleteByUser(user); + // Inbound share (file shared with this user by others) cleaned up + verify(fileShareAccessRepository).deleteByFileShare(inboundShare); + verify(fileShareRepository).deleteAll(List.of(inboundShare)); + // Participant records in others' sessions de-linked (not deleted) to preserve audit trail + verify(workflowParticipantRepository).clearUserReferences(user); + verify(storedFileRepository).clearWorkflowSessionReferencesByOwner(user); + verify(workflowSessionRepository).deleteAll(List.of(session)); + verify(fileShareAccessRepository).deleteByFileShare(share); + verify(storedFileRepository).deleteAll(List.of(ownedFile)); + verify(userRepository).delete(user); + // Persistent login (remember-me) tokens revoked + verify(persistentLoginRepository).deleteByUsername("target"); + // Storage blobs scheduled for physical deletion + verify(storageCleanupEntryRepository, times(2)).save(any()); + verify(userService).invalidateUserSessions("target"); + } + + @Test + void deleteUser_withNoRelatedData_deletesUserSuccessfully() { + User user = new User(); + user.setId(2L); + user.setUsername("clean"); + + when(userRepository.findByUsernameIgnoreCase("clean")).thenReturn(Optional.of(user)); + when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user)).thenReturn(List.of()); + when(storedFileRepository.findAllByOwner(user)).thenReturn(List.of()); + when(fileShareRepository.findBySharedWithUser(user)).thenReturn(List.of()); + + userService.deleteUser("clean"); + + verify(userRepository).delete(user); + verify(fileShareAccessRepository, never()).deleteByFileShare(any()); + verify(workflowSessionRepository).deleteAll(List.of()); + verify(storedFileRepository).deleteAll(List.of()); + } + + @Test + void deleteUser_internalApiUser_isNotDeleted() { + Authority internalAuth = new Authority(); + internalAuth.setAuthority(Role.INTERNAL_API_USER.getRoleId()); + User user = new User(); + user.setId(3L); + user.setUsername("internal"); + user.getAuthorities().add(internalAuth); + + when(userRepository.findByUsernameIgnoreCase("internal")).thenReturn(Optional.of(user)); + + userService.deleteUser("internal"); + + verify(userRepository, never()).delete(any()); + verify(workflowSessionRepository, never()).findByOwnerOrderByCreatedAtDesc(any()); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/converter/JsonMapConverterTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/converter/JsonMapConverterTest.java new file mode 100644 index 0000000000..53575fba12 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/converter/JsonMapConverterTest.java @@ -0,0 +1,97 @@ +package stirling.software.proprietary.storage.converter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class JsonMapConverterTest { + + private final JsonMapConverter converter = new JsonMapConverter(); + + // ------------------------------------------------------------------------- + // convertToDatabaseColumn + // ------------------------------------------------------------------------- + + @Test + void convertToDatabaseColumn_nullMap_returnsNull() { + assertThat(converter.convertToDatabaseColumn(null)).isNull(); + } + + @Test + void convertToDatabaseColumn_emptyMap_returnsNull() { + assertThat(converter.convertToDatabaseColumn(Map.of())).isNull(); + } + + @Test + void convertToDatabaseColumn_singleEntry_producesValidJson() { + String json = converter.convertToDatabaseColumn(Map.of("key", "value")); + assertThat(json).contains("\"key\"").contains("\"value\""); + } + + @Test + void convertToDatabaseColumn_mapWithMixedTypes_roundTrips() { + Map input = Map.of("str", "hello", "num", 42); + String json = converter.convertToDatabaseColumn(input); + Map result = converter.convertToEntityAttribute(json); + assertThat(result.get("str")).isEqualTo("hello"); + assertThat(result.get("num")).isEqualTo(42); + } + + // ------------------------------------------------------------------------- + // convertToEntityAttribute — normal paths + // ------------------------------------------------------------------------- + + @Test + void convertToEntityAttribute_nullInput_returnsEmptyMap() { + assertThat(converter.convertToEntityAttribute(null)).isEmpty(); + } + + @Test + void convertToEntityAttribute_blankInput_returnsEmptyMap() { + assertThat(converter.convertToEntityAttribute(" ")).isEmpty(); + } + + @Test + void convertToEntityAttribute_validJson_restoresMap() { + Map result = converter.convertToEntityAttribute("{\"foo\":\"bar\"}"); + assertThat(result).containsEntry("foo", "bar"); + } + + @Test + void convertToEntityAttribute_nestedObject_preservesStructure() { + String json = "{\"outer\":{\"inner\":\"value\"}}"; + Map result = converter.convertToEntityAttribute(json); + assertThat(result).containsKey("outer"); + } + + // ------------------------------------------------------------------------- + // convertToEntityAttribute — legacy double-encoded fallback + // ------------------------------------------------------------------------- + + @Test + void convertToEntityAttribute_doubleEncodedJson_fallbackRecovery() { + // A JSON string node whose text content is itself valid JSON + String doubleEncoded = "\"{\\\"foo\\\":\\\"bar\\\"}\""; + Map result = converter.convertToEntityAttribute(doubleEncoded); + assertThat(result).containsEntry("foo", "bar"); + } + + // ------------------------------------------------------------------------- + // convertToEntityAttribute — malformed input + // ------------------------------------------------------------------------- + + @Test + void convertToEntityAttribute_completelyMalformed_returnsEmptyMap() { + Map result = converter.convertToEntityAttribute("not-json-at-all"); + assertThat(result).isEmpty(); + } + + @Test + void convertToEntityAttribute_malformedJson_doesNotThrow() { + assertThatCode(() -> converter.convertToEntityAttribute("{broken")) + .doesNotThrowAnyException(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FileStorageServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FileStorageServiceTest.java new file mode 100644 index 0000000000..83d9111981 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/storage/service/FileStorageServiceTest.java @@ -0,0 +1,573 @@ +package stirling.software.proprietary.storage.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.FileShare; +import stirling.software.proprietary.storage.model.ShareAccessRole; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.provider.StoredObject; +import stirling.software.proprietary.storage.repository.FileShareAccessRepository; +import stirling.software.proprietary.storage.repository.FileShareRepository; +import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository; +import stirling.software.proprietary.storage.repository.StoredFileRepository; +import stirling.software.proprietary.workflow.model.WorkflowSession; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class FileStorageServiceTest { + + @Mock private StoredFileRepository storedFileRepository; + @Mock private FileShareRepository fileShareRepository; + @Mock private FileShareAccessRepository fileShareAccessRepository; + @Mock private UserRepository userRepository; + @Mock private ApplicationProperties applicationProperties; + @Mock private StorageProvider storageProvider; + @Mock private StorageCleanupEntryRepository storageCleanupEntryRepository; + + @Mock private ApplicationProperties.Security securityProperties; + @Mock private ApplicationProperties.System systemProperties; + @Mock private ApplicationProperties.Storage storageProperties; + @Mock private ApplicationProperties.Storage.Sharing sharingProperties; + @Mock private ApplicationProperties.Storage.Quotas quotasProperties; + + private FileStorageService service; + + @BeforeEach + void setUp() { + service = + new FileStorageService( + storedFileRepository, + fileShareRepository, + fileShareAccessRepository, + userRepository, + applicationProperties, + storageProvider, + Optional.empty(), + storageCleanupEntryRepository); + + // Default: storage and sharing fully enabled, share links enabled, no expiry + when(applicationProperties.getSecurity()).thenReturn(securityProperties); + when(securityProperties.isEnableLogin()).thenReturn(true); + when(applicationProperties.getStorage()).thenReturn(storageProperties); + when(storageProperties.isEnabled()).thenReturn(true); + when(storageProperties.getSharing()).thenReturn(sharingProperties); + when(sharingProperties.isEnabled()).thenReturn(true); + when(sharingProperties.isLinkEnabled()).thenReturn(true); + when(sharingProperties.getLinkExpirationDays()).thenReturn(0); + when(applicationProperties.getSystem()).thenReturn(systemProperties); + when(systemProperties.getFrontendUrl()).thenReturn("http://localhost:8080"); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private User user(long id) { + User u = new User(); + u.setId(id); + u.setUsername("user" + id); + return u; + } + + private StoredFile ownedFile(User owner) { + StoredFile f = new StoredFile(); + f.setId(100L); + f.setOwner(owner); + f.setOriginalFilename("test.pdf"); + return f; + } + + private FileShare shareFor(StoredFile file, User user, ShareAccessRole role) { + FileShare s = new FileShare(); + s.setFile(file); + s.setSharedWithUser(user); + s.setAccessRole(role); + return s; + } + + // ------------------------------------------------------------------------- + // getAccessibleFile + // ------------------------------------------------------------------------- + + @Test + void getAccessibleFile_owner_returnsFile() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(f)); + + assertThat(service.getAccessibleFile(owner, 100L)).isSameAs(f); + } + + @Test + void getAccessibleFile_sharedUser_returnsFile() { + User owner = user(1L); + User requester = user(2L); + StoredFile f = ownedFile(owner); + f.getShares().add(shareFor(f, requester, ShareAccessRole.VIEWER)); + when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(f)); + + assertThat(service.getAccessibleFile(requester, 100L)).isSameAs(f); + } + + @Test + void getAccessibleFile_noAccess_throwsForbidden() { + User owner = user(1L); + User requester = user(2L); + StoredFile f = ownedFile(owner); + when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(f)); + + assertThatThrownBy(() -> service.getAccessibleFile(requester, 100L)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + @Test + void getAccessibleFile_fileNotFound_throwsNotFound() { + User owner = user(1L); + when(storedFileRepository.findByIdWithShares(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getAccessibleFile(owner, 999L)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404); + } + + // ------------------------------------------------------------------------- + // requireEditorAccess + // ------------------------------------------------------------------------- + + @Test + void requireEditorAccess_owner_passes() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + service.requireEditorAccess(owner, f); + } + + @Test + void requireEditorAccess_editorShare_passes() { + User owner = user(1L); + User requester = user(2L); + StoredFile f = ownedFile(owner); + FileShare share = shareFor(f, requester, ShareAccessRole.EDITOR); + when(fileShareRepository.findByFileAndSharedWithUser(f, requester)) + .thenReturn(Optional.of(share)); + + service.requireEditorAccess(requester, f); + } + + @Test + void requireEditorAccess_viewerShare_throwsForbidden() { + User owner = user(1L); + User requester = user(2L); + StoredFile f = ownedFile(owner); + FileShare share = shareFor(f, requester, ShareAccessRole.VIEWER); + when(fileShareRepository.findByFileAndSharedWithUser(f, requester)) + .thenReturn(Optional.of(share)); + + assertThatThrownBy(() -> service.requireEditorAccess(requester, f)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + @Test + void requireEditorAccess_noShare_throwsForbidden() { + User owner = user(1L); + User requester = user(2L); + StoredFile f = ownedFile(owner); + when(fileShareRepository.findByFileAndSharedWithUser(f, requester)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.requireEditorAccess(requester, f)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + // ------------------------------------------------------------------------- + // requireReadAccess + // ------------------------------------------------------------------------- + + @Test + void requireReadAccess_owner_passes() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + service.requireReadAccess(owner, f); + } + + @Test + void requireReadAccess_viewerShare_passes() { + User owner = user(1L); + User requester = user(2L); + StoredFile f = ownedFile(owner); + FileShare share = shareFor(f, requester, ShareAccessRole.VIEWER); + when(fileShareRepository.findByFileAndSharedWithUser(f, requester)) + .thenReturn(Optional.of(share)); + + service.requireReadAccess(requester, f); + } + + // ------------------------------------------------------------------------- + // shareWithUser + // ------------------------------------------------------------------------- + + @Test + void shareWithUser_newShare_created() { + User owner = user(1L); + User target = user(2L); + StoredFile f = ownedFile(owner); + when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target)); + when(fileShareRepository.findByFileAndSharedWithUser(f, target)) + .thenReturn(Optional.empty()); + when(fileShareRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + FileShare result = service.shareWithUser(owner, f, "user2", ShareAccessRole.VIEWER); + + assertThat(result.getSharedWithUser()).isEqualTo(target); + assertThat(result.getAccessRole()).isEqualTo(ShareAccessRole.VIEWER); + verify(fileShareRepository).save(any(FileShare.class)); + } + + @Test + void shareWithUser_existingShare_updatesRole() { + User owner = user(1L); + User target = user(2L); + StoredFile f = ownedFile(owner); + FileShare existing = shareFor(f, target, ShareAccessRole.VIEWER); + when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target)); + when(fileShareRepository.findByFileAndSharedWithUser(f, target)) + .thenReturn(Optional.of(existing)); + when(fileShareRepository.save(existing)).thenReturn(existing); + + service.shareWithUser(owner, f, "user2", ShareAccessRole.EDITOR); + + assertThat(existing.getAccessRole()).isEqualTo(ShareAccessRole.EDITOR); + verify(fileShareRepository).save(existing); + } + + @Test + void shareWithUser_selfShare_throwsBadRequest() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + when(userRepository.findByUsernameIgnoreCase("user1")).thenReturn(Optional.of(owner)); + + assertThatThrownBy(() -> service.shareWithUser(owner, f, "user1", ShareAccessRole.VIEWER)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + + @Test + void shareWithUser_nonOwner_throwsForbidden() { + User owner = user(1L); + User nonOwner = user(2L); + StoredFile f = ownedFile(owner); + + assertThatThrownBy( + () -> service.shareWithUser(nonOwner, f, "user1", ShareAccessRole.VIEWER)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + // ------------------------------------------------------------------------- + // revokeUserShare + // ------------------------------------------------------------------------- + + @Test + void revokeUserShare_owner_removesShare() { + User owner = user(1L); + User target = user(2L); + StoredFile f = ownedFile(owner); + FileShare share = shareFor(f, target, ShareAccessRole.VIEWER); + when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target)); + when(fileShareRepository.findByFileAndSharedWithUser(f, target)) + .thenReturn(Optional.of(share)); + + service.revokeUserShare(owner, f, "user2"); + + verify(fileShareRepository).delete(share); + } + + @Test + void revokeUserShare_shareNotFound_silentSuccess() { + User owner = user(1L); + User target = user(2L); + StoredFile f = ownedFile(owner); + when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target)); + when(fileShareRepository.findByFileAndSharedWithUser(f, target)) + .thenReturn(Optional.empty()); + + service.revokeUserShare(owner, f, "user2"); + + verify(fileShareRepository, never()).delete(any()); + } + + @Test + void revokeUserShare_nonOwner_throwsForbidden() { + User owner = user(1L); + User nonOwner = user(2L); + StoredFile f = ownedFile(owner); + + assertThatThrownBy(() -> service.revokeUserShare(nonOwner, f, "user2")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + // ------------------------------------------------------------------------- + // createShareLink + // ------------------------------------------------------------------------- + + @Test + void createShareLink_owner_tokenGenerated() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + when(fileShareRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + FileShare result = service.createShareLink(owner, f, ShareAccessRole.VIEWER); + + assertThat(result.getShareToken()).isNotNull(); + assertThat(result.getAccessRole()).isEqualTo(ShareAccessRole.VIEWER); + verify(fileShareRepository).save(any(FileShare.class)); + } + + @Test + void createShareLink_nonOwner_throwsForbidden() { + User owner = user(1L); + User nonOwner = user(2L); + StoredFile f = ownedFile(owner); + + assertThatThrownBy(() -> service.createShareLink(nonOwner, f, ShareAccessRole.VIEWER)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + // ------------------------------------------------------------------------- + // revokeShareLink + // ------------------------------------------------------------------------- + + @Test + void revokeShareLink_owner_validToken_deletesShareAndAccessRecords() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + FileShare share = shareFor(f, null, ShareAccessRole.VIEWER); + share.setShareToken("test-token"); + when(fileShareRepository.findByShareToken("test-token")).thenReturn(Optional.of(share)); + + service.revokeShareLink(owner, f, "test-token"); + + verify(fileShareAccessRepository).deleteByFileShare(share); + verify(fileShareRepository).delete(share); + } + + @Test + void revokeShareLink_tokenBelongsToOtherFile_throwsForbidden() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + f.setId(1L); + StoredFile otherFile = ownedFile(owner); + otherFile.setId(2L); + FileShare share = shareFor(otherFile, null, ShareAccessRole.VIEWER); + share.setShareToken("token"); + when(fileShareRepository.findByShareToken("token")).thenReturn(Optional.of(share)); + + assertThatThrownBy(() -> service.revokeShareLink(owner, f, "token")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(403); + } + + @Test + void revokeShareLink_tokenNotFound_throwsNotFound() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + when(fileShareRepository.findByShareToken("unknown")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.revokeShareLink(owner, f, "unknown")) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(404); + } + + // ------------------------------------------------------------------------- + // Storage quota enforcement (via storeFile / replaceFile public API) + // ------------------------------------------------------------------------- + + @Test + void storeFile_nullQuotas_passes() throws IOException { + when(storageProperties.getQuotas()).thenReturn(null); + User owner = user(1L); + MockMultipartFile file = + new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1}); + when(storageProvider.store(any(), any())) + .thenReturn( + StoredObject.builder() + .storageKey("k") + .originalFilename("test.pdf") + .contentType("application/pdf") + .sizeBytes(1L) + .build()); + when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.storeFile(owner, file); + + verify(storageProvider).store(owner, file); + } + + @Test + void storeFile_fileTooLarge_throwsPayloadTooLarge() { + when(storageProperties.getQuotas()).thenReturn(quotasProperties); + when(quotasProperties.getMaxFileMb()).thenReturn(1L); // 1 MB limit + when(quotasProperties.getMaxStorageMbPerUser()).thenReturn(-1L); + when(quotasProperties.getMaxStorageMbTotal()).thenReturn(-1L); + User owner = user(1L); + // 2 MB file exceeds the 1 MB limit + MockMultipartFile file = + new MockMultipartFile( + "file", "big.pdf", "application/pdf", new byte[2 * 1024 * 1024]); + + assertThatThrownBy(() -> service.storeFile(owner, file)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(413); + } + + @Test + void storeFile_perUserQuotaExceeded_throwsPayloadTooLarge() { + when(storageProperties.getQuotas()).thenReturn(quotasProperties); + when(quotasProperties.getMaxFileMb()).thenReturn(-1L); + when(quotasProperties.getMaxStorageMbPerUser()).thenReturn(10L); // 10 MB per-user cap + when(quotasProperties.getMaxStorageMbTotal()).thenReturn(-1L); + User owner = user(1L); + // user already has 9 MB stored; a 2 MB upload pushes to 11 MB > 10 MB cap + when(storedFileRepository.sumStorageBytesByOwner(owner)).thenReturn(9L * 1024 * 1024); + MockMultipartFile file = + new MockMultipartFile( + "file", "f.pdf", "application/pdf", new byte[2 * 1024 * 1024]); + + assertThatThrownBy(() -> service.storeFile(owner, file)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(413); + } + + @Test + void storeFile_globalQuotaExceeded_throwsPayloadTooLarge() { + when(storageProperties.getQuotas()).thenReturn(quotasProperties); + when(quotasProperties.getMaxFileMb()).thenReturn(-1L); + when(quotasProperties.getMaxStorageMbPerUser()).thenReturn(-1L); + when(quotasProperties.getMaxStorageMbTotal()).thenReturn(100L); // 100 MB global cap + User owner = user(1L); + // system already has 99 MB; a 2 MB upload pushes to 101 MB > 100 MB cap + when(storedFileRepository.sumStorageBytesTotal()).thenReturn(99L * 1024 * 1024); + MockMultipartFile file = + new MockMultipartFile( + "file", "f.pdf", "application/pdf", new byte[2 * 1024 * 1024]); + + assertThatThrownBy(() -> service.storeFile(owner, file)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(413); + } + + @Test + void replaceFile_replacementShrinks_skipsPerUserAndGlobalCheck() throws IOException { + when(storageProperties.getQuotas()).thenReturn(quotasProperties); + when(quotasProperties.getMaxFileMb()).thenReturn(-1L); + User owner = user(1L); + // existing file is 5 MB; replacement is 1 MB → delta ≤ 0 → quota repos never queried + StoredFile existing = ownedFile(owner); + existing.setSizeBytes(5L * 1024 * 1024); + existing.setStorageKey("old-key"); + MockMultipartFile newFile = + new MockMultipartFile( + "file", "small.pdf", "application/pdf", new byte[1 * 1024 * 1024]); + when(storageProvider.store(any(), any())) + .thenReturn( + StoredObject.builder() + .storageKey("new-key") + .originalFilename("small.pdf") + .contentType("application/pdf") + .sizeBytes(1L * 1024 * 1024) + .build()); + when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.replaceFile(owner, existing, newFile); + + verify(storedFileRepository, never()).sumStorageBytesByOwner(any()); + verify(storedFileRepository, never()).sumStorageBytesTotal(); + } + + // ------------------------------------------------------------------------- + // deleteFile — workflow guard + // ------------------------------------------------------------------------- + + @Test + void deleteFile_fileInActiveWorkflow_throwsBadRequest() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + WorkflowSession session = mock(WorkflowSession.class); + when(session.isActive()).thenReturn(true); + f.setWorkflowSession(session); + + assertThatThrownBy(() -> service.deleteFile(owner, f)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + + verify(storedFileRepository, never()).delete(any()); + } + + @Test + void deleteFile_fileNotInAnyWorkflow_deletesSuccessfully() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + // no workflow session set + when(fileShareRepository.findShareLinks(f)).thenReturn(List.of()); + + service.deleteFile(owner, f); + + verify(storedFileRepository).delete(f); + } + + @Test + void deleteFile_fileInCompletedWorkflow_deletesSuccessfully() { + User owner = user(1L); + StoredFile f = ownedFile(owner); + WorkflowSession session = mock(WorkflowSession.class); + when(session.isActive()).thenReturn(false); + f.setWorkflowSession(session); + when(fileShareRepository.findShareLinks(f)).thenReturn(List.of()); + + service.deleteFile(owner, f); + + verify(storedFileRepository).delete(f); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/dto/WetSignatureMetadataTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/dto/WetSignatureMetadataTest.java new file mode 100644 index 0000000000..d0d209f9a3 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/dto/WetSignatureMetadataTest.java @@ -0,0 +1,115 @@ +package stirling.software.proprietary.workflow.dto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class WetSignatureMetadataTest { + + private static WetSignatureMetadata canvas(double x, double y, double w, double h) { + return new WetSignatureMetadata("canvas", "data:image/png;base64,abc==", 0, x, y, w, h); + } + + // ------------------------------------------------------------------------- + // validate() — image data prefix + // ------------------------------------------------------------------------- + + @Test + void validate_canvas_withDataImagePrefix_passes() { + assertThatCode(() -> canvas(0.0, 0.0, 0.5, 0.5).validate()).doesNotThrowAnyException(); + } + + @Test + void validate_image_withDataImagePrefix_passes() { + WetSignatureMetadata sig = + new WetSignatureMetadata( + "image", "data:image/jpeg;base64,xyz==", 0, 0.1, 0.1, 0.3, 0.3); + assertThatCode(sig::validate).doesNotThrowAnyException(); + } + + @Test + void validate_canvas_withoutDataImagePrefix_throws() { + WetSignatureMetadata sig = + new WetSignatureMetadata( + "canvas", "raw-base64-without-prefix", 0, 0.0, 0.0, 0.5, 0.5); + assertThatThrownBy(sig::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("data:image/"); + } + + @Test + void validate_text_doesNotRequireDataImagePrefix() { + WetSignatureMetadata sig = + new WetSignatureMetadata("text", "John Doe", 0, 0.1, 0.1, 0.3, 0.2); + assertThatCode(sig::validate).doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------------- + // validate() — cross-field boundary checks (x + width, y + height) + // ------------------------------------------------------------------------- + + @Test + void validate_xPlusWidthExactlyOne_passes() { + assertThatCode(() -> canvas(0.5, 0.0, 0.5, 0.5).validate()).doesNotThrowAnyException(); + } + + @Test + void validate_xPlusWidthExceedsOne_throws() { + assertThatThrownBy(() -> canvas(0.6, 0.0, 0.5, 0.5).validate()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("right edge"); + } + + @Test + void validate_yPlusHeightExactlyOne_passes() { + assertThatCode(() -> canvas(0.0, 0.5, 0.5, 0.5).validate()).doesNotThrowAnyException(); + } + + @Test + void validate_yPlusHeightExceedsOne_throws() { + assertThatThrownBy(() -> canvas(0.0, 0.6, 0.5, 0.5).validate()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bottom edge"); + } + + @Test + void validate_originWithFullPageSize_passes() { + assertThatCode(() -> canvas(0.0, 0.0, 1.0, 1.0).validate()).doesNotThrowAnyException(); + } + + // ------------------------------------------------------------------------- + // MAX_SIGNATURES_PER_PARTICIPANT constant + // ------------------------------------------------------------------------- + + @Test + void maxSignaturesConstant_isPositive() { + assertThat(WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT).isGreaterThan(0); + } + + // ------------------------------------------------------------------------- + // extractBase64Data() + // ------------------------------------------------------------------------- + + @Test + void extractBase64Data_stripsDataUrlPrefix() { + WetSignatureMetadata sig = canvas(0.0, 0.0, 0.5, 0.5); + sig.setData("data:image/png;base64,iVBORw0KGgo="); + assertThat(sig.extractBase64Data()).isEqualTo("iVBORw0KGgo="); + } + + @Test + void extractBase64Data_noComma_returnsDataUnchanged() { + WetSignatureMetadata sig = canvas(0.0, 0.0, 0.5, 0.5); + sig.setData("plainbase64withoutcomma"); + assertThat(sig.extractBase64Data()).isEqualTo("plainbase64withoutcomma"); + } + + @Test + void extractBase64Data_null_returnsNull() { + WetSignatureMetadata sig = canvas(0.0, 0.0, 0.5, 0.5); + sig.setData(null); + assertThat(sig.extractBase64Data()).isNull(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/MetadataEncryptionServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/MetadataEncryptionServiceTest.java new file mode 100644 index 0000000000..bd532ee780 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/MetadataEncryptionServiceTest.java @@ -0,0 +1,123 @@ +package stirling.software.proprietary.workflow.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.AutomaticallyGenerated; + +class MetadataEncryptionServiceTest { + + private MetadataEncryptionService service; + + @BeforeEach + void setUp() { + service = serviceWithKey("test-encryption-key-for-unit-tests-only"); + } + + private static MetadataEncryptionService serviceWithKey(String key) { + AutomaticallyGenerated generated = new AutomaticallyGenerated(); + generated.setKey(key); + ApplicationProperties props = new ApplicationProperties(); + props.setAutomaticallyGenerated(generated); + return new MetadataEncryptionService(props); + } + + // ------------------------------------------------------------------------- + // Null / empty passthrough + // ------------------------------------------------------------------------- + + @Test + void encrypt_null_returnsNull() { + assertThat(service.encrypt(null)).isNull(); + } + + @Test + void decrypt_null_returnsNull() { + assertThat(service.decrypt(null)).isNull(); + } + + // ------------------------------------------------------------------------- + // Legacy plaintext backwards-compatibility + // ------------------------------------------------------------------------- + + @Test + void decrypt_plaintextWithoutPrefix_returnsUnchanged() { + assertThat(service.decrypt("plaintext-password")).isEqualTo("plaintext-password"); + } + + @Test + void decrypt_emptyStringWithoutPrefix_returnsUnchanged() { + assertThat(service.decrypt("")).isEqualTo(""); + } + + // ------------------------------------------------------------------------- + // Encrypt / decrypt round-trip + // ------------------------------------------------------------------------- + + @Test + void encrypt_producesEncPrefix() { + String encrypted = service.encrypt("secret"); + assertThat(encrypted).startsWith(MetadataEncryptionService.ENC_PREFIX); + } + + @Test + void roundTrip_restoresOriginalValue() { + String original = "my-keystore-password"; + String encrypted = service.encrypt(original); + assertThat(service.decrypt(encrypted)).isEqualTo(original); + } + + @Test + void roundTrip_emptyString() { + String encrypted = service.encrypt(""); + assertThat(service.decrypt(encrypted)).isEqualTo(""); + } + + @Test + void roundTrip_specialCharactersAndUnicode() { + String original = "p@$$w0rd!£€#\u00e9"; + assertThat(service.decrypt(service.encrypt(original))).isEqualTo(original); + } + + // ------------------------------------------------------------------------- + // IV randomisation — each call must produce a distinct ciphertext + // ------------------------------------------------------------------------- + + @Test + void encrypt_sameInput_producesDifferentCiphertexts() { + String a = service.encrypt("same-value"); + String b = service.encrypt("same-value"); + assertThat(a).isNotEqualTo(b); + } + + // ------------------------------------------------------------------------- + // Key dependency — different keys must produce different ciphertexts + // ------------------------------------------------------------------------- + + @Test + void encrypt_differentKey_producesIncompatibleCiphertext() { + MetadataEncryptionService otherService = serviceWithKey("completely-different-key"); + + String encryptedByOther = otherService.encrypt("secret"); + + // The original service cannot decrypt what the other service encrypted + assertThatThrownBy(() -> service.decrypt(encryptedByOther)) + .isInstanceOf(IllegalStateException.class); + } + + // ------------------------------------------------------------------------- + // Missing key guard + // ------------------------------------------------------------------------- + + @Test + void encrypt_missingKey_throwsIllegalState() { + MetadataEncryptionService noKeyService = serviceWithKey(null); + + assertThatThrownBy(() -> noKeyService.encrypt("anything")) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/SigningFinalizationServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/SigningFinalizationServiceTest.java new file mode 100644 index 0000000000..4c688dcd6c --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/SigningFinalizationServiceTest.java @@ -0,0 +1,183 @@ +package stirling.software.proprietary.workflow.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.service.PdfSigningService; +import stirling.software.common.service.ServerCertificateServiceInterface; +import stirling.software.proprietary.workflow.model.ParticipantStatus; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; + +import tools.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +class SigningFinalizationServiceTest { + + @Mock private WorkflowParticipantRepository participantRepository; + @Mock private CustomPDFDocumentFactory pdfDocumentFactory; + @Mock private ObjectMapper objectMapper; + @Mock private PdfSigningService pdfSigningService; + @Mock private MetadataEncryptionService metadataEncryptionService; + @Mock private ServerCertificateServiceInterface serverCertificateService; + @Mock private UserServerCertificateService userServerCertificateService; + + @InjectMocks private SigningFinalizationService service; + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private WorkflowSession sessionWithParticipants(WorkflowParticipant... participants) { + WorkflowSession session = new WorkflowSession(); + session.setSessionId("test-session"); + List list = new ArrayList<>(); + for (WorkflowParticipant p : participants) { + list.add(p); + } + session.setParticipants(list); + return session; + } + + private WorkflowParticipant participantWithMetadata(Map metadata) { + WorkflowParticipant p = new WorkflowParticipant(); + p.setStatus(ParticipantStatus.SIGNED); + p.setEmail("test@example.com"); + p.setParticipantMetadata(new HashMap<>(metadata)); + return p; + } + + // ------------------------------------------------------------------------- + // clearSensitiveMetadata — individual key removal + // ------------------------------------------------------------------------- + + @Test + void clearSensitiveMetadata_removesWetSignaturesKey() { + WorkflowParticipant p = + participantWithMetadata(Map.of("wetSignatures", List.of("sig1"), "other", "keep")); + WorkflowSession session = sessionWithParticipants(p); + + service.clearSensitiveMetadata(session); + + assertThat(p.getParticipantMetadata()).doesNotContainKey("wetSignatures"); + assertThat(p.getParticipantMetadata()).containsKey("other"); + verify(participantRepository, times(1)).save(p); + } + + @Test + void clearSensitiveMetadata_removesCertificateSubmissionKey() { + WorkflowParticipant p = + participantWithMetadata( + Map.of("certificateSubmission", Map.of("certType", "SERVER"))); + WorkflowSession session = sessionWithParticipants(p); + + service.clearSensitiveMetadata(session); + + assertThat(p.getParticipantMetadata()).doesNotContainKey("certificateSubmission"); + verify(participantRepository, times(1)).save(p); + } + + @Test + void clearSensitiveMetadata_removesBothKeys_savesOnce() { + Map metadata = new HashMap<>(); + metadata.put("wetSignatures", List.of("sig1")); + metadata.put("certificateSubmission", Map.of("certType", "SERVER")); + metadata.put("showLogo", true); + WorkflowParticipant p = participantWithMetadata(metadata); + WorkflowSession session = sessionWithParticipants(p); + + service.clearSensitiveMetadata(session); + + assertThat(p.getParticipantMetadata()).doesNotContainKey("wetSignatures"); + assertThat(p.getParticipantMetadata()).doesNotContainKey("certificateSubmission"); + assertThat(p.getParticipantMetadata()).containsKey("showLogo"); + verify(participantRepository, times(1)).save(p); + } + + // ------------------------------------------------------------------------- + // clearSensitiveMetadata — no-op cases (save must NOT be called) + // ------------------------------------------------------------------------- + + @Test + void clearSensitiveMetadata_noSensitiveKeys_doesNotSave() { + WorkflowParticipant p = participantWithMetadata(Map.of("showLogo", true, "pageNumber", 1)); + WorkflowSession session = sessionWithParticipants(p); + + service.clearSensitiveMetadata(session); + + verify(participantRepository, never()).save(any()); + } + + @Test + void clearSensitiveMetadata_nullMetadata_doesNotSave() { + WorkflowParticipant p = new WorkflowParticipant(); + p.setStatus(ParticipantStatus.SIGNED); + p.setParticipantMetadata(null); + WorkflowSession session = sessionWithParticipants(p); + + service.clearSensitiveMetadata(session); + + verify(participantRepository, never()).save(any()); + } + + @Test + void clearSensitiveMetadata_emptyMetadata_doesNotSave() { + WorkflowParticipant p = participantWithMetadata(Map.of()); + WorkflowSession session = sessionWithParticipants(p); + + service.clearSensitiveMetadata(session); + + verify(participantRepository, never()).save(any()); + } + + // ------------------------------------------------------------------------- + // clearSensitiveMetadata — multiple participants + // ------------------------------------------------------------------------- + + @Test + void clearSensitiveMetadata_multipleParticipants_allWithSensitiveData_allCleared() { + WorkflowParticipant p1 = participantWithMetadata(Map.of("wetSignatures", List.of("s1"))); + WorkflowParticipant p2 = + participantWithMetadata(Map.of("certificateSubmission", Map.of("k", "v"))); + WorkflowParticipant p3 = + participantWithMetadata(Map.of("wetSignatures", List.of("s3"), "extra", "keep")); + WorkflowSession session = sessionWithParticipants(p1, p2, p3); + + service.clearSensitiveMetadata(session); + + assertThat(p1.getParticipantMetadata()).doesNotContainKey("wetSignatures"); + assertThat(p2.getParticipantMetadata()).doesNotContainKey("certificateSubmission"); + assertThat(p3.getParticipantMetadata()).doesNotContainKey("wetSignatures"); + assertThat(p3.getParticipantMetadata()).containsKey("extra"); + verify(participantRepository, times(3)).save(any()); + } + + @Test + void clearSensitiveMetadata_mixedParticipants_onlySavesModified() { + WorkflowParticipant withSensitive = + participantWithMetadata(Map.of("wetSignatures", List.of("s1"))); + WorkflowParticipant withoutSensitive = participantWithMetadata(Map.of("showLogo", false)); + WorkflowSession session = sessionWithParticipants(withSensitive, withoutSensitive); + + service.clearSensitiveMetadata(session); + + verify(participantRepository, times(1)).save(withSensitive); + verify(participantRepository, never()).save(withoutSensitive); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlServiceTest.java new file mode 100644 index 0000000000..a5aebfda32 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/UnifiedAccessControlServiceTest.java @@ -0,0 +1,337 @@ +package stirling.software.proprietary.workflow.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.FileShare; +import stirling.software.proprietary.storage.model.ShareAccessRole; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.repository.FileShareRepository; +import stirling.software.proprietary.workflow.model.ParticipantStatus; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.model.WorkflowStatus; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; +import stirling.software.proprietary.workflow.service.UnifiedAccessControlService.AccessValidationResult; + +@ExtendWith(MockitoExtension.class) +class UnifiedAccessControlServiceTest { + + @Mock private FileShareRepository fileShareRepository; + @Mock private WorkflowParticipantRepository workflowParticipantRepository; + + @InjectMocks private UnifiedAccessControlService service; + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private User user(long id) { + User u = new User(); + u.setId(id); + u.setUsername("user" + id); + return u; + } + + private FileShare share(StoredFile file, User sharedWith, LocalDateTime expiresAt) { + FileShare s = new FileShare(); + s.setFile(file); + s.setShareToken("tok-" + System.nanoTime()); + s.setAccessRole(ShareAccessRole.EDITOR); + s.setSharedWithUser(sharedWith); + s.setExpiresAt(expiresAt); + return s; + } + + private WorkflowParticipant participant( + User user, ParticipantStatus status, boolean sessionActive, LocalDateTime expiresAt) { + WorkflowSession session = new WorkflowSession(); + session.setStatus(sessionActive ? WorkflowStatus.IN_PROGRESS : WorkflowStatus.COMPLETED); + session.setFinalized(false); + + StoredFile file = new StoredFile(); + session.setOriginalFile(file); + + WorkflowParticipant p = new WorkflowParticipant(); + p.setUser(user); + p.setStatus(status); + p.setAccessRole(ShareAccessRole.EDITOR); + p.setExpiresAt(expiresAt); + p.setWorkflowSession(session); + return p; + } + + // ------------------------------------------------------------------------- + // validateToken — file share branch + // ------------------------------------------------------------------------- + + @Test + void validateToken_genericShare_noExpiry_noUserRestriction_allowed() { + StoredFile file = new StoredFile(); + FileShare s = share(file, null, null); + when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s)); + + AccessValidationResult result = service.validateToken("tok", null); + + assertThat(result.isAllowed()).isTrue(); + assertThat(result.isWorkflowAccess()).isFalse(); + } + + @Test + void validateToken_genericShare_expired_denied() { + StoredFile file = new StoredFile(); + FileShare s = share(file, null, LocalDateTime.now().minusSeconds(1)); + when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s)); + + AccessValidationResult result = service.validateToken("tok", null); + + assertThat(result.isAllowed()).isFalse(); + assertThat(result.getDenialReason()).containsIgnoringCase("expired"); + } + + @Test + void validateToken_userSpecificShare_wrongUser_denied() { + User owner = user(1L); + User requester = user(2L); + StoredFile file = new StoredFile(); + FileShare s = share(file, owner, null); + when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s)); + + AccessValidationResult result = service.validateToken("tok", requester); + + assertThat(result.isAllowed()).isFalse(); + assertThat(result.getDenialReason()).containsIgnoringCase("denied"); + } + + @Test + void validateToken_userSpecificShare_correctUser_allowed() { + User u = user(1L); + StoredFile file = new StoredFile(); + FileShare s = share(file, u, null); + when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s)); + + AccessValidationResult result = service.validateToken("tok", u); + + assertThat(result.isAllowed()).isTrue(); + } + + // ------------------------------------------------------------------------- + // validateToken — workflow participant branch + // ------------------------------------------------------------------------- + + @Test + void validateToken_participantToken_notFileShare_activeParticipant_allowed() { + User u = user(1L); + WorkflowParticipant p = participant(u, ParticipantStatus.PENDING, true, null); + when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty()); + when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p)); + + AccessValidationResult result = service.validateToken("tok", u); + + assertThat(result.isAllowed()).isTrue(); + assertThat(result.isWorkflowAccess()).isTrue(); + } + + @Test + void validateToken_participantToken_expired_denied() { + User u = user(1L); + WorkflowParticipant p = + participant( + u, ParticipantStatus.PENDING, true, LocalDateTime.now().minusSeconds(1)); + when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty()); + when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p)); + + AccessValidationResult result = service.validateToken("tok", u); + + assertThat(result.isAllowed()).isFalse(); + assertThat(result.getDenialReason()).containsIgnoringCase("expired"); + } + + @Test + void validateToken_participantToken_sessionInactive_denied() { + User u = user(1L); + WorkflowParticipant p = participant(u, ParticipantStatus.PENDING, false, null); + when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty()); + when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p)); + + AccessValidationResult result = service.validateToken("tok", u); + + assertThat(result.isAllowed()).isFalse(); + assertThat(result.getDenialReason()).containsIgnoringCase("no longer active"); + } + + @Test + void validateToken_participantToken_wrongUser_denied() { + User owner = user(1L); + User requester = user(2L); + WorkflowParticipant p = participant(owner, ParticipantStatus.PENDING, true, null); + when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty()); + when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p)); + + AccessValidationResult result = service.validateToken("tok", requester); + + assertThat(result.isAllowed()).isFalse(); + } + + @Test + void validateToken_unknownToken_denied() { + when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty()); + when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.empty()); + + AccessValidationResult result = service.validateToken("tok", null); + + assertThat(result.isAllowed()).isFalse(); + } + + // ------------------------------------------------------------------------- + // getEffectiveRole + // ------------------------------------------------------------------------- + + @Test + void getEffectiveRole_signed_returnsViewer() { + WorkflowParticipant p = participant(null, ParticipantStatus.SIGNED, true, null); + assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.VIEWER); + } + + @Test + void getEffectiveRole_declined_returnsViewer() { + WorkflowParticipant p = participant(null, ParticipantStatus.DECLINED, true, null); + assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.VIEWER); + } + + @Test + void getEffectiveRole_pending_returnsAssignedRole() { + WorkflowParticipant p = participant(null, ParticipantStatus.PENDING, true, null); + p.setAccessRole(ShareAccessRole.EDITOR); + assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.EDITOR); + } + + @Test + void getEffectiveRole_notified_returnsAssignedRole() { + WorkflowParticipant p = participant(null, ParticipantStatus.NOTIFIED, true, null); + p.setAccessRole(ShareAccessRole.VIEWER); + assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.VIEWER); + } + + @Test + void getEffectiveRole_viewed_returnsAssignedRole() { + WorkflowParticipant p = participant(null, ParticipantStatus.VIEWED, true, null); + p.setAccessRole(ShareAccessRole.COMMENTER); + assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.COMMENTER); + } + + // ------------------------------------------------------------------------- + // canAccessFile + // ------------------------------------------------------------------------- + + @Test + void canAccessFile_owner_returnsTrue() { + User u = user(1L); + StoredFile file = new StoredFile(); + file.setOwner(u); + + assertThat(service.canAccessFile(u, file)).isTrue(); + } + + @Test + void canAccessFile_nonOwner_withValidShare_returnsTrue() { + User owner = user(1L); + User requester = user(2L); + StoredFile file = new StoredFile(); + file.setOwner(owner); + + FileShare s = share(file, requester, null); + when(fileShareRepository.findByFileAndSharedWithUser(file, requester)) + .thenReturn(Optional.of(s)); + + assertThat(service.canAccessFile(requester, file)).isTrue(); + } + + @Test + void canAccessFile_nonOwner_withExpiredShare_returnsFalse() { + User owner = user(1L); + User requester = user(2L); + StoredFile file = new StoredFile(); + file.setOwner(owner); + + FileShare s = share(file, requester, LocalDateTime.now().minusSeconds(1)); + when(fileShareRepository.findByFileAndSharedWithUser(file, requester)) + .thenReturn(Optional.of(s)); + + assertThat(service.canAccessFile(requester, file)).isFalse(); + } + + @Test + void canAccessFile_nonOwner_noShare_returnsFalse() { + User owner = user(1L); + User requester = user(2L); + StoredFile file = new StoredFile(); + file.setOwner(owner); + + when(fileShareRepository.findByFileAndSharedWithUser(file, requester)) + .thenReturn(Optional.empty()); + + assertThat(service.canAccessFile(requester, file)).isFalse(); + } + + @Test + void canAccessFile_workflowParticipant_activeSession_returnsTrue() { + User owner = user(1L); + User requester = user(2L); + + WorkflowSession session = new WorkflowSession(); + session.setStatus(WorkflowStatus.IN_PROGRESS); + session.setFinalized(false); + + StoredFile file = new StoredFile(); + file.setOwner(owner); + file.setWorkflowSession(session); + + WorkflowParticipant p = participant(requester, ParticipantStatus.PENDING, true, null); + + when(fileShareRepository.findByFileAndSharedWithUser(file, requester)) + .thenReturn(Optional.empty()); + when(workflowParticipantRepository.findByWorkflowSessionAndUser(session, requester)) + .thenReturn(Optional.of(p)); + + assertThat(service.canAccessFile(requester, file)).isTrue(); + } + + @Test + void canAccessFile_workflowParticipant_expiredParticipant_returnsFalse() { + User owner = user(1L); + User requester = user(2L); + + WorkflowSession session = new WorkflowSession(); + session.setStatus(WorkflowStatus.IN_PROGRESS); + session.setFinalized(false); + + StoredFile file = new StoredFile(); + file.setOwner(owner); + file.setWorkflowSession(session); + + WorkflowParticipant p = + participant( + requester, + ParticipantStatus.PENDING, + true, + LocalDateTime.now().minusSeconds(1)); + + when(fileShareRepository.findByFileAndSharedWithUser(file, requester)) + .thenReturn(Optional.empty()); + when(workflowParticipantRepository.findByWorkflowSessionAndUser(session, requester)) + .thenReturn(Optional.of(p)); + + assertThat(service.canAccessFile(requester, file)).isFalse(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/UserServerCertificateServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/UserServerCertificateServiceTest.java new file mode 100644 index 0000000000..7ade5c3100 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/UserServerCertificateServiceTest.java @@ -0,0 +1,201 @@ +package stirling.software.proprietary.workflow.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.Optional; + +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.AutomaticallyGenerated; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.workflow.model.UserServerCertificateEntity; +import stirling.software.proprietary.workflow.repository.UserServerCertificateRepository; + +@ExtendWith(MockitoExtension.class) +class UserServerCertificateServiceTest { + + @Mock private UserServerCertificateRepository certificateRepository; + @Mock private UserRepository userRepository; + + private MetadataEncryptionService encryptionService; + private UserServerCertificateService service; + + @BeforeEach + void setUp() { + AutomaticallyGenerated generated = new AutomaticallyGenerated(); + generated.setKey("test-key-for-unit-tests-only"); + ApplicationProperties props = new ApplicationProperties(); + props.setAutomaticallyGenerated(generated); + + encryptionService = new MetadataEncryptionService(props); + service = + new UserServerCertificateService( + certificateRepository, userRepository, encryptionService); + } + + private User user(long id) { + User u = new User(); + u.setId(id); + u.setUsername("user" + id); + return u; + } + + // ------------------------------------------------------------------------- + // generateUserCertificate — password must be stored encrypted + // ------------------------------------------------------------------------- + + @Test + void generateUserCertificate_keystorePasswordStoredEncrypted() throws Exception { + User user = user(1L); + when(certificateRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.generateUserCertificate(user); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(UserServerCertificateEntity.class); + verify(certificateRepository).save(captor.capture()); + + String stored = captor.getValue().getKeystorePassword(); + assertThat(stored).startsWith(MetadataEncryptionService.ENC_PREFIX); + // The raw predictable prefix must not appear in the stored value + assertThat(stored).doesNotContain("stirling-user-cert-"); + } + + // ------------------------------------------------------------------------- + // uploadUserCertificate — password must be stored encrypted + // ------------------------------------------------------------------------- + + @Test + void uploadUserCertificate_keystorePasswordStoredEncrypted() throws Exception { + User user = user(2L); + String uploadPassword = "my-upload-password"; + byte[] p12Bytes = buildP12(uploadPassword); + + when(certificateRepository.findByUserId(2L)).thenReturn(Optional.empty()); + when(certificateRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.uploadUserCertificate(user, new ByteArrayInputStream(p12Bytes), uploadPassword); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(UserServerCertificateEntity.class); + verify(certificateRepository).save(captor.capture()); + + String stored = captor.getValue().getKeystorePassword(); + assertThat(stored).startsWith(MetadataEncryptionService.ENC_PREFIX); + assertThat(stored).doesNotContain(uploadPassword); + } + + // ------------------------------------------------------------------------- + // getUserKeystorePassword — must decrypt before returning + // ------------------------------------------------------------------------- + + @Test + void getUserKeystorePassword_returnsDecryptedPlaintext() { + String original = "plain-password"; + String encrypted = encryptionService.encrypt(original); + + UserServerCertificateEntity entity = new UserServerCertificateEntity(); + entity.setKeystorePassword(encrypted); + when(certificateRepository.findByUserId(1L)).thenReturn(Optional.of(entity)); + + assertThat(service.getUserKeystorePassword(1L)).isEqualTo(original); + } + + @Test + void getUserKeystorePassword_legacyPlaintext_returnedUnchanged() { + // Backwards-compatibility: values without enc: prefix pass through unchanged + UserServerCertificateEntity entity = new UserServerCertificateEntity(); + entity.setKeystorePassword("legacy-plain"); + when(certificateRepository.findByUserId(1L)).thenReturn(Optional.of(entity)); + + assertThat(service.getUserKeystorePassword(1L)).isEqualTo("legacy-plain"); + } + + // ------------------------------------------------------------------------- + // getUserKeyStore — decrypted password must successfully open the keystore + // ------------------------------------------------------------------------- + + @Test + void getUserKeyStore_decryptsPasswordToLoadKeystore() throws Exception { + String keystorePassword = "keystore-pass"; + byte[] p12Bytes = buildP12(keystorePassword); + String encryptedPassword = encryptionService.encrypt(keystorePassword); + + UserServerCertificateEntity entity = new UserServerCertificateEntity(); + entity.setKeystoreData(p12Bytes); + entity.setKeystorePassword(encryptedPassword); + when(certificateRepository.findByUserId(1L)).thenReturn(Optional.of(entity)); + + KeyStore ks = service.getUserKeyStore(1L); + + assertThat(ks).isNotNull(); + assertThat(ks.aliases().hasMoreElements()).isTrue(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Builds a minimal PKCS12 keystore containing a self-signed RSA certificate, using the same + * BouncyCastle provider that is already on the classpath. + */ + private static byte[] buildP12(String password) throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", "BC"); + kpg.initialize(2048, new SecureRandom()); + KeyPair kp = kpg.generateKeyPair(); + + org.bouncycastle.asn1.x500.X500Name subject = + new org.bouncycastle.asn1.x500.X500Name("CN=test"); + BigInteger serial = BigInteger.valueOf(System.currentTimeMillis()); + Date notBefore = new Date(); + Date notAfter = new Date(notBefore.getTime() + 365L * 24 * 60 * 60 * 1000); + + JcaX509v3CertificateBuilder builder = + new JcaX509v3CertificateBuilder( + subject, serial, notBefore, notAfter, subject, kp.getPublic()); + + ContentSigner signer = + new JcaContentSignerBuilder("SHA256WithRSA") + .setProvider("BC") + .build(kp.getPrivate()); + + X509Certificate cert = + new JcaX509CertificateConverter() + .setProvider(new BouncyCastleProvider()) + .getCertificate(builder.build(signer)); + + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry("alias", kp.getPrivate(), password.toCharArray(), new Certificate[] {cert}); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ks.store(baos, password.toCharArray()); + return baos.toByteArray(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java new file mode 100644 index 0000000000..eaba1a88ff --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/WorkflowSessionServiceTest.java @@ -0,0 +1,572 @@ +package stirling.software.proprietary.workflow.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.Storage; +import stirling.software.common.model.ApplicationProperties.Storage.Signing; +import stirling.software.proprietary.security.database.repository.UserRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.storage.model.StoredFile; +import stirling.software.proprietary.storage.provider.StorageProvider; +import stirling.software.proprietary.storage.provider.StoredObject; +import stirling.software.proprietary.storage.repository.StoredFileRepository; +import stirling.software.proprietary.workflow.dto.SignDocumentRequest; +import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest; +import stirling.software.proprietary.workflow.model.ParticipantStatus; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.model.WorkflowStatus; +import stirling.software.proprietary.workflow.model.WorkflowType; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; +import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository; + +import tools.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +class WorkflowSessionServiceTest { + + @Mock private WorkflowSessionRepository workflowSessionRepository; + @Mock private WorkflowParticipantRepository workflowParticipantRepository; + @Mock private StoredFileRepository storedFileRepository; + @Mock private UserRepository userRepository; + @Mock private StorageProvider storageProvider; + @Mock private ObjectMapper objectMapper; + @Mock private ApplicationProperties applicationProperties; + @Mock private MetadataEncryptionService metadataEncryptionService; + + @InjectMocks private WorkflowSessionService service; + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private WorkflowSession sessionWithParticipant( + String sessionId, WorkflowParticipant participant) { + WorkflowSession session = new WorkflowSession(); + session.setSessionId(sessionId); + List participants = new ArrayList<>(); + participants.add(participant); + session.setParticipants(participants); + when(workflowSessionRepository.findBySessionId(sessionId)).thenReturn(Optional.of(session)); + return session; + } + + private WorkflowParticipant pendingParticipant(User user) { + WorkflowParticipant p = new WorkflowParticipant(); + p.setUser(user); + p.setStatus(ParticipantStatus.PENDING); + return p; + } + + private User user(String username) { + User u = new User(); + u.setUsername(username); + return u; + } + + // ------------------------------------------------------------------------- + // signDocument — status transition + // ------------------------------------------------------------------------- + + @Test + void signDocument_transitionsParticipantToSigned() { + User user = user("alice"); + WorkflowParticipant participant = pendingParticipant(user); + sessionWithParticipant("s1", participant); + + when(metadataEncryptionService.encrypt(any())).thenReturn("enc:pw"); + when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + SignDocumentRequest req = new SignDocumentRequest(); + req.setCertType("SERVER"); + + service.signDocument("s1", user, req); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WorkflowParticipant.class); + verify(workflowParticipantRepository).save(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo(ParticipantStatus.SIGNED); + } + + // ------------------------------------------------------------------------- + // signDocument — certificate metadata + // ------------------------------------------------------------------------- + + @Test + void signDocument_storesCertTypeAndEncryptedPasswordInMetadata() { + User user = user("bob"); + WorkflowParticipant participant = pendingParticipant(user); + sessionWithParticipant("s2", participant); + + when(metadataEncryptionService.encrypt("secret")).thenReturn("enc:secret"); + when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + SignDocumentRequest req = new SignDocumentRequest(); + req.setCertType("USER_CERT"); + req.setPassword("secret"); + + service.signDocument("s2", user, req); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WorkflowParticipant.class); + verify(workflowParticipantRepository).save(captor.capture()); + + Map meta = captor.getValue().getParticipantMetadata(); + assertThat(meta).containsKey("certificateSubmission"); + + @SuppressWarnings("unchecked") + Map cert = (Map) meta.get("certificateSubmission"); + assertThat(cert.get("certType")).isEqualTo("USER_CERT"); + // Raw password must not be stored — only the encrypted form + assertThat(cert.get("password")).isEqualTo("enc:secret"); + assertThat(cert.get("password")).isNotEqualTo("secret"); + } + + @Test + void signDocument_preservesExistingParticipantMetadata() { + User user = user("carol"); + WorkflowParticipant participant = pendingParticipant(user); + Map existing = new HashMap<>(); + existing.put("showLogo", true); + existing.put("pageNumber", 1); + participant.setParticipantMetadata(existing); + sessionWithParticipant("s3", participant); + + when(metadataEncryptionService.encrypt(any())).thenReturn("enc:pw"); + when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + SignDocumentRequest req = new SignDocumentRequest(); + req.setCertType("SERVER"); + + service.signDocument("s3", user, req); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WorkflowParticipant.class); + verify(workflowParticipantRepository).save(captor.capture()); + + Map meta = captor.getValue().getParticipantMetadata(); + // Owner-configured appearance settings must survive the sign operation + assertThat(meta.get("showLogo")).isEqualTo(true); + assertThat(meta.get("pageNumber")).isEqualTo(1); + assertThat(meta).containsKey("certificateSubmission"); + } + + // ------------------------------------------------------------------------- + // signDocument — guard conditions + // ------------------------------------------------------------------------- + + @Test + void signDocument_throwsBadRequest_whenAlreadySigned() { + User user = user("dave"); + WorkflowParticipant participant = pendingParticipant(user); + participant.setStatus(ParticipantStatus.SIGNED); + sessionWithParticipant("s4", participant); + + SignDocumentRequest req = new SignDocumentRequest(); + req.setCertType("SERVER"); + + assertThatThrownBy(() -> service.signDocument("s4", user, req)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + + verify(workflowParticipantRepository, never()).save(any()); + } + + @Test + void signDocument_throwsBadRequest_whenAlreadyDeclined() { + User user = user("eve"); + WorkflowParticipant participant = pendingParticipant(user); + participant.setStatus(ParticipantStatus.DECLINED); + sessionWithParticipant("s5", participant); + + SignDocumentRequest req = new SignDocumentRequest(); + req.setCertType("SERVER"); + + assertThatThrownBy(() -> service.signDocument("s5", user, req)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + + verify(workflowParticipantRepository, never()).save(any()); + } + + @Test + void signDocument_throwsForbidden_whenUserIsNotParticipant() { + User owner = user("frank"); + owner.setId(1L); + User intruder = user("intruder"); + intruder.setId(2L); + WorkflowParticipant participant = pendingParticipant(owner); + sessionWithParticipant("s6", participant); + + SignDocumentRequest req = new SignDocumentRequest(); + req.setCertType("SERVER"); + + assertThatThrownBy(() -> service.signDocument("s6", intruder, req)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + + verify(workflowParticipantRepository, never()).save(any()); + } + + // ------------------------------------------------------------------------- + // createSession — validation guards + // ------------------------------------------------------------------------- + + private void stubSigningEnabled() { + Storage storage = mock(Storage.class); + Signing signing = mock(Signing.class); + when(applicationProperties.getStorage()).thenReturn(storage); + when(storage.isEnabled()).thenReturn(true); + when(storage.getSigning()).thenReturn(signing); + when(signing.isEnabled()).thenReturn(true); + } + + @Test + void createSession_nullFile_throwsBadRequest() { + WorkflowCreationRequest request = new WorkflowCreationRequest(); + request.setWorkflowType(WorkflowType.SIGNING); + + assertThatThrownBy(() -> service.createSession(user("owner"), null, request)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void createSession_emptyFile_throwsBadRequest() { + MockMultipartFile empty = + new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[0]); + WorkflowCreationRequest request = new WorkflowCreationRequest(); + request.setWorkflowType(WorkflowType.SIGNING); + + assertThatThrownBy(() -> service.createSession(user("owner"), empty, request)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void createSession_nullWorkflowType_throwsBadRequest() { + MockMultipartFile file = + new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1}); + WorkflowCreationRequest request = new WorkflowCreationRequest(); + request.setWorkflowType(null); + + assertThatThrownBy(() -> service.createSession(user("owner"), file, request)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void createSession_validRequest_sessionSavedWithOwnerAndInProgressStatus() throws IOException { + User owner = user("alice"); + MockMultipartFile file = + new MockMultipartFile("file", "doc.pdf", "application/pdf", new byte[] {1, 2}); + WorkflowCreationRequest request = new WorkflowCreationRequest(); + request.setWorkflowType(WorkflowType.SIGNING); + request.setDocumentName("My Doc"); + + StoredObject storedObject = + StoredObject.builder() + .storageKey("key-1") + .originalFilename("doc.pdf") + .contentType("application/pdf") + .sizeBytes(2L) + .build(); + when(storageProvider.store(any(), any())).thenReturn(storedObject); + + StoredFile savedFile = new StoredFile(); + when(storedFileRepository.save(any())).thenReturn(savedFile); + + WorkflowSession savedSession = new WorkflowSession(); + savedSession.setSessionId("s-abc"); + savedSession.setParticipants(new ArrayList<>()); + when(workflowSessionRepository.save(any())).thenReturn(savedSession); + + WorkflowSession result = service.createSession(owner, file, request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WorkflowSession.class); + verify(workflowSessionRepository).save(captor.capture()); + assertThat(captor.getValue().getOwner()).isEqualTo(owner); + assertThat(captor.getValue().getStatus()).isEqualTo(WorkflowStatus.IN_PROGRESS); + assertThat(result).isNotNull(); + } + + @Test + void createSession_documentNameFromRequest() throws IOException { + User owner = user("alice"); + MockMultipartFile file = + new MockMultipartFile("file", "original.pdf", "application/pdf", new byte[] {1}); + WorkflowCreationRequest request = new WorkflowCreationRequest(); + request.setWorkflowType(WorkflowType.SIGNING); + request.setDocumentName("Custom Name"); + + when(storageProvider.store(any(), any())) + .thenReturn( + StoredObject.builder() + .storageKey("k") + .originalFilename("original.pdf") + .contentType("application/pdf") + .sizeBytes(1L) + .build()); + when(storedFileRepository.save(any())).thenReturn(new StoredFile()); + + WorkflowSession savedSession = new WorkflowSession(); + savedSession.setSessionId("s-1"); + savedSession.setParticipants(new ArrayList<>()); + when(workflowSessionRepository.save(any())).thenReturn(savedSession); + + service.createSession(owner, file, request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WorkflowSession.class); + verify(workflowSessionRepository).save(captor.capture()); + assertThat(captor.getValue().getDocumentName()).isEqualTo("Custom Name"); + } + + @Test + void createSession_documentNameFallsBackToOriginalFilename() throws IOException { + User owner = user("alice"); + MockMultipartFile file = + new MockMultipartFile("file", "uploaded.pdf", "application/pdf", new byte[] {1}); + WorkflowCreationRequest request = new WorkflowCreationRequest(); + request.setWorkflowType(WorkflowType.SIGNING); + request.setDocumentName(null); + + when(storageProvider.store(any(), any())) + .thenReturn( + StoredObject.builder() + .storageKey("k") + .originalFilename("uploaded.pdf") + .contentType("application/pdf") + .sizeBytes(1L) + .build()); + when(storedFileRepository.save(any())).thenReturn(new StoredFile()); + + WorkflowSession savedSession = new WorkflowSession(); + savedSession.setSessionId("s-2"); + savedSession.setParticipants(new ArrayList<>()); + when(workflowSessionRepository.save(any())).thenReturn(savedSession); + + service.createSession(owner, file, request); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WorkflowSession.class); + verify(workflowSessionRepository).save(captor.capture()); + assertThat(captor.getValue().getDocumentName()).isEqualTo("uploaded.pdf"); + } + + // ------------------------------------------------------------------------- + // getSessionForOwner + // ------------------------------------------------------------------------- + + @Test + void getSessionForOwner_ownerMatch_returnsSession() { + User owner = user("alice"); + owner.setId(1L); + WorkflowSession session = new WorkflowSession(); + session.setSessionId("s1"); + session.setOwner(owner); + when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(session)); + + WorkflowSession result = service.getSessionForOwner("s1", owner); + + assertThat(result).isSameAs(session); + } + + @Test + void getSessionForOwner_sessionNotFound_throwsNotFound() { + when(workflowSessionRepository.findBySessionId("missing")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getSessionForOwner("missing", user("alice"))) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void getSessionForOwner_wrongOwner_throwsForbidden() { + User owner = user("alice"); + owner.setId(1L); + User intruder = user("bob"); + intruder.setId(2L); + + WorkflowSession session = new WorkflowSession(); + session.setSessionId("s2"); + session.setOwner(owner); + when(workflowSessionRepository.findBySessionId("s2")).thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> service.getSessionForOwner("s2", intruder)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + // ------------------------------------------------------------------------- + // deleteSession + // ------------------------------------------------------------------------- + + @Test + void deleteSession_ownerAuthorized_deletesSession() { + User owner = user("alice"); + owner.setId(1L); + WorkflowSession session = new WorkflowSession(); + session.setSessionId("s3"); + session.setOwner(owner); + when(workflowSessionRepository.findBySessionId("s3")).thenReturn(Optional.of(session)); + + service.deleteSession("s3", owner); + + verify(workflowSessionRepository).delete(session); + // session.save() must NOT be called — that would UPDATE original_file_id to NULL, + // violating the NOT NULL constraint; the row is simply deleted instead + verify(workflowSessionRepository, never()).save(any()); + } + + @Test + void deleteSession_withBothFiles_nullsBackRefsAndDeletesInOrder() { + User owner = user("alice"); + owner.setId(1L); + + StoredFile originalFile = new StoredFile(); + originalFile.setStorageKey("key-orig"); + StoredFile processedFile = new StoredFile(); + processedFile.setStorageKey("key-proc"); + + WorkflowSession session = new WorkflowSession(); + session.setSessionId("s3b"); + session.setOwner(owner); + session.setOriginalFile(originalFile); + session.setProcessedFile(processedFile); + when(workflowSessionRepository.findBySessionId("s3b")).thenReturn(Optional.of(session)); + + service.deleteSession("s3b", owner); + + // Only the back-reference (StoredFile → session) must be nulled; session.originalFile + // is NOT nulled because that would emit UPDATE original_file_id=NULL (NOT NULL violation) + assertThat(originalFile.getWorkflowSession()).isNull(); + assertThat(processedFile.getWorkflowSession()).isNull(); + + // Order: save StoredFiles (clear back-refs) → delete session → delete StoredFile rows + InOrder inOrder = inOrder(workflowSessionRepository, storedFileRepository); + inOrder.verify(storedFileRepository).save(originalFile); + inOrder.verify(storedFileRepository).save(processedFile); + inOrder.verify(workflowSessionRepository).delete(session); + inOrder.verify(storedFileRepository).delete(originalFile); + inOrder.verify(storedFileRepository).delete(processedFile); + } + + @Test + void deleteSession_finalizedSession_throwsBadRequest() { + User owner = user("alice"); + owner.setId(1L); + + WorkflowSession session = new WorkflowSession(); + session.setSessionId("s3c"); + session.setOwner(owner); + session.setFinalized(true); + when(workflowSessionRepository.findBySessionId("s3c")).thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> service.deleteSession("s3c", owner)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST); + + verify(workflowSessionRepository, never()).delete(any()); + } + + @Test + void deleteSession_storageErrorOnOriginalFile_stillDeletesSession() throws Exception { + User owner = user("alice"); + owner.setId(1L); + + StoredFile originalFile = new StoredFile(); + originalFile.setStorageKey("key-orig"); + + WorkflowSession session = new WorkflowSession(); + session.setSessionId("s4"); + session.setOwner(owner); + session.setOriginalFile(originalFile); + when(workflowSessionRepository.findBySessionId("s4")).thenReturn(Optional.of(session)); + doThrow(new RuntimeException("storage unavailable")) + .when(storageProvider) + .delete("key-orig"); + + service.deleteSession("s4", owner); + + // Storage failure is non-fatal — back-ref is still cleared and DB records still deleted + verify(storedFileRepository).save(originalFile); // back-ref cleared + verify(workflowSessionRepository).delete(session); + verify(storedFileRepository).delete(originalFile); + } + + @Test + void deleteSession_notOwner_throwsForbidden() { + User owner = user("alice"); + owner.setId(1L); + User other = user("bob"); + other.setId(2L); + + WorkflowSession session = new WorkflowSession(); + session.setSessionId("s5"); + session.setOwner(owner); + when(workflowSessionRepository.findBySessionId("s5")).thenReturn(Optional.of(session)); + + assertThatThrownBy(() -> service.deleteSession("s5", other)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + + verify(workflowSessionRepository, never()).delete(any()); + } + + // ------------------------------------------------------------------------- + // listUserSessions + // ------------------------------------------------------------------------- + + @Test + void listUserSessions_returnsAllSessionsForOwner() { + User owner = user("alice"); + WorkflowSession s1 = new WorkflowSession(); + WorkflowSession s2 = new WorkflowSession(); + when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(owner)) + .thenReturn(List.of(s1, s2)); + + List result = service.listUserSessions(owner); + + assertThat(result).containsExactly(s1, s2); + } + + @Test + void listUserSessions_noSessions_returnsEmptyList() { + User owner = user("alice"); + when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(owner)) + .thenReturn(List.of()); + + assertThat(service.listUserSessions(owner)).isEmpty(); + } +} diff --git a/docker/embedded/compose/docker-compose-latest-fat-endpoints-disabled.yml b/docker/embedded/compose/docker-compose-latest-fat-endpoints-disabled.yml index 51d859c806..512ba4f144 100644 --- a/docker/embedded/compose/docker-compose-latest-fat-endpoints-disabled.yml +++ b/docker/embedded/compose/docker-compose-latest-fat-endpoints-disabled.yml @@ -3,6 +3,9 @@ services: stirling-pdf: container_name: Stirling-PDF-Fat-Disable-Endpoints image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:fat + build: + context: ../../../ + dockerfile: docker/embedded/Dockerfile.fat deploy: resources: limits: diff --git a/docker/embedded/compose/docker-compose-latest-fat-security.yml b/docker/embedded/compose/docker-compose-latest-fat-security.yml index 187e7bba60..8b68380576 100644 --- a/docker/embedded/compose/docker-compose-latest-fat-security.yml +++ b/docker/embedded/compose/docker-compose-latest-fat-security.yml @@ -2,6 +2,9 @@ services: stirling-pdf: container_name: Stirling-PDF-Security-Fat image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:fat + build: + context: ../../../ + dockerfile: docker/embedded/Dockerfile deploy: resources: limits: @@ -33,4 +36,5 @@ services: METRICS_ENABLED: "true" SYSTEM_GOOGLEVISIBILITY: "true" SHOW_SURVEY: "true" + STORAGE_LOCAL_BASEPATH: /configs/storage restart: unless-stopped diff --git a/docker/embedded/compose/docker-compose-latest-ultra-lite.yml b/docker/embedded/compose/docker-compose-latest-ultra-lite.yml index fbc8e4cdcd..a5cae8dc54 100644 --- a/docker/embedded/compose/docker-compose-latest-ultra-lite.yml +++ b/docker/embedded/compose/docker-compose-latest-ultra-lite.yml @@ -2,6 +2,9 @@ services: stirling-pdf: container_name: Stirling-PDF-Ultra-Lite image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:ultra-lite + build: + context: ../../../ + dockerfile: docker/embedded/Dockerfile.ultra-lite deploy: resources: limits: diff --git a/docker/embedded/compose/test_cicd.yml b/docker/embedded/compose/test_cicd.yml index dd692dba28..d1e08013c2 100644 --- a/docker/embedded/compose/test_cicd.yml +++ b/docker/embedded/compose/test_cicd.yml @@ -2,6 +2,9 @@ services: stirling-pdf: container_name: Stirling-PDF-Security-Fat-with-login image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:fat + build: + context: ../../../ + dockerfile: docker/embedded/Dockerfile.fat deploy: resources: limits: diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 8602832fec..2d6165c8e4 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -8,6 +8,7 @@ black = "Black" blue = "Blue" bored = "Bored Waiting?" cancel = "Cancel" +confirm = "Confirm" changedCredsMessage = "Credentials changed!" chooseFile = "Choose File" close = "Close" @@ -147,6 +148,8 @@ loadingCredits = "Checking credits..." loadingProStatus = "Checking subscription status..." noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan" +accessInvite = "Invite" + [account] accountSettings = "Account Settings" adminSettings = "Admin Settings - View and Add Users" @@ -1415,6 +1418,34 @@ title = "Processing" description = "Maximum time to wait for a processing job before reporting an error." label = "Processing Timeout (seconds)" +[admin.settings.storage] +description = "Control server storage and sharing options." +title = "File Storage & Sharing" + +[admin.settings.storage.enabled] +description = "Allow users to store files on the server." +label = "Enable Server File Storage" + +[admin.settings.storage.sharing.email] +description = "Allow sharing with email addresses." +label = "Enable Email Sharing" +mailLink = "Configure Mail Settings" +mailNote = "Requires mail configuration. " + +[admin.settings.storage.sharing.enabled] +description = "Allow users to share stored files." +label = "Enable Sharing" + +[admin.settings.storage.sharing.links] +description = "Allow sharing via signed-in links." +frontendUrlLink = "Configure in System Settings" +frontendUrlNote = "Requires a Frontend URL. " +label = "Enable Share Links" + +[admin.settings.storage.signing.enabled] +description = "Allow users to create multi-participant document signing sessions. Requires server file storage to be enabled." +label = "Enable Group Signing (Alpha)" + [admin.settings.unsavedChanges] cancel = "Keep Editing" discard = "Discard Changes" @@ -2047,7 +2078,19 @@ numbers = "Numbers/ranges: 5, 10-20" progressions = "Progressions: 3n, 4n+1" [certSign] +allSigned = "All participants have signed. Ready to finalize." +awaitingSignatures = "Awaiting signatures" +signatureProgress = "{{signedCount}}/{{totalCount}} signatures" chooseCertificate = "Choose Certificate File" +declined = "Declined" +fetchFailed = "Failed to load signing data" +finalized = "Finalized" +notified = "Pending" +partialNote = "You can finalize early with current signatures. Unsigned participants will be excluded." +pending = "Pending" +readyToFinalize = "Ready to finalize" +signed = "Signed" +viewed = "Viewed" chooseJksFile = "Choose JKS File" chooseP12File = "Choose PKCS12 File" choosePfxFile = "Choose PFX File" @@ -2070,6 +2113,7 @@ title = "Certificate Signing" invisible = "Invisible" stepTitle = "Signature Appearance" visible = "Visible" +visibility = "Visibility" [certSign.appearance.options] title = "Signature Details" @@ -2176,6 +2220,239 @@ bullet4 = "Can use custom certificates for verification" text = "When you check signatures, the tool tells you if they're valid, who signed the document, when it was signed, and whether the document has been changed since signing." title = "Checking Signatures" +[certSign.collab.finalize] +button = "Finalize and Load Signed PDF" +early = "Finalize with Current Signatures" + +[certSign.collab.sessionDetail] +addButton = "Add Participants" +addParticipants = "Add Participants" +addParticipantsError = "Failed to add participants" +backToList = "Back to Sessions" +deleteConfirm = "Are you sure? This cannot be undone." +deleteError = "Failed to delete session" +deleted = "Session deleted" +deleteSession = "Delete Session" +dueDate = "Due Date" +finalizeError = "Failed to finalize session" +loadPdfError = "Failed to load signed PDF" +loadSignedPdf = "Load Signed PDF into Active Files" +messageLabel = "Message" +noAdditionalInfo = "No additional information" +owner = "Owner" +participantRemoved = "Participant removed" +participants = "Participants" +participantsAdded = "Participants added successfully" +removeParticipant = "Remove" +removeParticipantError = "Failed to remove participant" +selectUsers = "Select users..." +sessionInfo = "Session Info" +workbenchTitle = "Session Management" + +[certSign.collab.signRequest] +addedToFiles = "Document added to active files" +addSignature = "Add Your Signature" +addToFiles = "Add to Active Files" +advancedSettings = "Advanced Settings" +backToList = "Back to Sign Requests" +certificateChoice = "Select a certificate to sign with" +changeSignature = "Change signature" +clearSignature = "Clear Signature" +completeAndSign = "Complete & Sign" +createNewSignature = "Create New Signature" +declineButton = "Decline" +decline = "Decline Request" +deleteSelected = "Delete selected signature" +drawSignature = "Draw your signature below" +dueDate = "Due Date" +fileTooLarge = "File size must be less than 5MB" +fontFamily = "Font Family" +fontSize = "Font Size: {{size}}px" +fontSizePlaceholder = "Size" +from = "From" +invalidCertFile = "Please select a P12 or PFX certificate file" +invalidFileType = "Please select an image file" +location = "Location (Optional)" +locationPlaceholder = "Where are you signing from?" +message = "Message" +noCertificate = "Please select a certificate file" +noSignatures = "Please place at least one signature on the PDF" +p12File = "P12/PFX Certificate File" +password = "Certificate Password" +passwordPlaceholder = "Enter password..." +penColor = "Pen Color" +penSize = "Pen Size: {{size}}px" +placementActive = "Click PDF to place" +placeSignatureButton = "Place Signature on PDF" +reason = "Reason (Optional)" +reasonPlaceholder = "Why are you signing?" +removeImage = "Remove Image" +removeCertFile = "Remove File" +savedSignatures = "Saved Signatures" +selectFile = "Select Image File" +selectSignatureTitle = "Select or Create Signature" +signButton = "Sign Document" +signatureInfo = "These settings are configured by the document owner" +signaturePlaced = "Signature placed on page" +signatureSettings = "Signature Settings" +signatureText = "Signature Text" +signatureTextPlaceholder = "Enter your name..." +signatureTypeLabel = "Signature Type" +signingTitle = "Signing" +textColor = "Text Color" +typeSignature = "Type your name to create a signature" +uploadCert = "Custom Certificate" +uploadCertDesc = "Use your own P12/PFX certificate" +uploadSignature = "Upload your signature image" +usePersonalCert = "Personal Certificate" +usePersonalCertDesc = "Auto-generated for your account" +useServerCert = "Organization Certificate" +useServerCertDesc = "Shared organization certificate" +workbenchTitle = "Sign Request" + +[certSign.collab.signRequest.canvas] +colorPickerTitle = "Choose stroke colour" +continue = "Continue" + +[certSign.collab.signRequest.certModal] +description = "You have placed {{count}} signature(s). Choose your certificate to complete signing." +sign = "Sign Document" +title = "Configure Certificate" + +[certSign.collab.signRequest.image] +hint = "Upload a PNG or JPG image of your signature" + +[certSign.collab.signRequest.mode] +move = "Move Signature" +place = "Place Signature" +title = "Sign or move mode" + +[certSign.collab.signRequest.modeTabs] +draw = "Draw" +image = "Upload" +text = "Type" + +[certSign.collab.signRequest.placeSignature] +message = "Click on the PDF to place your signature" +title = "Place Signature" + +[certSign.collab.signRequest.preview] +imageAlt = "Selected signature" +missing = "No preview" +textFallback = "Signature" + +[certSign.collab.signRequest.saved] +defaultCanvasLabel = "Drawing signature" +defaultImageLabel = "Uploaded signature" +defaultLabel = "Signature" +defaultTextLabel = "Typed signature" +delete = "Delete signature" +none = "No saved signatures" + +[certSign.collab.signRequest.signatureType] +draw = "Draw" +type = "Type" +upload = "Upload" + +[certSign.collab.signRequest.steps] +back = "Back" +cancelPlacement = "Cancel Placement" +certificate = "Certificate" +clickMultipleTimes = "Click on the PDF multiple times to place signatures. Drag any signature to move or resize it." +clickToPlace = "Click on the PDF where you would like your signature to appear." +continue = "Continue to Certificate Selection" +continueToPlacement = "Continue to Placement" +continueToReview = "Continue to Review" +createSignature = "Create Signature" +invisible = "Invisible" +location = "Location:" +multipleSignatures = "{{count}} signatures will be applied to the PDF" +oneSignature = "1 signature will be applied to the PDF" +placeOnPdf = "Place on PDF" +reason = "Reason:" +reviewTitle = "Review Before Signing" +signaturePlaced = "Signature placed on page {{page}}. You can adjust the position by clicking again or continue to review." +visible = "Visible" +visibility = "Visibility:" +yourSignatures = "Your Signatures ({{count}})" + +[certSign.collab.signRequest.text] +colorLabel = "Color" +fontLabel = "Font" +fontSizeLabel = "Size" +fontSizePlaceholder = "16" +label = "Signature Text" +modalHint = "Enter your name, then click Continue to place it on the PDF." +placeholder = "Enter your name..." + +[certSign.collab.addParticipants] +add = "Add {{count}} Participant(s)" +back = "Back" +configureSignatures = "Configure Signature Settings" +continue = "Continue to Signature Settings" +reasonHelp = "Pre-set a signing reason for these participants (optional, they can override when signing)" +reasonPlaceholder = "e.g. Approval, Review..." +selectUsers = "Select Users" + +[certSign.collab.sessionCreation] +includeSummaryPage = "Include Signature Summary Page" +includeSummaryPageHelp = "A summary page will be added at the end with all signature metadata. The digital certificate signature boxes on individual pages will be suppressed (wet signatures are unaffected)." + +[certSign.collab.sessionList] +active = "Active" +finalized = "Finalized" + +[certSign.collab.signatureSettings] +description = "Configure how signatures will appear for all participants" +title = "Signature Appearance" + +[certSign.collab.userSelector] +inviteUsers = "Add Users" +loadError = "Failed to load users" +noTeam = "No Team" +noUsers = "No other users found." +placeholder = "Select users..." + +[certSign.mobile] +panelActions = "Actions" +panelDocument = "Document" +panelPeople = "People" + +[certSign.sessions] +deleted = "Session deleted" +fetchFailed = "Failed to load session details" +finalized = "Session finalized" +loaded = "Signed PDF loaded" +pdfNotReady = "PDF Not Ready" +pdfNotReadyDesc = "The signed PDF is being generated. Please try again in a moment." + +[certificateChoice.tooltip] +header = "Certificate Types" + +[certificateChoice.tooltip.organization] +bullet1 = "Managed by system administrators" +bullet2 = "Shared across authorized users" +bullet3 = "Represents company identity, not individual" +bullet4 = "Best for: Official documents, team signatures" +description = "A shared certificate provided by your organization. Used for company-wide signing authority." +title = "Organization Certificate" + +[certificateChoice.tooltip.personal] +bullet1 = "Generated automatically on first use" +bullet2 = "Tied to your user account" +bullet3 = "Cannot be shared with other users" +bullet4 = "Best for: Personal documents, individual accountability" +description = "An auto-generated certificate unique to your user account. Suitable for individual signatures." +title = "Personal Certificate" + +[certificateChoice.tooltip.upload] +bullet1 = "Requires P12/PFX file and password" +bullet2 = "Can be issued by external Certificate Authorities" +bullet3 = "Higher trust level for legal documents" +bullet4 = "Best for: Legally binding contracts, external validation" +description = "Use your own PKCS#12 certificate file. Provides full control over certificate properties." +title = "Upload Custom P12" + [changeCreds] changePassword = "You are using default login credentials. Please enter a new password" changeUsername = "Update your username. You will be logged out after updating." @@ -3230,6 +3507,46 @@ totalSelected = "Total Selected" unsupported = "Unsupported" unzip = "Unzip" uploadError = "Failed to upload some files." +copyCreated = "Copy saved to this device." +copyFailed = "Could not create a copy." +leaveShare = "Remove from my list" +leaveShareFailed = "Could not remove the shared file." +leaveShareSuccess = "Removed from your shared list." +removeBoth = "Remove from both" +removeFilePrompt = "This file is saved on this device and on your server. Where would you like to remove it from?" +removeFileTitle = "Remove file" +removeLocalOnly = "This device only" +removeServerFailed = "Could not remove the file from the server." +removeServerOnly = "Server only" +removeServerOnlyPrompt = "This file is stored only on your server. Would you like to remove it from the server?" +removeServerSuccess = "Removed from server." +removeSharedPrompt = "This file is shared with you. You can remove it from this device or your shared list." +removeSharedServerOnlyBlockedPrompt = "This file is shared with you and stored only on the server." +removeSharedServerOnlyPrompt = "This file is shared with you and stored only on the server. Remove it from your list?" +changesNotUploaded = "Changes not uploaded" +cloudFile = "Cloud file" +filterAll = "All" +filterLocal = "Local" +filterSharedByMe = "Shared by me" +filterSharedWithMe = "Shared with me" +lastSynced = "Last synced" +localOnly = "Local only" +makeCopy = "Make a copy" +owner = "Owner" +ownerUnknown = "Unknown" +share = "Share" +shareSelected = "Share Selected" +sharedByYou = "Shared by you" +sharedEditNoticeBody = "You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy." +sharedEditNoticeConfirm = "Got it" +sharedEditNoticeTitle = "Read-only server copy" +sharedWithYou = "Shared with you" +sharing = "Sharing" +storageState = "Storage" +synced = "Synced" +updateOnServer = "Update on Server" +uploadSelected = "Upload Selected" +uploadToServer = "Upload to Server" [files] addFiles = "Add files" @@ -3355,6 +3672,77 @@ title = "About Flattening PDFs" discord = "Discord" issues = "GitHub" +[groupSigning.tooltip] +header = "About Group Signing" + +[groupSigning.tooltip.finalization] +bullet1 = "All signatures are applied in the participant order you specified" +bullet2 = "You can finalize with partial signatures if needed" +bullet3 = "Once finalized, the session cannot be modified" +description = "Once all participants have signed (or you choose to finalize early), you can generate the final signed PDF." +title = "Finalization Process" + +[groupSigning.tooltip.roles] +bullet1 = "Owner (you): Creates session, configures signature defaults, finalizes document" +bullet2 = "Participants: Create their signature, choose certificate, place on PDF" +bullet3 = "Participants cannot modify signature visibility, reason, or location settings" +description = "You control the signature appearance settings for all participants." +title = "Participant Roles" + +[groupSigning.tooltip.sequential] +bullet1 = "First participant must sign before the second can access the document" +bullet2 = "Ensures proper signing order for legal compliance" +bullet3 = "You can reorder participants by dragging them in the list" +description = "Participants sign documents in the order you specify. Each signer receives a notification when it is their turn." +title = "Sequential Signing" + +[groupSigning.steps] +back = "Back" +completed = "Completed" +current = "Current" +stepLabel = "Step {{number}}" + +[groupSigning.steps.configureDefaults] +continue = "Continue to Review" +invisible = "Signatures will be invisible (metadata only)" +locationLabel = "Location:" +preview = "Preview" +reasonLabel = "Reason:" +title = "Configure Signature Settings" +visible = "Signatures will be visible on page {{page}}" + +[groupSigning.steps.review] +document = "Document" +dueDate = "Due Date (Optional)" +dueDatePlaceholder = "Select due date..." +invisible = "Invisible (metadata only)" +location = "Location:" +logo = "Logo:" +logoHidden = "No logo" +logoShown = "Stirling PDF logo shown" +participants = "Participants" +reason = "Reason:" +send = "Send Signing Requests" +signatureSettings = "Signature Settings" +title = "Review Session Details" +titleShort = "Review & Send" +visibility = "Visibility:" +visible = "Visible on page {{page}}" +participantCount = "{{count}} participant(s) will sign in order" + +[groupSigning.steps.selectDocument] +continue = "Continue to Participant Selection" +noFile = "Please select a single PDF file from your active files to create a signing session." +selectedFile = "Selected document" +title = "Select Document" + +[groupSigning.steps.selectParticipants] +continue = "Continue to Signature Settings" +count = "{{count}} participant(s) selected" +label = "Select participants" +placeholder = "Choose participants to sign..." +title = "Choose Participants" + [getPdfInfo] downloadJson = "Download JSON" downloads = "Downloads" @@ -4458,6 +4846,7 @@ singlePageView = "Single Page View" unknownFile = "Unknown file" zoomIn = "Zoom In" zoomOut = "Zoom Out" +resetZoom = "Reset zoom" [rightRail] closeSelected = "Close Selected Files" @@ -5353,20 +5742,72 @@ title = "Print File" 2 = "Enter Printer Name" [quickAccess] +access = "Access" +accessAddPerson = "Add another person" +accessBack = "Back" +accessCopyLink = "Copy link" +accessEmail = "Email Address" +accessEmailPlaceholder = "name@company.com" +accessFileLabel = "File" +accessGeneral = "General Access" +accessInviteTitle = "Invite People" +accessOwner = "Owner" +accessPanel = "Document access" +accessPeople = "People with access" +accessRemove = "Remove" +accessRestricted = "Restricted" +accessRestrictedHint = "Only people with access can open" +accessRole = "Role" +accessRoleCommenter = "Commenter" +accessRoleEditor = "Editor" +accessRoleViewer = "Viewer" +accessSelectedFile = "Selected file" +accessSendInvite = "Send Invite" +accessTitle = "Document Access" +accessYou = "You" account = "Account" +activeSessions = "Active Sessions" +activeTab = "Active" activity = "Activity" adminSettings = "Admin Settings" +allSessions = "All Sessions" allTools = "Tools" automate = "Automate" +back = "Back" +certSign = "Certificate Sign" +completedSessions = "Completed Sessions" +completedTab = "Completed" config = "Config" +createNew = "Create New Request" +createSession = "Create Signing Request" +dueDate = "Due date (optional)" files = "Files" help = "Help" +noActiveSessions = "No pending sign requests or active sessions" +noCompletedSessions = "No completed sessions" +noFile = "No file selected" read = "Read" reader = "Reader" +refresh = "Refresh" +requestSignatures = "Request Signatures" +selectSingleFileToRequest = "Select a single PDF file to request signatures" +selectedFile = "Selected file" +selectUsers = "Select users to sign" +selectUsersPlaceholder = "Choose participants..." +sendingRequest = "Sending..." settings = "Settings" showMeAround = "Show me around" sign = "Sign" +signatureRequests = "Signature Requests" +signYourself = "Sign Yourself" +newRequest = "New Request" tours = "Tours" +wetSign = "Add Signature" +filterMine = "Mine" +filterOverdue = "Overdue" +filterSigned = "Signed" +filterDeclined = "Declined" +searchDocuments = "Search documents…" [quickAccess.helpMenu] adminTour = "Admin Tour" @@ -5998,11 +6439,75 @@ toolNotAvailableLocally = "Your Stirling-PDF server is offline and \"{{endpoint} expired = "Your session has expired. Please refresh the page and try again." refreshPage = "Refresh Page" +[sessionManagement.tooltip] +header = "Managing Signing Sessions" + +[sessionManagement.tooltip.addParticipants] +bullet1 = "New participants added to the end of signing order" +bullet2 = "Cannot add participants after session is finalized" +bullet3 = "Each participant receives a notification when it's their turn" +description = "You can add more participants to an active session at any time before finalization." +title = "Adding Participants" + +[sessionManagement.tooltip.finalization] +bullet1 = "Full finalization: All participants have signed" +bullet2 = "Partial finalization: Some participants haven't signed yet" +bullet3 = "Unsigned participants will be excluded from the final document" +bullet4 = "Once finalized, you can load the signed PDF into active files" +description = "Finalization combines all signatures into a single signed PDF. This action cannot be undone." +title = "Session Finalization" + +[sessionManagement.tooltip.participantRemoval] +bullet1 = "Cannot remove participants who have already signed" +bullet2 = "Removed participants no longer receive notifications" +bullet3 = "Signing order adjusts automatically" +description = "Participants can be removed from sessions before they sign." +title = "Removing Participants" + +[sessionManagement.tooltip.signatureOrder] +bullet1 = "Each signature is applied sequentially to the PDF" +bullet2 = "Later signers can see earlier signatures" +bullet3 = "Critical for approval workflows and legal chains of custody" +description = "The order you specify when creating the session determines who signs first." +title = "Signature Order" + +[signatureSettings.tooltip] +header = "Signature Appearance Settings" + +[signatureSettings.tooltip.location] +bullet1 = "Examples: \"New York, USA\", \"London Office\", \"Remote\"" +bullet2 = "Not the same as page position" +bullet3 = "May be required for certain legal jurisdictions" +description = "Optional geographic location where the signature was applied. Stored in certificate metadata." +title = "Signature Location" + +[signatureSettings.tooltip.logo] +bullet1 = "Displayed alongside signature and text" +bullet2 = "Supports PNG, JPG formats" +bullet3 = "Enhances professional appearance" +description = "Add a company logo to visible signatures for branding and authenticity." +title = "Company Logo" + +[signatureSettings.tooltip.reason] +bullet1 = "Examples: \"Approval\", \"Contract Agreement\", \"Review Complete\"" +bullet2 = "Visible in PDF signature properties" +bullet3 = "Useful for audit trails and compliance" +description = "Optional text explaining why the document is being signed. Stored in certificate metadata." +title = "Signature Reason" + +[signatureSettings.tooltip.visibility] +bullet1 = "Visible: Signature appears on PDF with custom appearance" +bullet2 = "Invisible: Certificate embedded without visual mark" +bullet3 = "Invisible signatures still provide cryptographic validation" +description = "Controls whether the signature is visible on the document or embedded invisibly." +title = "Signature Visibility" + [settings.configuration] advanced = "Advanced" database = "Database" endpoints = "Endpoints" features = "Features" +storageSharing = "File Storage & Sharing" systemSettings = "System Settings" title = "Configuration" @@ -6477,6 +6982,15 @@ saved = "Saved" text = "Text" title = "Signature Type" +[signRequest] +declined = "Sign request declined" +fetchFailed = "Failed to load sign request" +signed = "Document signed successfully" + +[signSession] +createFailed = "Failed to create signing request" +created = "Signing request sent" + [signup] accountCreatedSuccessfully = "Account created successfully! You can now sign in." alreadyHaveAccount = "Already have an account? Sign in" @@ -6755,6 +7269,106 @@ title = "Split PDF by Chapters" [splitPdfByChapters] tags = "split,chapters,bookmarks,organize" +[storageShare] +accessed = "Accessed" +accessDenied = "You do not have access to this shared file. Ask the owner to share it with you." +accessFailed = "Unable to load activity." +accessDeniedBody = "You do not have access to this file. Ask the owner to share it with you." +accessDeniedTitle = "No access" +accessLimitedCommenter = "Comment access is coming soon. Ask the owner for editor access if you need to download." +accessLimitedTitle = "Limited access" +accessLimitedViewer = "This link is view-only. Ask the owner for editor access if you need to download." +createdAt = "Created" +download = "Download" +downloadFailed = "Unable to download this file." +expiredBody = "This share link is invalid or has expired." +expiredTitle = "Link expired" +goToLogin = "Go to login" +loadFailed = "Unable to open shared file." +loading = "Loading share link..." +loginPrompt = "Sign in to access this shared file." +loginRequired = "Login required" +openInApp = "Open in Stirling PDF" +ownerLabel = "Owner" +ownerUnknown = "Unknown" +requiresLogin = "This shared file requires login." +roleCommenter = "Commenter" +roleEditor = "Editor" +roleViewer = "Viewer" +shareHeading = "Shared file" +titleDefault = "Shared file" +tryAgain = "Please try again later." +addUser = "Add" +commenterHint = "Commenting is coming soon." +copied = "Link copied to clipboard" +copy = "Copy" +copyFailed = "Copy failed" +description = "Create a share link for this file. Signed-in users with the link can access it." +downloadsCount = "Downloads: {{count}}" +emailWarningBody = "This looks like an email address. If this person is not already a Stirling PDF user, they will not be able to access the file." +emailWarningConfirm = "Share anyway" +emailWarningTitle = "Email address" +errorTitle = "Share failed" +failure = "Unable to generate a share link. Please try again." +fileLabel = "File" +generate = "Generate Link" +generated = "Share link generated" +hideActivity = "Hide activity" +invalidUsername = "Enter a valid username or email address." +lastAccessed = "Last accessed" +linkAccessTitle = "Share link access" +linkLabel = "Share link" +linksDisabled = "Share links are disabled." +linksDisabledBody = "Share links are disabled by your server settings." +manage = "Manage sharing" +manageDescription = "Create and manage links to share this file." +manageLoadFailed = "Unable to load share links." +manageTitle = "Manage Sharing" +noActivity = "No activity yet." +noLinks = "No active share links yet." +noSharedUsers = "No users have access yet." +removeLink = "Remove link" +removeUser = "Remove" +revokeFailed = "Unable to remove the share link." +revoked = "Share link removed" +roleLabel = "Role" +sharingDisabled = "Sharing is disabled." +sharingDisabledBody = "Sharing has been disabled by your server settings." +sharedUsersTitle = "Shared users" +title = "Share File" +unknownUser = "Unknown user" +userAddFailed = "Unable to share with that user." +userAdded = "User added to shared list." +usernameLabel = "Username or email" +usernamePlaceholder = "Enter a username or email" +userRemoveFailed = "Unable to remove that user." +userRemoved = "User removed from shared list." +viewActivity = "View activity" +viewed = "Viewed" +viewsCount = "Views: {{count}}" +downloaded = "Downloaded" +bulkDescription = "Create one link to share all selected files with signed-in users." +bulkTitle = "Share selected files" +copyLink = "Copy share link" +fileCount = "{{count}} files selected" +ownerOnly = "Only the owner can manage sharing." +selectSingleFile = "Select a single file to manage sharing." + +[storageUpload] +description = "This uploads the current file to server storage for your own access." +errorTitle = "Upload failed" +failure = "Upload failed. Please check your login and storage settings." +fileLabel = "File" +hint = "Public links and access modes are controlled by your server settings." +success = "Uploaded to server" +title = "Upload to Server" +updateButton = "Update on Server" +uploadButton = "Upload to Server" +bulkDescription = "This uploads the selected files to your server storage." +bulkTitle = "Upload selected files" +fileCount = "{{count}} files selected" +more = " +{{count}} more" + [storage] approximateSize = "Approximate size" fileTooLarge = "File too large. Maximum size per file is" @@ -7144,6 +7758,30 @@ title = "View/Edit PDF" [warning] tooltipTitle = "Warning" +[wetSignature.tooltip] +header = "Signature Creation Methods" + +[wetSignature.tooltip.draw] +bullet1 = "Customize pen color and thickness" +bullet2 = "Clear and redraw until satisfied" +bullet3 = "Works on touch devices (tablets, phones)" +description = "Create a handwritten signature using your mouse or touchscreen. Best for personal, authentic signatures." +title = "Draw Signature" + +[wetSignature.tooltip.type] +bullet1 = "Choose from multiple fonts" +bullet2 = "Customize text size and color" +bullet3 = "Perfect for standardized signatures" +description = "Generate a signature from typed text. Fast and consistent, suitable for business documents." +title = "Type Signature" + +[wetSignature.tooltip.upload] +bullet1 = "Supports PNG, JPG, and other image formats" +bullet2 = "Transparent backgrounds recommended for best results" +bullet3 = "Image will be resized to fit signature area" +description = "Upload a pre-created signature image. Ideal if you have a scanned signature or company logo." +title = "Upload Signature Image" + [watermark] completed = "Watermark added" desc = "Add text or image watermarks to PDF files" diff --git a/frontend/src/core/auth/UseSession.tsx b/frontend/src/core/auth/UseSession.tsx index 3d687c22ea..01d9b94d11 100644 --- a/frontend/src/core/auth/UseSession.tsx +++ b/frontend/src/core/auth/UseSession.tsx @@ -1,6 +1,6 @@ export interface AuthContextType { session: null; - user: null; + user: { id?: string; email?: string; [key: string]: unknown } | null; loading: boolean; error: Error | null; signOut: () => Promise; diff --git a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx index 7e3324a97b..fd8864be26 100644 --- a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx @@ -15,7 +15,9 @@ interface DrawingCanvasProps { onPenSizeInputChange: (input: string) => void; onSignatureDataChange: (data: string | null) => void; onDrawingComplete?: () => void; + onModalClose?: () => void; disabled?: boolean; + autoOpen?: boolean; width?: number; height?: number; modalWidth?: number; @@ -33,7 +35,9 @@ export const DrawingCanvas: React.FC = ({ onPenSizeInputChange, onSignatureDataChange, onDrawingComplete, + onModalClose, disabled = false, + autoOpen = false, width = 400, height = 150, initialSignatureData, @@ -83,6 +87,10 @@ export const DrawingCanvas: React.FC = ({ setModalOpen(true); }; + useEffect(() => { + if (autoOpen) openModal(); + }, [autoOpen]); + const trimCanvas = (canvas: HTMLCanvasElement): string => { const ctx = canvas.getContext('2d'); if (!ctx) return canvas.toDataURL('image/png'); @@ -160,6 +168,7 @@ export const DrawingCanvas: React.FC = ({ padRef.current = null; } setModalOpen(false); + onModalClose?.(); }; const clear = () => { diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index 40dd0966db..2af8a87ab6 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -8,6 +8,8 @@ import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; import CloseIcon from '@mui/icons-material/Close'; import VisibilityIcon from '@mui/icons-material/Visibility'; import UnarchiveIcon from '@mui/icons-material/Unarchive'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import LinkIcon from '@mui/icons-material/Link'; import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; import LockOpenIcon from '@mui/icons-material/LockOpen'; @@ -26,6 +28,9 @@ import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverAction import { downloadFile } from '@app/services/downloadService'; import FileEditorFileName from '@app/components/fileEditor/FileEditorFileName'; import { PrivateContent } from '@app/components/shared/PrivateContent'; +import UploadToServerModal from '@app/components/shared/UploadToServerModal'; +import ShareFileModal from '@app/components/shared/ShareFileModal'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; @@ -60,6 +65,7 @@ const FileEditorThumbnail = ({ isSupported = true, }: FileEditorThumbnailProps) => { const { t } = useTranslation(); + const { config } = useAppConfig(); const terminology = useFileActionTerminology(); const icons = useFileActionIcons(); const DownloadOutlinedIcon = icons.download; @@ -80,6 +86,10 @@ const FileEditorThumbnail = ({ const [showHoverMenu, setShowHoverMenu] = useState(false); const isMobile = useIsMobile(); const [showCloseModal, setShowCloseModal] = useState(false); + const [showUploadModal, setShowUploadModal] = useState(false); + const [showShareModal, setShowShareModal] = useState(false); + const [showSharedEditNotice, setShowSharedEditNotice] = useState(false); + const sharedEditNoticeShownRef = useRef(false); // Resolve the actual File object for pin/unpin operations const actualFile = useMemo(() => { @@ -115,6 +125,17 @@ const FileEditorThumbnail = ({ const isCBZ = extLower === 'cbz'; const isCBR = extLower === 'cbr'; + const uploadEnabled = config?.storageEnabled === true; + const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; + const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true; + const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false; + const isSharedFile = file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink; + const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0; + const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0; + const isUploaded = Boolean(file.remoteStorageId); + const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt; + const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate); + const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf; const pageLabel = useMemo( () => @@ -248,6 +269,30 @@ const FileEditorThumbnail = ({ onDownloadFile(file.id); }, }, + ...(canUpload || canShare + ? [ + ...(canUpload ? [{ + id: 'upload', + icon: , + label: isUploaded + ? t('fileManager.updateOnServer', 'Update on Server') + : t('fileManager.uploadToServer', 'Upload to Server'), + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + setShowUploadModal(true); + }, + }] : []), + ...(canShare ? [{ + id: 'share', + icon: , + label: t('fileManager.share', 'Share'), + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + setShowShareModal(true); + }, + }] : []), + ] + : []), { id: 'unzip', icon: , @@ -271,7 +316,22 @@ const FileEditorThumbnail = ({ }, color: 'red', } - ], [t, file.id, file.name, isZipFile, isCBR, onViewFile, onDownloadFile, onUnzipFile, handleCloseWithConfirmation]); + ], [ + t, + file.id, + file.name, + isZipFile, + isCBZ, + isCBR, + terminology, + onViewFile, + onDownloadFile, + onUnzipFile, + handleCloseWithConfirmation, + canUpload, + canShare, + isUploaded + ]); // ---- Card interactions ---- const handleCardClick = () => { @@ -280,6 +340,10 @@ const FileEditorThumbnail = ({ if (hasError) { try { fileActions.clearFileError(file.id); } catch (_e) { void _e; } } + if (isSharedFile && !sharedEditNoticeShownRef.current) { + sharedEditNoticeShownRef.current = true; + setShowSharedEditNotice(true); + } onToggleFile(file.id); }; @@ -537,6 +601,42 @@ const FileEditorThumbnail = ({ )} + setShowSharedEditNotice(false)} + title={t('fileManager.sharedEditNoticeTitle', 'Read-only server copy')} + centered + size="auto" + > + + + {t( + 'fileManager.sharedEditNoticeBody', + 'You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.' + )} + + + + + + + + {canUpload && ( + setShowUploadModal(false)} + file={file} + /> + )} + {canShare && ( + setShowShareModal(false)} + file={file} + /> + )} ); }; diff --git a/frontend/src/core/components/fileManager/CompactFileDetails.tsx b/frontend/src/core/components/fileManager/CompactFileDetails.tsx index 724c3a9a34..5156dccff9 100644 --- a/frontend/src/core/components/fileManager/CompactFileDetails.tsx +++ b/frontend/src/core/components/fileManager/CompactFileDetails.tsx @@ -34,6 +34,13 @@ const CompactFileDetails: React.FC = ({ const { t } = useTranslation(); const hasSelection = selectedFiles.length > 0; const hasMultipleFiles = numberOfFiles > 1; + const showOwner = Boolean( + currentFile && + (currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink) + ); + const ownerLabel = currentFile + ? currentFile.remoteOwnerUsername || t('fileManager.ownerUnknown', 'Unknown') + : ''; return ( @@ -88,6 +95,11 @@ const CompactFileDetails: React.FC = ({ {currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(' → ')} )} + {currentFile && showOwner && ( + + {t('fileManager.owner', 'Owner')}: {ownerLabel} + + )} {/* Navigation arrows for multiple files */} diff --git a/frontend/src/core/components/fileManager/FileActions.tsx b/frontend/src/core/components/fileManager/FileActions.tsx index 19d5e634e5..2e69c32d35 100644 --- a/frontend/src/core/components/fileManager/FileActions.tsx +++ b/frontend/src/core/components/fileManager/FileActions.tsx @@ -1,25 +1,68 @@ -import React from "react"; -import { Group, Text, ActionIcon, Tooltip } from "@mantine/core"; +import React, { useEffect } from "react"; +import { Group, Text, ActionIcon, Tooltip, SegmentedControl } from "@mantine/core"; import SelectAllIcon from "@mui/icons-material/SelectAll"; import DeleteIcon from "@mui/icons-material/Delete"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import LinkIcon from "@mui/icons-material/Link"; import { useTranslation } from "react-i18next"; import { useFileManagerContext } from "@app/contexts/FileManagerContext"; import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal"; +import BulkShareModal from "@app/components/shared/BulkShareModal"; const FileActions: React.FC = () => { const { t } = useTranslation(); const terminology = useFileActionTerminology(); const icons = useFileActionIcons(); const DownloadIcon = icons.download; + const { config } = useAppConfig(); + const [showBulkUploadModal, setShowBulkUploadModal] = React.useState(false); + const [showBulkShareModal, setShowBulkShareModal] = React.useState(false); const { recentFiles, selectedFileIds, + selectedFiles, filteredFiles, onSelectAll, onDeleteSelected, - onDownloadSelected - } = useFileManagerContext(); + onDownloadSelected, + refreshRecentFiles, + storageFilter, + onStorageFilterChange + } = + useFileManagerContext(); + const uploadEnabled = config?.storageEnabled === true; + const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; + const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true; + const showStorageFilter = uploadEnabled; + const storageFilterOptions = sharingEnabled + ? [ + { value: "all", label: t("fileManager.filterAll", "All") }, + { value: "local", label: t("fileManager.filterLocal", "Local") }, + { value: "sharedWithMe", label: t("fileManager.filterSharedWithMe", "Shared with me") }, + { value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") } + ] + : [ + { value: "all", label: t("fileManager.filterAll", "All") }, + { value: "local", label: t("fileManager.filterLocal", "Local") } + ]; + useEffect(() => { + if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) { + onStorageFilterChange("all"); + } + }, [sharingEnabled, storageFilter, onStorageFilterChange]); + const hasSelection = selectedFileIds.length > 0; + const hasOnlyOwnedSelection = selectedFiles.every((file) => file.remoteOwnedByCurrentUser !== false); + const hasDownloadAccess = selectedFiles.every((file) => { + const role = (file.remoteOwnedByCurrentUser !== false + ? 'editor' + : (file.remoteAccessRole ?? 'viewer')).toLowerCase(); + return role === 'editor' || role === 'commenter' || role === 'viewer'; + }); + const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection; + const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection; const handleSelectAll = () => { onSelectAll(); @@ -44,7 +87,6 @@ const FileActions: React.FC = () => { } const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length; - const hasSelection = selectedFileIds.length > 0; return (

{ position: "relative", }} > - {/* Left: Select All */} -
+ {/* Left: Select All + Filter */} + @@ -74,7 +116,17 @@ const FileActions: React.FC = () => { -
+ {showStorageFilter && ( + + onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe") + } + data={storageFilterOptions} + /> + )} + {/* Center: Selected count */}
{ {/* Right: Delete and Download */} + {uploadEnabled && ( + + setShowBulkUploadModal(true)} + disabled={!canBulkUpload} + radius="sm" + > + + + + )} + {shareLinksEnabled && ( + + setShowBulkShareModal(true)} + disabled={!canBulkShare} + radius="sm" + > + + + + )} { size="sm" color="dimmed" onClick={handleDownloadSelected} - disabled={!hasSelection} + disabled={!hasSelection || !hasDownloadAccess} radius="sm" > + + {uploadEnabled && ( + setShowBulkUploadModal(false)} + files={selectedFiles} + onUploaded={refreshRecentFiles} + /> + )} + {shareLinksEnabled && ( + setShowBulkShareModal(false)} + files={selectedFiles} + onShared={refreshRecentFiles} + /> + )}
); }; diff --git a/frontend/src/core/components/fileManager/FileInfoCard.tsx b/frontend/src/core/components/fileManager/FileInfoCard.tsx index 1588e74270..182fcded9d 100644 --- a/frontend/src/core/components/fileManager/FileInfoCard.tsx +++ b/frontend/src/core/components/fileManager/FileInfoCard.tsx @@ -1,10 +1,13 @@ -import React from 'react'; -import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core'; +import React, { useMemo, useState } from 'react'; +import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea, Button } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { detectFileExtension, getFileSize } from '@app/utils/fileUtils'; import { StirlingFileStub } from '@app/types/fileContext'; import ToolChain from '@app/components/shared/ToolChain'; import { PrivateContent } from '@app/components/shared/PrivateContent'; +import { useFileManagerContext } from '@app/contexts/FileManagerContext'; +import ShareManagementModal from '@app/components/shared/ShareManagementModal'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; interface FileInfoCardProps { currentFile: StirlingFileStub | null; @@ -16,6 +19,40 @@ const FileInfoCard: React.FC = ({ modalHeight }) => { const { t } = useTranslation(); + const { config } = useAppConfig(); + const { onMakeCopy } = useFileManagerContext(); + const [showShareManageModal, setShowShareManageModal] = useState(false); + const isSharedWithYou = useMemo(() => { + if (!currentFile) return false; + return currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink; + }, [currentFile]); + const isOwnedRemote = useMemo(() => { + if (!currentFile) return false; + return Boolean(currentFile.remoteStorageId) && currentFile.remoteOwnedByCurrentUser !== false; + }, [currentFile]); + const localUpdatedAt = currentFile?.createdAt ?? currentFile?.lastModified ?? 0; + const remoteUpdatedAt = currentFile?.remoteStorageUpdatedAt ?? 0; + const isUploaded = Boolean(currentFile?.remoteStorageId); + const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt; + const isOutOfSync = isUploaded && !isUpToDate && isOwnedRemote; + const isLocalOnly = !currentFile?.remoteStorageId && !currentFile?.remoteSharedViaLink; + const isSharedByYou = useMemo(() => { + if (!currentFile) return false; + return isOwnedRemote && Boolean(currentFile.remoteHasShareLinks); + }, [currentFile, isOwnedRemote]); + const uploadEnabled = config?.storageEnabled === true; + const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; + const ownerLabel = useMemo(() => { + if (!currentFile) return ''; + if (currentFile.remoteOwnerUsername) { + return currentFile.remoteOwnerUsername; + } + return t('fileManager.ownerUnknown', 'Unknown'); + }, [currentFile, t]); + const lastSyncedLabel = useMemo(() => { + if (!currentFile?.remoteStorageUpdatedAt) return ''; + return new Date(currentFile.remoteStorageUpdatedAt).toLocaleString(); + }, [currentFile?.remoteStorageUpdatedAt]); return ( @@ -57,7 +94,7 @@ const FileInfoCard: React.FC = ({ - {t('fileManager.lastModified', 'Last Modified')} + {t('fileManager.lastModified', 'Last modified')} {currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ''} @@ -72,6 +109,20 @@ const FileInfoCard: React.FC = ({ } + {sharingEnabled && isSharedWithYou && ( + <> + + + {t('fileManager.owner', 'Owner')} + + {ownerLabel} + + {t('fileManager.sharedWithYou', 'Shared with you')} + + + + + )} {/* Tool Chain Display */} {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && ( @@ -87,8 +138,83 @@ const FileInfoCard: React.FC = ({ )} + + {currentFile && isSharedWithYou && ( + <> + + + + )} + + {currentFile && isOwnedRemote && ( + <> + + + {t('fileManager.cloudFile', 'Cloud file')} + {uploadEnabled && isOutOfSync ? ( + + {t('fileManager.changesNotUploaded', 'Changes not uploaded')} + + ) : uploadEnabled ? ( + + {t('fileManager.synced', 'Synced')} + + ) : null} + + {lastSyncedLabel && ( + + {t('fileManager.lastSynced', 'Last synced')} + {lastSyncedLabel} + + )} + {isSharedByYou && sharingEnabled && ( + <> + + + {t('fileManager.sharing', 'Sharing')} + + {t('fileManager.sharedByYou', 'Shared by you')} + + + + + )} + + )} + {currentFile && isLocalOnly && ( + <> + + + {t('fileManager.storageState', 'Storage')} + + {t('fileManager.localOnly', 'Local only')} + + + + )} + {currentFile && isOwnedRemote && isSharedByYou && sharingEnabled && ( + setShowShareManageModal(false)} + file={currentFile} + /> + )} ); }; diff --git a/frontend/src/core/components/fileManager/FileListItem.tsx b/frontend/src/core/components/fileManager/FileListItem.tsx index bab58c1b21..73ae8bd2a2 100644 --- a/frontend/src/core/components/fileManager/FileListItem.tsx +++ b/frontend/src/core/components/fileManager/FileListItem.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import DeleteIcon from '@mui/icons-material/Delete'; @@ -7,6 +7,9 @@ import HistoryIcon from '@mui/icons-material/History'; import RestoreIcon from '@mui/icons-material/Restore'; import UnarchiveIcon from '@mui/icons-material/Unarchive'; import CloseIcon from '@mui/icons-material/Close'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; +import CloudDoneIcon from '@mui/icons-material/CloudDone'; +import LinkIcon from '@mui/icons-material/Link'; import { useTranslation } from 'react-i18next'; import { getFileSize, getFileDate } from '@app/utils/fileUtils'; import { FileId, StirlingFileStub } from '@app/types/fileContext'; @@ -16,6 +19,13 @@ import ToolChain from '@app/components/shared/ToolChain'; import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; import { PrivateContent } from '@app/components/shared/PrivateContent'; import { useFileManagement } from '@app/contexts/FileContext'; +import UploadToServerModal from '@app/components/shared/UploadToServerModal'; +import ShareFileModal from '@app/components/shared/ShareFileModal'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import ShareManagementModal from '@app/components/shared/ShareManagementModal'; +import apiClient from '@app/services/apiClient'; +import { absoluteWithBasePath } from '@app/constants/app'; +import { alert } from '@app/components/toast'; interface FileListItemProps { file: StirlingFileStub; @@ -45,8 +55,12 @@ const FileListItem: React.FC = ({ }) => { const [isHovered, setIsHovered] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); + const [showUploadModal, setShowUploadModal] = useState(false); + const [showShareModal, setShowShareModal] = useState(false); + const [showShareManageModal, setShowShareManageModal] = useState(false); const { t } = useTranslation(); - const { expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext(); + const { config } = useAppConfig(); + const {expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext(); const { removeFiles } = useFileManagement(); // Check if this is a ZIP file @@ -65,6 +79,73 @@ const FileListItem: React.FC = ({ const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+) const currentVersion = file.versionNumber || 1; // Display original files as v1 const isExpanded = expandedFileIds.has(leafFileId); + const uploadEnabled = config?.storageEnabled === true; + const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; + const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true; + const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false; + const isSharedWithYou = + sharingEnabled && (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink); + const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0; + const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0; + const isUploaded = Boolean(file.remoteStorageId); + const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt; + const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal; + const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink; + const accessRole = (isOwnedOrLocal ? 'editor' : (file.remoteAccessRole ?? 'viewer')).toLowerCase(); + const hasReadAccess = isOwnedOrLocal || accessRole === 'editor' || accessRole === 'commenter' || accessRole === 'viewer'; + const canUpload = uploadEnabled && isOwnedOrLocal && isLatestVersion && (!isUploaded || !isUpToDate); + const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion; + const canManageShare = sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId); + const canCopyShareLink = + shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId); + const canDownloadFile = Boolean(onDownload) && hasReadAccess; + + const shareBaseUrl = useMemo(() => { + const frontendUrl = (config?.frontendUrl || '').trim(); + if (frontendUrl) { + const normalized = frontendUrl.endsWith('/') + ? frontendUrl.slice(0, -1) + : frontendUrl; + return `${normalized}/share/`; + } + return absoluteWithBasePath('/share/'); + }, [config?.frontendUrl]); + + const handleCopyShareLink = useCallback(async () => { + if (!file.remoteStorageId) return; + try { + const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>( + `/api/v1/storage/files/${file.remoteStorageId}`, + { suppressErrorToast: true } as any + ); + const links = response.data?.shareLinks ?? []; + const token = links[links.length - 1]?.token; + if (!token) { + alert({ + alertType: 'warning', + title: t('storageShare.noLinks', 'No active share links yet.'), + expandable: false, + durationMs: 2500, + }); + return; + } + await navigator.clipboard.writeText(`${shareBaseUrl}${token}`); + alert({ + alertType: 'success', + title: t('storageShare.copied', 'Link copied to clipboard'), + expandable: false, + durationMs: 2000, + }); + } catch (error) { + console.error('Failed to copy share link:', error); + alert({ + alertType: 'warning', + title: t('storageShare.copyFailed', 'Copy failed'), + expandable: false, + durationMs: 2500, + }); + } + }, [file.remoteStorageId, shareBaseUrl, t]); return ( <> @@ -133,6 +214,45 @@ const FileListItem: React.FC = ({ v{currentVersion} + {sharingEnabled && isSharedWithYou ? ( + + {t('fileManager.sharedWithYou', 'Shared with you')} + + ) : null} + {sharingEnabled && isSharedWithYou && accessRole && accessRole !== 'editor' ? ( + + {accessRole === 'commenter' + ? t('storageShare.roleCommenter', 'Commenter') + : t('storageShare.roleViewer', 'Viewer')} + + ) : isLocalOnly ? ( + + {t('fileManager.localOnly', 'Local only')} + + ) : uploadEnabled && isOutOfSync ? ( + } + > + {t('fileManager.changesNotUploaded', 'Changes not uploaded')} + + ) : uploadEnabled && isUploaded ? ( + } + > + {t('fileManager.synced', 'Synced')} + + ) : null} + {sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && ( + + {t('fileManager.sharedByYou', 'Shared by you')} + + )} @@ -194,18 +314,68 @@ const FileListItem: React.FC = ({ )} - {onDownload && ( + {canDownloadFile && ( } onClick={(e) => { e.stopPropagation(); - onDownload(); + onDownload?.(); }} > {t('fileManager.download', 'Download')} )} + {canUpload && ( + } + onClick={(e) => { + e.stopPropagation(); + setShowUploadModal(true); + }} + > + {isUploaded + ? t('fileManager.updateOnServer', 'Update on Server') + : t('fileManager.uploadToServer', 'Upload to Server')} + + )} + + {canShare && ( + } + onClick={(e) => { + e.stopPropagation(); + setShowShareModal(true); + }} + > + {t('fileManager.share', 'Share')} + + )} + + {canCopyShareLink && ( + } + onClick={(e) => { + e.stopPropagation(); + void handleCopyShareLink(); + }} + > + {t('storageShare.copyLink', 'Copy share link')} + + )} + + {canManageShare && ( + } + onClick={(e) => { + e.stopPropagation(); + setShowShareManageModal(true); + }} + > + {t('storageShare.manage', 'Manage sharing')} + + )} + {/* Show/Hide History option for latest version files */} {isLatestVersion && hasVersionHistory && ( <> @@ -275,6 +445,29 @@ const FileListItem: React.FC = ({ { } + {canUpload && ( + setShowUploadModal(false)} + file={file} + onUploaded={refreshRecentFiles} + /> + )} + {canShare && ( + setShowShareModal(false)} + file={file} + onUploaded={refreshRecentFiles} + /> + )} + {canManageShare && ( + setShowShareManageModal(false)} + file={file} + /> + )} ); }; diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index 0eabada86c..521ac5db15 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -93,20 +93,16 @@ export default function Workbench() { }; const renderMainContent = () => { - // Check for custom workbench views first + // Check if we're showing a custom workbench first + // Custom workbenches may not require files in FileContext (e.g., sign request workbench) if (!isBaseWorkbench(currentView)) { const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null); if (customView) { - // PDF text editor handles its own empty state (shows dropzone when no document) - const handlesOwnEmptyState = currentView === 'custom:pdfTextEditor'; - if (handlesOwnEmptyState || activeFiles.length > 0) { - const CustomComponent = customView.component; - return ; - } + const CustomComponent = customView.component; + return ; } } - // For base workbenches (or custom views that don't handle empty state), show landing page when no files if (activeFiles.length === 0) { return ( {/* Top Controls */} - {activeFiles.length > 0 && ( + {activeFiles.length > 0 && !customWorkbenchViews.find(v => v.workbenchId === currentView)?.hideTopControls && ( void; + files: StirlingFileStub[]; + onShared?: () => Promise | void; +} + +const BulkShareModal: React.FC = ({ + opened, + onClose, + files, + onShared, +}) => { + const { t } = useTranslation(); + const { config } = useAppConfig(); + const { actions } = useFileActions(); + const shareLinksEnabled = config?.storageShareLinksEnabled === true; + const [isWorking, setIsWorking] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [shareToken, setShareToken] = useState(null); + const [shareRole, setShareRole] = useState<'editor' | 'commenter' | 'viewer'>('editor'); + + useEffect(() => { + if (!opened) { + setIsWorking(false); + setErrorMessage(null); + setShareToken(null); + } + }, [opened]); + + useEffect(() => { + if (opened) { + setShareRole('editor'); + } + }, [opened]); + + const shareUrl = useMemo(() => { + if (!shareToken) return ''; + const frontendUrl = (config?.frontendUrl || '').trim(); + if (frontendUrl) { + const normalized = frontendUrl.endsWith('/') + ? frontendUrl.slice(0, -1) + : frontendUrl; + return `${normalized}/share/${shareToken}`; + } + return absoluteWithBasePath(`/share/${shareToken}`); + }, [config?.frontendUrl, shareToken]); + + const createShareLink = useCallback(async (storedFileId: number) => { + const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, { + accessRole: shareRole, + }); + return response.data as { token?: string }; + }, [shareRole]); + + const handleGenerateLink = useCallback(async () => { + if (!shareLinksEnabled) { + alert({ + alertType: 'warning', + title: t('storageShare.linksDisabled', 'Share links are disabled.'), + expandable: false, + durationMs: 2500, + }); + return; + } + setIsWorking(true); + setErrorMessage(null); + setShareToken(null); + + try { + const rootIds = Array.from( + new Set(files.map((file) => (file.originalFileId || file.id) as FileId)) + ); + const remoteIds = Array.from( + new Set(files.map((file) => file.remoteStorageId).filter(Boolean) as number[]) + ); + const existingRemoteId = + rootIds.length === 1 && remoteIds.length === 1 ? remoteIds[0] : undefined; + + const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChains( + rootIds, + existingRemoteId + ); + + const shareResponse = await createShareLink(storedId); + setShareToken(shareResponse.token ?? null); + + for (const stub of chain) { + actions.updateStirlingFileStub(stub.id, { + remoteStorageId: storedId, + remoteStorageUpdatedAt: updatedAt, + remoteOwnedByCurrentUser: true, + remoteSharedViaLink: false, + remoteHasShareLinks: true, + }); + await fileStorage.updateFileMetadata(stub.id, { + remoteStorageId: storedId, + remoteStorageUpdatedAt: updatedAt, + remoteOwnedByCurrentUser: true, + remoteSharedViaLink: false, + remoteHasShareLinks: true, + }); + } + + alert({ + alertType: 'success', + title: t('storageShare.generated', 'Share link generated'), + expandable: false, + durationMs: 3000, + }); + if (onShared) { + await onShared(); + } + } catch (error: any) { + console.error('Failed to generate share link:', error); + setErrorMessage( + t('storageShare.failure', 'Unable to generate a share link. Please try again.') + ); + } finally { + setIsWorking(false); + } + }, [actions, createShareLink, files, onShared, shareLinksEnabled, t]); + + const handleCopyLink = useCallback(async () => { + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + alert({ + alertType: 'success', + title: t('storageShare.copied', 'Link copied to clipboard'), + expandable: false, + durationMs: 2000, + }); + } catch (error) { + console.error('Failed to copy share link:', error); + alert({ + alertType: 'warning', + title: t('storageShare.copyFailed', 'Copy failed'), + expandable: false, + durationMs: 2500, + }); + } + }, [shareUrl, t]); + + return ( + + + + + + {t( + 'storageShare.bulkDescription', + 'Create one link to share all selected files with signed-in users.' + )} + + + {t('storageShare.fileCount', '{{count}} files selected', { + count: files.length, + })} + + + + + {errorMessage && ( + + {errorMessage} + + )} + {!shareLinksEnabled && ( + + {t('storageShare.linksDisabledBody', 'Share links are disabled by your server settings.')} + + )} + + {shareUrl && ( + + + } + onClick={handleCopyLink} + > + {t('storageShare.copy', 'Copy')} + + } + /> + + + )} + + + + + {t('storageShare.linkAccessTitle', 'Share link access')} + + setSelectedAccessFileId(event.target.value)} + > + {selectedFileStubs.map((file) => ( + + ))} + +
+ +
+ +
+
+ {t('quickAccess.accessGeneral', 'General Access')} +
+
+
+ +
+
+
+ {t('quickAccess.accessRestricted', 'Restricted')} +
+
+ {t('quickAccess.accessRestrictedHint', 'Only people with access can open')} +
+
+
+
+ +
+ +
+
+ {t('quickAccess.accessPeople', 'People with access')} +
+
+
+ {(selectedAccessFileStub?.remoteOwnerUsername || 'You').slice(0, 2).toUpperCase()} +
+
+
+ {selectedAccessFileStub?.remoteOwnerUsername || t('quickAccess.accessYou', 'You')} +
+
+ {selectedAccessFileStub?.name ?? t('quickAccess.accessSelectedFile', 'Selected file')} +
+
+ + {t('quickAccess.accessOwner', 'Owner')} + +
+
+
+ +
+
+
+ {t('quickAccess.accessInviteTitle', 'Invite People')} +
+
+ {inviteRows.map((row) => ( +
+
+ + + handleInviteRowChange(row.id, { email: event.target.value, error: undefined }) + } + /> + {row.error && ( +
{row.error}
+ )} +
+
+ + +
+ +
+ ))} + +
+ +
+ +
+ {accessInviteOpen ? ( + <> + + {shareLinksEnabled && ( + + )} + + ) : ( + <> + {sharingEnabled && ( + + )} + {shareLinksEnabled && ( + + )} + + )} +
+ + , + document.body + )} + + {/* Sign Popover */} + setSignMenuOpen(false)} + buttonRef={signButtonRef} + isRTL={isRTL} + groupSigningEnabled={groupSigningEnabled} + /> ); }); diff --git a/frontend/src/core/components/shared/ShareFileModal.tsx b/frontend/src/core/components/shared/ShareFileModal.tsx new file mode 100644 index 0000000000..3cdcf99245 --- /dev/null +++ b/frontend/src/core/components/shared/ShareFileModal.tsx @@ -0,0 +1,276 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from '@mantine/core'; +import LinkIcon from '@mui/icons-material/Link'; +import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded'; +import { useTranslation } from 'react-i18next'; + +import apiClient from '@app/services/apiClient'; +import { absoluteWithBasePath } from '@app/constants/app'; +import { alert } from '@app/components/toast'; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import type { StirlingFileStub } from '@app/types/fileContext'; +import { uploadHistoryChain } from '@app/services/serverStorageUpload'; +import { fileStorage } from '@app/services/fileStorage'; +import { useFileActions } from '@app/contexts/FileContext'; +import type { FileId } from '@app/types/file'; + +interface ShareFileModalProps { + opened: boolean; + onClose: () => void; + file: StirlingFileStub; + onUploaded?: () => Promise | void; +} + +const ShareFileModal: React.FC = ({ + opened, + onClose, + file, + onUploaded, +}) => { + const { t } = useTranslation(); + const { config } = useAppConfig(); + const { actions } = useFileActions(); + const shareLinksEnabled = config?.storageShareLinksEnabled === true; + const [isWorking, setIsWorking] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [shareToken, setShareToken] = useState(null); + const [shareRole, setShareRole] = useState<'editor' | 'commenter' | 'viewer'>('editor'); + + useEffect(() => { + if (!opened) { + setIsWorking(false); + setErrorMessage(null); + setShareToken(null); + } + }, [opened]); + + useEffect(() => { + if (opened) { + setShareRole('editor'); + } + }, [opened]); + + const shareUrl = useMemo(() => { + if (!shareToken) return ''; + const frontendUrl = (config?.frontendUrl || '').trim(); + if (frontendUrl) { + try { + const parsed = new URL(frontendUrl); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + const normalized = frontendUrl.endsWith('/') ? frontendUrl.slice(0, -1) : frontendUrl; + return `${normalized}/share/${shareToken}`; + } + } catch { + // invalid URL — fall through to default + } + } + return absoluteWithBasePath(`/share/${shareToken}`); + }, [config?.frontendUrl, shareToken]); + + const createShareLink = useCallback(async (storedFileId: number) => { + const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, { + accessRole: shareRole, + }); + return response.data as { token?: string }; + }, [shareRole]); + + const handleGenerateLink = useCallback(async () => { + if (!shareLinksEnabled) { + alert({ + alertType: 'warning', + title: t('storageShare.linksDisabled', 'Share links are disabled.'), + expandable: false, + durationMs: 2500, + }); + return; + } + setIsWorking(true); + setErrorMessage(null); + setShareToken(null); + + try { + const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0; + const isUpToDate = + Boolean(file.remoteStorageId) && + Boolean(file.remoteStorageUpdatedAt) && + (file.remoteStorageUpdatedAt as number) >= localUpdatedAt; + + let storedId = file.remoteStorageId; + + if (!isUpToDate) { + const originalFileId = (file.originalFileId || file.id) as FileId; + const remoteId = file.remoteStorageId; + const { remoteId: newStoredId, updatedAt, chain } = await uploadHistoryChain( + originalFileId, + remoteId + ); + storedId = newStoredId; + + for (const stub of chain) { + actions.updateStirlingFileStub(stub.id, { + remoteStorageId: newStoredId, + remoteStorageUpdatedAt: updatedAt, + remoteOwnedByCurrentUser: true, + remoteHasShareLinks: true, + }); + await fileStorage.updateFileMetadata(stub.id, { + remoteStorageId: newStoredId, + remoteStorageUpdatedAt: updatedAt, + remoteOwnedByCurrentUser: true, + remoteHasShareLinks: true, + }); + } + } + + if (!storedId) { + throw new Error('Missing stored file ID for sharing.'); + } + const shareResponse = await createShareLink(storedId); + setShareToken(shareResponse.token ?? null); + + alert({ + alertType: 'success', + title: t('storageShare.generated', 'Share link generated'), + expandable: false, + durationMs: 3000, + }); + if (storedId) { + actions.updateStirlingFileStub(file.id, { remoteHasShareLinks: true }); + await fileStorage.updateFileMetadata(file.id, { remoteHasShareLinks: true }); + } + if (onUploaded) { + await onUploaded(); + } + } catch (error: any) { + console.error('Failed to generate share link:', error); + setErrorMessage( + t('storageShare.failure', 'Unable to generate a share link. Please try again.') + ); + } finally { + setIsWorking(false); + } + }, [actions, createShareLink, file, onUploaded, shareLinksEnabled, t]); + + const handleCopyLink = useCallback(async () => { + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + alert({ + alertType: 'success', + title: t('storageShare.copied', 'Link copied to clipboard'), + expandable: false, + durationMs: 2000, + }); + } catch (error) { + console.error('Failed to copy share link:', error); + alert({ + alertType: 'warning', + title: t('storageShare.copyFailed', 'Copy failed'), + expandable: false, + durationMs: 2500, + }); + } + }, [shareUrl, t]); + + return ( + + + + + + {t( + 'storageShare.description', + 'Create a share link for this file. Signed-in users with the link can access it.' + )} + + + {t('storageShare.fileLabel', 'File')}: {file.name} + + + + + {errorMessage && ( + + {errorMessage} + + )} + {!shareLinksEnabled && ( + + {t('storageShare.linksDisabledBody', 'Share links are disabled by your server settings.')} + + )} + + {shareUrl && ( + + + } + onClick={handleCopyLink} + > + {t('storageShare.copy', 'Copy')} + + } + /> + + + )} + + + + + {t('storageShare.linkAccessTitle', 'Share link access')} + + setShareRole((value as typeof shareRole) || 'editor')} + comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }} + data={[ + { value: 'editor', label: t('storageShare.roleEditor', 'Editor') }, + { value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') }, + { value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') }, + ]} + /> + {shareRole === 'commenter' && ( + + {t('storageShare.commenterHint', 'Commenting is coming soon.')} + + )} + + + + + + )} + + + + + {t('storageShare.sharedUsersTitle', 'Shared users')} + + + { + setShareUsername(event.currentTarget.value); + setShowEmailWarning(false); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void handleAddUser(); + } + }} + disabled={!sharingEnabled || isLoading} + error={shareUsernameError} + /> + + + {showEmailWarning && ( + + + + {t( + 'storageShare.emailWarningBody', + 'This looks like an email address. If this person is not already a Stirling PDF user, they will not be able to access the file.' + )} + + + + + + + + )} + {sharedUsers.length === 0 ? ( + + {t('storageShare.noSharedUsers', 'No users have access yet.')} + + ) : ( + + {sharedUsers.map((user) => ( + + + {user.username} + {user.accessRole === 'commenter' && ( + + {t('storageShare.commenterHint', 'Commenting is coming soon.')} + + )} + + + onDueDateChange(e.target.value)} + disabled={creating} + /> + + +
+ onIncludeSummaryPageChange(e.currentTarget.checked)} + disabled={creating} + size="sm" + /> +
+ + )} + + ); +}; + +export default CreateSessionPanel; diff --git a/frontend/src/core/components/shared/signing/SignPopout.tsx b/frontend/src/core/components/shared/signing/SignPopout.tsx new file mode 100644 index 0000000000..183f731831 --- /dev/null +++ b/frontend/src/core/components/shared/signing/SignPopout.tsx @@ -0,0 +1,845 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { Drawer } from '@mantine/core'; +import { useIsPhone } from '@app/hooks/useIsMobile'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import ActiveSessionsPanel from '@app/components/shared/signing/ActiveSessionsPanel'; +import CompletedSessionsPanel from '@app/components/shared/signing/CompletedSessionsPanel'; +import CreateSessionPanel from '@app/components/shared/signing/CreateSessionPanel'; +import apiClient from '@app/services/apiClient'; +import { alert } from '@app/components/toast'; +import { SignRequestSummary, SignRequestDetail, SessionSummary, SessionDetail } from '@app/types/signingSession'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; +import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext'; +import { useFileSelection } from '@app/contexts/file/fileHooks'; +import { fileStorage } from '@app/services/fileStorage'; +import { useFileActions } from '@app/contexts/FileContext'; +import SignRequestWorkbenchView from '@app/components/tools/certSign/SignRequestWorkbenchView'; +import SessionDetailWorkbenchView from '@app/components/tools/certSign/SessionDetailWorkbenchView'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; + +export const SIGN_REQUEST_WORKBENCH_TYPE = 'custom:signRequestWorkbench' as const; +export const SESSION_DETAIL_WORKBENCH_TYPE = 'custom:sessionDetailWorkbench' as const; + +type SessionItem = (SignRequestSummary | SessionSummary) & { + itemType: 'signRequest' | 'mySession'; +}; + +function sortSessions(sessions: SessionItem[], tab: 'active' | 'completed'): SessionItem[] { + return [...sessions].sort((a, b) => { + if (tab === 'active') { + const aDue = (a as SignRequestSummary).dueDate; + const bDue = (b as SignRequestSummary).dueDate; + if (aDue && bDue) return new Date(aDue).getTime() - new Date(bDue).getTime(); + if (aDue) return -1; + if (bDue) return 1; + } + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); +} + +interface SignPopoutProps { + isOpen: boolean; + onClose: () => void; + buttonRef: React.RefObject; + isRTL: boolean; + groupSigningEnabled: boolean; +} + +const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: SignPopoutProps) => { + const { t } = useTranslation(); + const isPhone = useIsPhone(); + const popoverRef = useRef(null); + const [popoverPosition, setPopoverPosition] = useState({ top: 160, left: 84 }); + const [maxHeight, setMaxHeight] = useState(undefined); + + // Tab state + const [activeTab, setActiveTab] = useState<'active' | 'completed'>('active'); + const [showCreatePanel, setShowCreatePanel] = useState(false); + + // Search / filter state + const [searchQuery, setSearchQuery] = useState(''); + const [activeFilters, setActiveFilters] = useState>(new Set()); + + const handleTabChange = (tab: 'active' | 'completed') => { + setActiveTab(tab); + setSearchQuery(''); + setActiveFilters(new Set()); + }; + + const toggleFilter = (key: string) => { + setActiveFilters(prev => { + const next = new Set(prev); + if (next.has(key)) { next.delete(key); } else { next.add(key); } + return next; + }); + }; + + // Data state + const [signRequests, setSignRequests] = useState([]); + const [mySessions, setMySessions] = useState([]); + const [loading, setLoading] = useState(false); + + // Create form state + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [dueDate, setDueDate] = useState(''); + const [creating, setCreating] = useState(false); + const [includeSummaryPage, setIncludeSummaryPage] = useState(false); + + // Hooks + const { selectedFiles } = useFileSelection(); + const { actions: fileActions } = useFileActions(); + const { actions: navigationActions } = useNavigationActions(); + const { workbench: currentView } = useNavigationState(); + const { + registerCustomWorkbenchView, + unregisterCustomWorkbenchView, + setCustomWorkbenchViewData, + clearCustomWorkbenchViewData, + handleToolSelect, + } = useToolWorkflow(); + + // Workbench IDs + const SIGN_REQUEST_WORKBENCH_ID = 'signRequestWorkbench'; + const SESSION_DETAIL_WORKBENCH_ID = 'sessionDetailWorkbench'; + + // Register workbenches when group signing is enabled. + // No cleanup on unmount — registration must persist when this component unmounts + // on mobile (QuickAccessBar is desktop-only). Re-registering on remount is idempotent. + useEffect(() => { + if (!groupSigningEnabled) return; + + registerCustomWorkbenchView({ + id: SIGN_REQUEST_WORKBENCH_ID, + workbenchId: SIGN_REQUEST_WORKBENCH_TYPE, + label: t('certSign.collab.signRequest.workbenchTitle', 'Sign Request'), + component: SignRequestWorkbenchView, + hideTopControls: true, + hideToolPanel: true, + }); + + registerCustomWorkbenchView({ + id: SESSION_DETAIL_WORKBENCH_ID, + workbenchId: SESSION_DETAIL_WORKBENCH_TYPE, + label: t('certSign.collab.sessionDetail.workbenchTitle', 'Session Management'), + component: SessionDetailWorkbenchView, + hideTopControls: true, + hideToolPanel: true, + }); + }, [groupSigningEnabled]); + + // Unregister workbenches only when the feature is explicitly disabled + useEffect(() => { + if (groupSigningEnabled) return; + unregisterCustomWorkbenchView(SIGN_REQUEST_WORKBENCH_ID); + unregisterCustomWorkbenchView(SESSION_DETAIL_WORKBENCH_ID); + }, [groupSigningEnabled, unregisterCustomWorkbenchView]); + + // Clear sign request workbench data when the user navigates away from it + useEffect(() => { + if (currentView !== SIGN_REQUEST_WORKBENCH_TYPE) { + clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID); + } + }, [currentView]); + + // Clear session detail workbench data when the user navigates away from it + useEffect(() => { + if (currentView !== SESSION_DETAIL_WORKBENCH_TYPE) { + clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID); + } + }, [currentView]); + + // Position popover (desktop/tablet only — phone uses Drawer) + useEffect(() => { + if (!isOpen || isPhone) return; + + const updatePosition = () => { + const anchor = buttonRef.current; + if (!anchor) return; + const rect = anchor.getBoundingClientRect(); + const left = isRTL ? Math.max(16, rect.left - 360) : rect.right + 12; + const viewportHeight = window.innerHeight; + + // Start at button position with small offset + let top = rect.top - 24; + + // Ensure minimum top margin + top = Math.max(24, top); + + // Calculate available height from top position to bottom of viewport + const availableHeight = viewportHeight - top - 24; // 24px bottom margin + + setPopoverPosition({ top, left }); + setMaxHeight(availableHeight); + }; + + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, { capture: true }); + + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, { capture: true }); + }; + }, [isOpen, isRTL, buttonRef]); + + // Handle outside clicks (desktop/tablet only — Drawer handles its own backdrop on phone) + useEffect(() => { + if (!isOpen || isPhone) return; + + const handleOutside = (event: MouseEvent) => { + const target = event.target as Node; + if (popoverRef.current?.contains(target)) return; + if (buttonRef.current?.contains(target)) return; + + const mantineDropdown = (target as Element).closest?.( + '.mantine-Combobox-dropdown, .mantine-Popover-dropdown' + ); + if (mantineDropdown) return; + + onClose(); + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + + document.addEventListener('mousedown', handleOutside); + document.addEventListener('keydown', handleEscape); + + return () => { + document.removeEventListener('mousedown', handleOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [isOpen, onClose, buttonRef]); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const [requestsResponse, sessionsResponse] = await Promise.all([ + apiClient.get('/api/v1/security/cert-sign/sign-requests'), + apiClient.get('/api/v1/security/cert-sign/sessions'), + ]); + setSignRequests(requestsResponse.data); + setMySessions(sessionsResponse.data); + } catch (error) { + console.error('Failed to fetch signing data:', error instanceof Error ? error.message : error); + alert({ + alertType: 'warning', + title: t('common.error'), + body: t('certSign.fetchFailed', 'Failed to load signing data'), + expandable: false, + durationMs: 2500, + }); + } finally { + setLoading(false); + } + }, [t]); + + // Fetch data when opened (only needed for group signing sessions) + useEffect(() => { + if (isOpen && groupSigningEnabled) { + fetchData(); + } + }, [isOpen, groupSigningEnabled, fetchData]); + + // Auto-refresh Active tab every 15 seconds to show updated signature status + useEffect(() => { + if (isOpen && groupSigningEnabled && activeTab === 'active' && !showCreatePanel) { + const interval = setInterval(() => { + fetchData(); + }, 15000); // Refresh every 15 seconds + + return () => clearInterval(interval); + } + }, [isOpen, activeTab, showCreatePanel, fetchData]); + + // Combine and filter sessions + const activeSessions: SessionItem[] = [ + // Sign requests where user hasn't signed or declined yet + ...signRequests + .filter(req => req.myStatus !== 'SIGNED' && req.myStatus !== 'DECLINED') + .map(req => ({ ...req, itemType: 'signRequest' as const })), + // Sessions user created that aren't finalized yet + ...mySessions + .filter(s => !s.finalized) + .map(s => ({ ...s, itemType: 'mySession' as const })), + ]; + + const completedSessions: SessionItem[] = [ + // Sign requests where user has signed or declined + ...signRequests + .filter(req => req.myStatus === 'SIGNED' || req.myStatus === 'DECLINED') + .map(req => ({ ...req, itemType: 'signRequest' as const })), + // Sessions user created that have been finalized + ...mySessions + .filter(s => s.finalized) + .map(s => ({ ...s, itemType: 'mySession' as const })), + ]; + + // Filter options vary by tab + const filterOptions = activeTab === 'active' + ? [ + { key: 'mine', label: t('quickAccess.filterMine', 'Mine') }, + { key: 'overdue', label: t('quickAccess.filterOverdue', 'Overdue') }, + ] + : [ + { key: 'mine', label: t('quickAccess.filterMine', 'Mine') }, + { key: 'signed', label: t('quickAccess.filterSigned', 'Signed') }, + { key: 'declined',label: t('quickAccess.filterDeclined', 'Declined') }, + ]; + + const applyFiltersAndSearch = (sessions: SessionItem[]): SessionItem[] => { + let result = sessions; + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + result = result.filter(s => s.documentName.toLowerCase().includes(q)); + } + const now = new Date(); + if (activeFilters.has('mine')) result = result.filter(s => s.itemType === 'mySession'); + if (activeFilters.has('overdue')) result = result.filter(s => (s as SignRequestSummary).dueDate && new Date((s as SignRequestSummary).dueDate) < now); + if (activeFilters.has('signed')) result = result.filter(s => (s as SignRequestSummary).myStatus === 'SIGNED'); + if (activeFilters.has('declined')) result = result.filter(s => (s as SignRequestSummary).myStatus === 'DECLINED'); + return result; + }; + + const displayedActiveSessions = applyFiltersAndSearch(sortSessions(activeSessions, 'active')); + const displayedCompletedSessions = applyFiltersAndSearch(sortSessions(completedSessions, 'completed')); + + // Create session handler + const handleCreateSession = useCallback(async () => { + if (selectedUserIds.length === 0 || selectedFiles.length !== 1) return; + + setCreating(true); + try { + const selectedFile = selectedFiles[0]; + const stirlingFile = await fileStorage.getStirlingFile(selectedFile.fileId); + if (!stirlingFile) throw new Error('File not found'); + + const formData = new FormData(); + formData.append('file', stirlingFile, selectedFile.name); + formData.append('workflowType', 'SIGNING'); + formData.append('documentName', selectedFile.name); + selectedUserIds.forEach((userId, index) => { + formData.append(`participantUserIds[${index}]`, userId.toString()); + }); + if (dueDate) formData.append('dueDate', dueDate); + formData.append('notifyOnCreate', 'true'); + + // Send includeSummaryPage setting as workflowMetadata if enabled + if (includeSummaryPage) { + const workflowMetadata = JSON.stringify({ + includeSummaryPage: true, + }); + formData.append('workflowMetadata', workflowMetadata); + } + + await apiClient.post('/api/v1/security/cert-sign/sessions', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + alert({ + alertType: 'success', + title: t('success'), + body: t('signSession.created', 'Signing request sent'), + expandable: false, + durationMs: 2500, + }); + + setSelectedUserIds([]); + setDueDate(''); + setIncludeSummaryPage(false); + setShowCreatePanel(false); + await fetchData(); + } catch (error) { + console.error('Failed to create session:', error instanceof Error ? error.message : error); + alert({ + alertType: 'error', + title: t('common.error'), + body: t('signSession.createFailed', 'Failed to create signing request'), + expandable: false, + durationMs: 3000, + }); + } finally { + setCreating(false); + } + }, [selectedUserIds, dueDate, selectedFiles, fetchData, t, includeSummaryPage]); + + // Handle clicking a sign request + const handleSignRequestClick = useCallback(async (request: SignRequestSummary) => { + onClose(); + try { + const [detailResponse, pdfResponse] = await Promise.all([ + apiClient.get(`/api/v1/security/cert-sign/sign-requests/${request.sessionId}`), + apiClient.get(`/api/v1/security/cert-sign/sign-requests/${request.sessionId}/document`, { + responseType: 'blob', + }), + ]); + + const pdfFile = new File([pdfResponse.data], detailResponse.data.documentName, { + type: 'application/pdf', + }); + const canSign = + detailResponse.data.myStatus === 'PENDING' || + detailResponse.data.myStatus === 'NOTIFIED' || + detailResponse.data.myStatus === 'VIEWED'; + + setCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID, { + signRequest: detailResponse.data, + pdfFile, + onSign: (certData: FormData) => handleSign(request.sessionId, certData), + onDecline: () => handleDecline(request.sessionId), + onBack: () => { + clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID); + navigationActions.setWorkbench('viewer'); + }, + canSign, + }); + + requestAnimationFrame(() => { + navigationActions.setWorkbench(SIGN_REQUEST_WORKBENCH_TYPE); + }); + } catch (error) { + console.error('Failed to load sign request:', error instanceof Error ? error.message : error); + alert({ + alertType: 'error', + title: t('common.error'), + body: t('signRequest.fetchFailed', 'Failed to load sign request'), + expandable: false, + durationMs: 3000, + }); + } + }, [onClose, setCustomWorkbenchViewData, clearCustomWorkbenchViewData, navigationActions, t]); + + // Handle clicking a session + const handleSessionClick = useCallback(async (session: SessionSummary) => { + onClose(); + try { + // First fetch session detail + const detailResponse = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${session.sessionId}` + ); + + // Determine which endpoint to use based on session state + let pdfFile: File | null = null; + + if (detailResponse.data.finalized) { + // Finalized sessions have signed PDF available + try { + const pdfResponse = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${session.sessionId}/signed-pdf`, + { responseType: 'blob' } + ); + pdfFile = new File([pdfResponse.data], session.documentName, { type: 'application/pdf' }); + } catch (pdfError: any) { + if (pdfError?.response?.status === 404) { + // Finalized but signed PDF not available - backend issue + alert({ + alertType: 'warning', + title: t('certSign.sessions.pdfNotReady', 'PDF Not Ready'), + body: t('certSign.sessions.pdfNotReadyDesc', 'The signed PDF is being generated. Please try again in a moment.'), + expandable: false, + durationMs: 3000, + }); + return; + } + throw pdfError; + } + } else { + // For non-finalized sessions, get original PDF (always available) + try { + const pdfResponse = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${session.sessionId}/pdf`, + { responseType: 'blob' } + ); + pdfFile = new File([pdfResponse.data], session.documentName, { type: 'application/pdf' }); + } catch (_error) { + // Fallback if PDF not available + pdfFile = null; + } + } + + setCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID, { + session: detailResponse.data, + pdfFile, + onFinalize: () => handleFinalize(session.sessionId, session.documentName), + onLoadSignedPdf: () => handleLoadSignedPdf(session.sessionId, session.documentName), + onAddParticipants: (userIds: number[], defaultReason?: string) => + handleAddParticipants(session.sessionId, userIds, defaultReason), + onRemoveParticipant: (participantId: number) => handleRemoveParticipant(session.sessionId, participantId), + onDelete: () => handleDeleteSession(session.sessionId), + onBack: () => { + clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID); + navigationActions.setWorkbench('viewer'); + }, + onRefresh: () => handleRefreshSession(session.sessionId), + }); + + requestAnimationFrame(() => { + navigationActions.setWorkbench(SESSION_DETAIL_WORKBENCH_TYPE); + }); + } catch (error) { + console.error('Failed to load session:', error instanceof Error ? error.message : error); + alert({ + alertType: 'error', + title: t('common.error'), + body: t('certSign.sessions.fetchFailed', 'Failed to load session details'), + expandable: false, + durationMs: 3000, + }); + } + }, [onClose, setCustomWorkbenchViewData, clearCustomWorkbenchViewData, navigationActions, t]); + + // Action handlers + const handleSign = async (sessionId: string, certificateData: FormData) => { + await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/sign`, certificateData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + alert({ + alertType: 'success', + title: t('success'), + body: t('signRequest.signed', 'Document signed successfully'), + expandable: false, + durationMs: 2500, + }); + clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID); + navigationActions.setWorkbench('viewer'); + await fetchData(); + }; + + const handleDecline = async (sessionId: string) => { + await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/decline`); + alert({ + alertType: 'success', + title: t('success'), + body: t('signRequest.declined', 'Sign request declined'), + expandable: false, + durationMs: 2500, + }); + clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID); + navigationActions.setWorkbench('viewer'); + await fetchData(); + }; + + const handleFinalize = async (sessionId: string, documentName: string) => { + const response = await apiClient.post( + `/api/v1/security/cert-sign/sessions/${sessionId}/finalize`, + null, + { responseType: 'blob' } + ); + const contentDisposition = response.headers['content-disposition']; + const filenameMatch = contentDisposition?.match(/filename="?(.+?)"?$/); + const filename = filenameMatch ? filenameMatch[1] : `${documentName}_signed.pdf`; + const signedFile = new File([response.data], filename, { type: 'application/pdf' }); + await fileActions.addFiles([signedFile]); + alert({ + alertType: 'success', + title: t('success'), + body: t('certSign.sessions.finalized', 'Session finalized'), + expandable: false, + durationMs: 2500, + }); + clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID); + navigationActions.setWorkbench('viewer'); + await fetchData(); + }; + + const handleLoadSignedPdf = async (sessionId: string, documentName: string) => { + const response = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${sessionId}/signed-pdf`, + { responseType: 'blob' } + ); + const contentDisposition = response.headers['content-disposition']; + const filenameMatch = contentDisposition?.match(/filename="?(.+?)"?$/); + const filename = filenameMatch ? filenameMatch[1] : `${documentName}_signed.pdf`; + const signedFile = new File([response.data], filename, { type: 'application/pdf' }); + await fileActions.addFiles([signedFile]); + alert({ + alertType: 'success', + title: t('success'), + body: t('certSign.sessions.loaded', 'Signed PDF loaded'), + expandable: false, + durationMs: 2500, + }); + clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID); + navigationActions.setWorkbench('viewer'); + }; + + const handleAddParticipants = async (sessionId: string, userIds: number[], defaultReason?: string) => { + const requests = userIds.map(userId => ({ + userId, + defaultReason: defaultReason || undefined, + sendNotification: true, + })); + await apiClient.post(`/api/v1/security/cert-sign/sessions/${sessionId}/participants`, requests); + await handleRefreshSession(sessionId); + }; + + const handleRemoveParticipant = async (sessionId: string, participantId: number) => { + await apiClient.delete(`/api/v1/security/cert-sign/sessions/${sessionId}/participants/${participantId}`); + await handleRefreshSession(sessionId); + }; + + const handleDeleteSession = async (sessionId: string) => { + await apiClient.delete(`/api/v1/security/cert-sign/sessions/${sessionId}`); + alert({ + alertType: 'success', + title: t('success'), + body: t('certSign.sessions.deleted', 'Session deleted'), + expandable: false, + durationMs: 2500, + }); + clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID); + navigationActions.setWorkbench('viewer'); + await fetchData(); + }; + + const handleRefreshSession = async (sessionId: string) => { + const response = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${sessionId}` + ); + // Update workbench data, preserving PDF and callbacks + setCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID, (prevData: any) => ({ + ...prevData, + session: response.data, + })); + }; + + if (typeof document === 'undefined') return null; + + // Shared card content — rendered inside either the portal (desktop/tablet) or Drawer (phone) + const popoutCard = ( +
+ {/* Header */} +
+ +
+ {showCreatePanel + ? t('quickAccess.createSession', 'Create Signing Request') + : groupSigningEnabled && activeTab === 'active' + ? t('quickAccess.activeSessions', 'Active Sessions') + : groupSigningEnabled + ? t('quickAccess.completedSessions', 'Completed Sessions') + : t('quickAccess.sign', 'Sign')} +
+
+ {!showCreatePanel && ( + + )} + +
+
+ + {/* Quick sign tools */} + {!showCreatePanel && ( +
+
+ {t('quickAccess.signYourself', 'Sign Yourself')} +
+
+ + +
+
+ )} + + {/* Signature Requests section label + Tab Navigation */} + {!showCreatePanel && groupSigningEnabled && ( + <> +
+ {t('quickAccess.signatureRequests', 'Signature Requests')} + +
+
+ + +
+ + )} + + {/* Search + filter bar */} + {!showCreatePanel && groupSigningEnabled && ( +
+ setSearchQuery(e.target.value)} + /> +
+ {filterOptions.map(f => ( + + ))} +
+
+ )} + + {/* Body */} + {groupSigningEnabled && ( +
+ {showCreatePanel ? ( + + ) : activeTab === 'active' ? ( + { + if (item.itemType === 'signRequest') { + handleSignRequestClick(item as SignRequestSummary); + } else { + handleSessionClick(item as SessionSummary); + } + }} + /> + ) : ( + { + if (item.itemType === 'signRequest') { + handleSignRequestClick(item as SignRequestSummary); + } else { + handleSessionClick(item as SessionSummary); + } + }} + /> + )} +
+ )} + + {/* Footer */} + {groupSigningEnabled && showCreatePanel && ( +
+ +
+ )} +
+ ); + + // Phone: bottom-sheet Drawer (full height) + if (isPhone) { + return ( + + {popoutCard} + + ); + } + + // Desktop / tablet: fixed-position portal + return createPortal( +
e.stopPropagation()} + > + {popoutCard} +
, + document.body + ); +}; + +export default SignPopout; diff --git a/frontend/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.tsx b/frontend/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.tsx new file mode 100644 index 0000000000..5fd2d2989d --- /dev/null +++ b/frontend/src/core/components/shared/signing/steps/ConfigureSignatureDefaultsStep.tsx @@ -0,0 +1,75 @@ +import { Button, Stack, Text, Group, Paper } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import SignatureSettingsInput, { SignatureSettings } from '@app/components/tools/certSign/SignatureSettingsInput'; + +interface ConfigureSignatureDefaultsStepProps { + settings: SignatureSettings; + onSettingsChange: (settings: SignatureSettings) => void; + onBack: () => void; + onNext: () => void; + disabled?: boolean; +} + +export const ConfigureSignatureDefaultsStep: React.FC = ({ + settings, + onSettingsChange, + onBack, + onNext, + disabled = false, +}) => { + const { t } = useTranslation(); + + return ( + + + + + + + {t('groupSigning.steps.configureDefaults.preview', 'Preview')} + + + {settings.showSignature + ? t( + 'groupSigning.steps.configureDefaults.visible', + 'Signatures will be visible on page {{page}}', + { page: settings.pageNumber || 1 } + ) + : t( + 'groupSigning.steps.configureDefaults.invisible', + 'Signatures will be invisible (metadata only)' + )} + + {settings.showSignature && settings.reason && ( + + {t('groupSigning.steps.configureDefaults.reasonLabel', 'Reason:')}{' '} + {settings.reason} + + )} + {settings.showSignature && settings.location && ( + + + {t('groupSigning.steps.configureDefaults.locationLabel', 'Location:')} + {' '} + {settings.location} + + )} + + + + + + + + + ); +}; diff --git a/frontend/src/core/components/shared/signing/steps/ReviewSessionStep.tsx b/frontend/src/core/components/shared/signing/steps/ReviewSessionStep.tsx new file mode 100644 index 0000000000..aab7d01b2e --- /dev/null +++ b/frontend/src/core/components/shared/signing/steps/ReviewSessionStep.tsx @@ -0,0 +1,159 @@ +import { Button, Stack, Text, Group, Divider, TextInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import PeopleIcon from '@mui/icons-material/People'; +import DrawIcon from '@mui/icons-material/Draw'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import SendIcon from '@mui/icons-material/Send'; +import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; +import type { SignatureSettings } from '@app/components/tools/certSign/SignatureSettingsInput'; +import type { FileState } from '@app/types/file'; + +interface ReviewSessionStepProps { + selectedFile: FileState; + participantCount: number; + signatureSettings: SignatureSettings; + dueDate: string; + onDueDateChange: (value: string) => void; + onBack: () => void; + onSubmit: () => void; + disabled?: boolean; +} + +export const ReviewSessionStep: React.FC = ({ + selectedFile, + participantCount, + signatureSettings, + dueDate, + onDueDateChange, + onBack, + onSubmit, + disabled = false, +}) => { + const { t } = useTranslation(); + + return ( + + + {t('groupSigning.steps.review.title', 'Review Session Details')} + + + {/* Document Info */} +
+ + + + {t('groupSigning.steps.review.document', 'Document')} + + +
+ {selectedFile.name} + {selectedFile.size && ( + + {(selectedFile.size / 1024 / 1024).toFixed(2)} MB + + )} +
+
+ + + + {/* Participants */} +
+ + + + {t('groupSigning.steps.review.participants', 'Participants')} + + + + {t('groupSigning.steps.review.participantCount', { + count: participantCount, + defaultValue: '{{count}} participant(s) will sign in order', + })} + +
+ + + + {/* Signature Settings */} +
+ + + + {t('groupSigning.steps.review.signatureSettings', 'Signature Settings')} + + + + + {t('groupSigning.steps.review.visibility', 'Visibility:')}{' '} + {signatureSettings.showSignature + ? t('groupSigning.steps.review.visible', 'Visible on page {{page}}', { + page: signatureSettings.pageNumber || 1, + }) + : t('groupSigning.steps.review.invisible', 'Invisible (metadata only)')} + + {signatureSettings.showSignature && signatureSettings.reason && ( + + {t('groupSigning.steps.review.reason', 'Reason:')} {signatureSettings.reason} + + )} + {signatureSettings.showSignature && signatureSettings.location && ( + + {t('groupSigning.steps.review.location', 'Location:')} {signatureSettings.location} + + )} + {signatureSettings.showSignature && ( + + {t('groupSigning.steps.review.logo', 'Logo:')}{' '} + {signatureSettings.showLogo + ? t('groupSigning.steps.review.logoShown', 'Stirling PDF logo shown') + : t('groupSigning.steps.review.logoHidden', 'No logo')} + + )} + +
+ + + + {/* Due Date */} +
+ + + + {t('groupSigning.steps.review.dueDate', 'Due Date (Optional)')} + + + onDueDateChange(e.target.value)} + disabled={disabled} + placeholder={t('groupSigning.steps.review.dueDatePlaceholder', 'Select due date...')} + /> +
+ + + + + +
+ ); +}; diff --git a/frontend/src/core/components/shared/signing/steps/SelectDocumentStep.tsx b/frontend/src/core/components/shared/signing/steps/SelectDocumentStep.tsx new file mode 100644 index 0000000000..508ef659bb --- /dev/null +++ b/frontend/src/core/components/shared/signing/steps/SelectDocumentStep.tsx @@ -0,0 +1,67 @@ +import { Button, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import type { FileState } from '@app/types/file'; + +interface SelectDocumentStepProps { + selectedFiles: FileState[]; + onNext: () => void; +} + +export const SelectDocumentStep: React.FC = ({ + selectedFiles, + onNext, +}) => { + const { t } = useTranslation(); + + const hasValidFile = selectedFiles.length === 1; + const selectedFile = hasValidFile ? selectedFiles[0] : null; + + return ( + + {!hasValidFile ? ( + + {t( + 'groupSigning.steps.selectDocument.noFile', + 'Please select a single PDF file from your active files to create a signing session.' + )} + + ) : ( + <> +
+ + {t('groupSigning.steps.selectDocument.selectedFile', 'Selected document')} + +
+ +
+ + {selectedFile?.name} + + {selectedFile?.size && ( + + {(selectedFile.size / 1024 / 1024).toFixed(2)} MB + + )} +
+
+
+ + + + )} +
+ ); +}; diff --git a/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx b/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx new file mode 100644 index 0000000000..04af1ffe7c --- /dev/null +++ b/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx @@ -0,0 +1,61 @@ +import { Button, Stack, Text, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import UserSelector from '@app/components/shared/UserSelector'; + +interface SelectParticipantsStepProps { + selectedUserIds: number[]; + onSelectedUserIdsChange: (userIds: number[]) => void; + onBack: () => void; + onNext: () => void; + disabled?: boolean; +} + +export const SelectParticipantsStep: React.FC = ({ + selectedUserIds, + onSelectedUserIdsChange, + onBack, + onNext, + disabled = false, +}) => { + const { t } = useTranslation(); + + const hasParticipants = selectedUserIds.length > 0; + + return ( + +
+ + {t('groupSigning.steps.selectParticipants.label', 'Select participants')} + + +
+ + {selectedUserIds.length > 0 && ( + + {t('groupSigning.steps.selectParticipants.count', { + count: selectedUserIds.length, + defaultValue: '{{count}} participant(s) selected', + })} + + )} + + + + + +
+ ); +}; diff --git a/frontend/src/core/components/shared/wetSignature/DrawSignatureCanvas.tsx b/frontend/src/core/components/shared/wetSignature/DrawSignatureCanvas.tsx new file mode 100644 index 0000000000..a3d23d3af5 --- /dev/null +++ b/frontend/src/core/components/shared/wetSignature/DrawSignatureCanvas.tsx @@ -0,0 +1,162 @@ +import { useRef, useEffect, useState } from 'react'; +import { Stack, Button, Group, ColorPicker, Slider, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import DeleteIcon from '@mui/icons-material/Delete'; + +interface DrawSignatureCanvasProps { + signature: string | null; + onChange: (signature: string | null) => void; + disabled?: boolean; +} + +export const DrawSignatureCanvas: React.FC = ({ + signature, + onChange, + disabled = false, +}) => { + const { t } = useTranslation(); + const canvasRef = useRef(null); + const [isDrawing, setIsDrawing] = useState(false); + const [penColor, setPenColor] = useState('#000000'); + const [penSize, setPenSize] = useState(2); + + useEffect(() => { + if (!canvasRef.current) return; + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Draw existing signature if any + if (signature) { + const img = new Image(); + img.onload = () => { + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + }; + img.src = signature; + } + }, [signature]); + + const startDrawing = (e: React.MouseEvent) => { + if (disabled) return; + setIsDrawing(true); + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.beginPath(); + ctx.moveTo(x, y); + }; + + const draw = (e: React.MouseEvent) => { + if (!isDrawing || disabled) return; + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.lineTo(x, y); + ctx.strokeStyle = penColor; + ctx.lineWidth = penSize; + ctx.lineCap = 'round'; + ctx.stroke(); + }; + + const stopDrawing = () => { + if (!isDrawing) return; + setIsDrawing(false); + const canvas = canvasRef.current; + if (!canvas) return; + + // Convert canvas to base64 and save + const dataUrl = canvas.toDataURL('image/png'); + onChange(dataUrl); + }; + + const clearCanvas = () => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + onChange(null); + }; + + return ( + + + {t('certSign.collab.signRequest.drawSignature', 'Draw your signature below')} + + + + + +
+ + {t('certSign.collab.signRequest.penColor', 'Pen Color')} + + +
+
+ + {t('certSign.collab.signRequest.penSize', 'Pen Size: {{size}}px', { size: penSize })} + + +
+
+ + +
+ ); +}; diff --git a/frontend/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx b/frontend/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx new file mode 100644 index 0000000000..fa9ddb2ced --- /dev/null +++ b/frontend/src/core/components/shared/wetSignature/SignatureTypeSelector.tsx @@ -0,0 +1,41 @@ +import { SegmentedControl } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +export type SignatureType = 'draw' | 'upload' | 'type'; + +interface SignatureTypeSelectorProps { + value: SignatureType; + onChange: (value: SignatureType) => void; + disabled?: boolean; +} + +export const SignatureTypeSelector: React.FC = ({ + value, + onChange, + disabled = false, +}) => { + const { t } = useTranslation(); + + return ( + onChange(val as SignatureType)} + disabled={disabled} + data={[ + { + value: 'draw', + label: t('certSign.collab.signRequest.signatureType.draw', 'Draw'), + }, + { + value: 'upload', + label: t('certSign.collab.signRequest.signatureType.upload', 'Upload'), + }, + { + value: 'type', + label: t('certSign.collab.signRequest.signatureType.type', 'Type'), + }, + ]} + fullWidth + /> + ); +}; diff --git a/frontend/src/core/components/shared/wetSignature/TypeSignatureText.tsx b/frontend/src/core/components/shared/wetSignature/TypeSignatureText.tsx new file mode 100644 index 0000000000..c6b3c9246a --- /dev/null +++ b/frontend/src/core/components/shared/wetSignature/TypeSignatureText.tsx @@ -0,0 +1,160 @@ +import { Stack, TextInput, Select, ColorPicker, Slider, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useEffect, useRef } from 'react'; + +interface TypeSignatureTextProps { + text: string; + fontFamily: string; + fontSize: number; + color: string; + onTextChange: (text: string) => void; + onFontFamilyChange: (fontFamily: string) => void; + onFontSizeChange: (fontSize: number) => void; + onColorChange: (color: string) => void; + onSignatureChange: (signature: string | null) => void; + disabled?: boolean; +} + +export const TypeSignatureText: React.FC = ({ + text, + fontFamily, + fontSize, + color, + onTextChange, + onFontFamilyChange, + onFontSizeChange, + onColorChange, + onSignatureChange, + disabled = false, +}) => { + const { t } = useTranslation(); + const canvasRef = useRef(null); + + // Generate signature image when text/style changes + useEffect(() => { + if (!text || !canvasRef.current) { + onSignatureChange(null); + return; + } + + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Set font and measure text + ctx.font = `${fontSize}px ${fontFamily}`; + const metrics = ctx.measureText(text); + const textWidth = metrics.width; + const _textHeight = fontSize * 1.2; // Approximate height + + // Center text on canvas + ctx.fillStyle = color; + ctx.textBaseline = 'middle'; + ctx.fillText(text, (canvas.width - textWidth) / 2, canvas.height / 2); + + // Convert to base64 + const dataUrl = canvas.toDataURL('image/png'); + onSignatureChange(dataUrl); + }, [text, fontFamily, fontSize, color, onSignatureChange]); + + const fontOptions = [ + { value: 'Arial', label: 'Arial' }, + { value: 'Times New Roman', label: 'Times New Roman' }, + { value: 'Courier New', label: 'Courier New' }, + { value: 'Georgia', label: 'Georgia' }, + { value: 'Verdana', label: 'Verdana' }, + { value: 'Comic Sans MS', label: 'Comic Sans MS' }, + { value: 'Brush Script MT', label: 'Brush Script MT (cursive)' }, + ]; + + return ( + + + {t('certSign.collab.signRequest.typeSignature', 'Type your name to create a signature')} + + + onTextChange(e.target.value)} + disabled={disabled} + /> + + + + ); +}; diff --git a/frontend/src/core/components/tools/certSign/CertificateSelector.tsx b/frontend/src/core/components/tools/certSign/CertificateSelector.tsx new file mode 100644 index 0000000000..ab35e7e3ef --- /dev/null +++ b/frontend/src/core/components/tools/certSign/CertificateSelector.tsx @@ -0,0 +1,209 @@ +import { Stack, Radio, Divider, TextInput, Text, Group, Button } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useEffect } from 'react'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import FileUploadButton from '@app/components/shared/FileUploadButton'; + +export type CertificateType = 'USER_CERT' | 'SERVER' | 'UPLOAD'; +export type UploadFormat = 'PKCS12' | 'PFX' | 'PEM' | 'JKS'; + +interface CertificateSelectorProps { + certType: CertificateType; + onCertTypeChange: (certType: CertificateType) => void; + uploadFormat: UploadFormat; + onUploadFormatChange: (format: UploadFormat) => void; + p12File: File | null; + onP12FileChange: (file: File | null) => void; + privateKeyFile: File | null; + onPrivateKeyFileChange: (file: File | null) => void; + certFile: File | null; + onCertFileChange: (file: File | null) => void; + jksFile: File | null; + onJksFileChange: (file: File | null) => void; + password: string; + onPasswordChange: (password: string) => void; + disabled?: boolean; +} + +export const CertificateSelector: React.FC = ({ + certType, + onCertTypeChange, + uploadFormat, + onUploadFormatChange, + p12File, + onP12FileChange, + privateKeyFile, + onPrivateKeyFileChange, + certFile, + onCertFileChange, + jksFile, + onJksFileChange, + password, + onPasswordChange, + disabled = false, +}) => { + const { t } = useTranslation(); + const { config } = useAppConfig(); + const isServerPlan = config?.runningProOrHigher ?? false; + + // If managed cert types are not available, reset to UPLOAD + useEffect(() => { + if (!isServerPlan && (certType === 'USER_CERT' || certType === 'SERVER')) { + onCertTypeChange('UPLOAD'); + } + }, [isServerPlan, certType, onCertTypeChange]); + + const handleFormatChange = (fmt: UploadFormat) => { + onUploadFormatChange(fmt); + onP12FileChange(null); + onPrivateKeyFileChange(null); + onCertFileChange(null); + onJksFileChange(null); + onPasswordChange(''); + }; + + const showPassword = + ((uploadFormat === 'PKCS12' || uploadFormat === 'PFX') && p12File) || + (uploadFormat === 'PEM' && privateKeyFile && certFile) || + (uploadFormat === 'JKS' && jksFile); + + return ( + + {/* Managed certificate options — server plan only */} + {isServerPlan && ( + onCertTypeChange(val as CertificateType)} + > + + + + {t('certSign.collab.signRequest.usePersonalCert', 'Personal Certificate')} + + + {t('certSign.collab.signRequest.usePersonalCertDesc', 'Auto-generated for your account')} + + + } + /> + + + {t('certSign.collab.signRequest.useServerCert', 'Organization Certificate')} + + + {t('certSign.collab.signRequest.useServerCertDesc', 'Shared organization certificate')} + + + } + /> + + {t('certSign.collab.signRequest.uploadCert', 'Custom Certificate')} + + } + /> +
+ + )} + + {/* Upload section */} + {certType === 'UPLOAD' && ( + + {isServerPlan && ( + + )} + + {/* Format picker */} + + {(['PKCS12', 'PFX', 'PEM', 'JKS'] as UploadFormat[]).map((fmt) => ( + + ))} + + + {/* PKCS12 / PFX */} + {(uploadFormat === 'PKCS12' || uploadFormat === 'PFX') && ( + onP12FileChange(file || null)} + accept=".p12,.pfx" + disabled={disabled} + placeholder={ + uploadFormat === 'PFX' + ? t('certSign.choosePfxFile', 'Choose PFX File') + : t('certSign.chooseP12File', 'Choose PKCS12 File') + } + /> + )} + + {/* PEM */} + {uploadFormat === 'PEM' && ( + + onPrivateKeyFileChange(file || null)} + accept=".pem,.der,.key" + disabled={disabled} + placeholder={t('certSign.choosePrivateKey', 'Choose Private Key File')} + /> + {privateKeyFile && ( + onCertFileChange(file || null)} + accept=".pem,.der,.crt,.cer" + disabled={disabled} + placeholder={t('certSign.chooseCertificate', 'Choose Certificate File')} + /> + )} + + )} + + {/* JKS */} + {uploadFormat === 'JKS' && ( + onJksFileChange(file || null)} + accept=".jks,.keystore" + disabled={disabled} + placeholder={t('certSign.chooseJksFile', 'Choose JKS File')} + /> + )} + + {/* Password */} + {showPassword && ( + onPasswordChange(e.target.value)} + disabled={disabled} + size="sm" + /> + )} + + )} +
+ ); +}; diff --git a/frontend/src/core/components/tools/certSign/SessionDetailWorkbenchView.tsx b/frontend/src/core/components/tools/certSign/SessionDetailWorkbenchView.tsx new file mode 100644 index 0000000000..afb4c28a77 --- /dev/null +++ b/frontend/src/core/components/tools/certSign/SessionDetailWorkbenchView.tsx @@ -0,0 +1,418 @@ +import { useState, useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Stack, Paper, Text, Group, Badge, Button, Divider, Modal, SegmentedControl } from '@mantine/core'; +import { useIsPhone } from '@app/hooks/useIsMobile'; +import { alert } from '@app/components/toast'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import DeleteIcon from '@mui/icons-material/Delete'; +import ZoomInIcon from '@mui/icons-material/ZoomIn'; +import ZoomOutIcon from '@mui/icons-material/ZoomOut'; +import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap'; +import { Z_INDEX_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; +import { SessionDetail } from '@app/types/signingSession'; +import { LocalEmbedPDFWithAnnotations, SignaturePreview, AnnotationAPI } from '@app/components/viewer/LocalEmbedPDFWithAnnotations'; +import { getFileColor } from '@app/components/pageEditor/fileColors'; +import { ParticipantListPanel } from '@app/components/tools/certSign/panels/ParticipantListPanel'; +import { SessionActionsPanel } from '@app/components/tools/certSign/panels/SessionActionsPanel'; +import { AddParticipantsFlow } from '@app/components/tools/certSign/modals/AddParticipantsFlow'; + +export interface SessionDetailWorkbenchData { + session: SessionDetail; + pdfFile: File | null; + onFinalize: () => Promise; + onLoadSignedPdf: () => Promise; + onAddParticipants: (userIds: number[], defaultReason?: string) => Promise; + onRemoveParticipant: (participantId: number) => Promise; + onDelete: () => Promise; + onBack: () => void; + onRefresh: () => Promise; +} + +interface SessionDetailWorkbenchViewProps { + data: SessionDetailWorkbenchData; +} + +const SessionDetailWorkbenchView = ({ data }: SessionDetailWorkbenchViewProps) => { + const { t } = useTranslation(); + const isPhone = useIsPhone(); + const [mobilePanel, setMobilePanel] = useState<'participants' | 'pdf' | 'actions'>('pdf'); + const { + session, + pdfFile, + onFinalize, + onLoadSignedPdf, + onAddParticipants, + onRemoveParticipant, + onDelete, + onBack, + onRefresh, + } = data; + + // Ref for annotation API (to access zoom controls) + const annotationApiRef = useRef(null); + + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + const [addParticipantsModalOpen, setAddParticipantsModalOpen] = useState(false); + const [finalizing, setFinalizing] = useState(false); + const [deleting, setDeleting] = useState(false); + const [loadingPdf, setLoadingPdf] = useState(false); + + // Auto-refresh every 30 seconds when not finalized + useEffect(() => { + if (!session.finalized) { + const interval = setInterval(() => { + onRefresh(); + }, 30000); + return () => clearInterval(interval); + } + }, [session.finalized, onRefresh]); + + const handleAddParticipants = async (userIds: number[], defaultReason?: string) => { + try { + await onAddParticipants(userIds, defaultReason); + alert({ + alertType: 'success', + title: t('success'), + body: t('certSign.collab.sessionDetail.participantsAdded', 'Participants added successfully'), + }); + } catch (_error) { + alert({ + alertType: 'error', + title: t('common.error'), + body: t('certSign.collab.sessionDetail.addParticipantsError', 'Failed to add participants'), + }); + throw _error; // Re-throw so modal can handle loading state + } + }; + + const handleRemoveParticipant = async (participantId: number) => { + try { + await onRemoveParticipant(participantId); + alert({ + alertType: 'success', + title: t('success'), + body: t('certSign.collab.sessionDetail.participantRemoved', 'Participant removed'), + }); + } catch (_error) { + alert({ + alertType: 'error', + title: t('common.error'), + body: t('certSign.collab.sessionDetail.removeParticipantError', 'Failed to remove participant'), + }); + } + }; + + const handleFinalize = async () => { + setFinalizing(true); + try { + await onFinalize(); + } catch (_error) { + alert({ + alertType: 'error', + title: t('common.error'), + body: t('certSign.collab.sessionDetail.finalizeError', 'Failed to finalize session'), + }); + } finally { + setFinalizing(false); + } + }; + + const handleDelete = async () => { + setDeleting(true); + try { + await onDelete(); + setDeleteModalOpen(false); + alert({ + alertType: 'success', + title: t('success'), + body: t('certSign.collab.sessionDetail.deleted', 'Session deleted'), + }); + } catch (_error) { + alert({ + alertType: 'error', + title: t('common.error'), + body: t('certSign.collab.sessionDetail.deleteError', 'Failed to delete session'), + }); + setDeleting(false); + } + }; + + const handleLoadSignedPdf = async () => { + setLoadingPdf(true); + try { + await onLoadSignedPdf(); + } catch (_error) { + alert({ + alertType: 'error', + title: t('common.error'), + body: t('certSign.collab.sessionDetail.loadPdfError', 'Failed to load signed PDF'), + }); + } finally { + setLoadingPdf(false); + } + }; + + // Extract wet signatures from all participants for preview + const wetSignaturePreviews = useMemo(() => { + const previews: SignaturePreview[] = []; + + session.participants.forEach((participant, participantIndex) => { + if (participant.wetSignatures && participant.wetSignatures.length > 0) { + const color = getFileColor(participantIndex); + const participantName = participant.name || participant.email; + participant.wetSignatures.forEach((wetSig, sigIndex) => { + previews.push({ + id: `participant-${participant.userId}-sig-${sigIndex}`, + pageIndex: wetSig.page, + x: wetSig.x, + y: wetSig.y, + width: wetSig.width, + height: wetSig.height, + signatureData: wetSig.data, + signatureType: 'image' as const, + color, + participantName, + }); + }); + } + }); + + return previews; + }, [session.participants]); + + return ( +
+ {/* Top Control Bar */} + + + + + + + + + {session.documentName} + + + {session.finalized + ? t('certSign.collab.sessionList.finalized', 'Finalized') + : t('certSign.collab.sessionList.active', 'Active')} + + + {!isPhone && ( + + {session.ownerEmail && `${t('certSign.collab.sessionDetail.owner', 'Owner')}: ${session.ownerEmail}`} + {session.ownerEmail && ' • '} + {new Date(session.createdAt).toLocaleDateString()} + + )} + + + + + {/* Zoom Controls — hidden on phone (pinch-to-zoom available) */} + {!isPhone && ( + + + + + + )} + + {/* Delete Session Button */} + {!session.finalized && ( + + )} + + + + + {/* Main Content Area */} + {isPhone ? ( + // Phone: single-panel view — all three panels stay mounted (CSS display:none preserves state) +
+ + + + +
+ +
+ + + setAddParticipantsModalOpen(true)} + onFinalize={handleFinalize} + onLoadSignedPdf={handleLoadSignedPdf} + finalizing={finalizing} + loadingPdf={loadingPdf} + /> + +
+ ) : ( + // Desktop/tablet: three-column flex layout +
+ {/* Left Panel - Participants */} + + + + + {/* Center - PDF Viewer */} +
+ +
+ + {/* Right Panel - Session Actions */} + + setAddParticipantsModalOpen(true)} + onFinalize={handleFinalize} + onLoadSignedPdf={handleLoadSignedPdf} + finalizing={finalizing} + loadingPdf={loadingPdf} + /> + +
+ )} + + {/* Phone bottom navigation */} + {isPhone && ( + + setMobilePanel(v as typeof mobilePanel)} + data={[ + { value: 'participants', label: t('certSign.mobile.panelPeople', 'People') }, + { value: 'pdf', label: t('certSign.mobile.panelDocument', 'Document') }, + { value: 'actions', label: t('certSign.mobile.panelActions', 'Actions') }, + ]} + /> + + )} + + {/* Add Participants Modal */} + setAddParticipantsModalOpen(false)} + onSubmit={handleAddParticipants} + /> + + {/* Delete Confirmation Modal */} + setDeleteModalOpen(false)} + title={t('certSign.collab.sessionDetail.deleteSession', 'Delete Session')} + > + + {t('certSign.collab.sessionDetail.deleteConfirm', 'Are you sure? This cannot be undone.')} + + + + + + +
+ ); +}; + +export default SessionDetailWorkbenchView; diff --git a/frontend/src/core/components/tools/certSign/SignControlsStrip.module.css b/frontend/src/core/components/tools/certSign/SignControlsStrip.module.css new file mode 100644 index 0000000000..99f2191f6c --- /dev/null +++ b/frontend/src/core/components/tools/certSign/SignControlsStrip.module.css @@ -0,0 +1,643 @@ +.topBar { + position: relative; + display: flex; + align-items: center; + gap: 12px; + height: 52px; + padding: 0 16px; + background: var(--bg-toolbar); + border-bottom: 1px solid var(--border-default); +} + +/* Mobile split: title-only bar at top (keep border-bottom) */ +.topBar[data-mobile-section="title"] { + border-bottom: 1px solid var(--border-default); +} + +/* Mobile split: controls-only bar at bottom; right section fills and centers */ +.topBar[data-mobile-section="controls"] .right { + flex: 1; + justify-content: center; +} + +.left { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + flex: 1; +} + +.fileTitle { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.viewerFileRow { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + flex: 1; + overflow: hidden; + justify-content: flex-start; +} + +.fileName { + min-width: 0; + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.subText { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.activeFilesRow { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; +} + +.fileEditorHeaderRow { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + flex: 1; + justify-content: space-between; +} + +.fileEditorActions { + display: flex; + align-items: center; + flex: 0 0 auto; +} + +.activeFilesContent { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; + flex: 1; +} + +.activeFilesSummary { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + margin-left: auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.right { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + flex: 0 0 auto; + min-width: 0; + flex-wrap: nowrap; +} + +.downloadSlot { + display: flex; + flex: 0 0 auto; +} + +.actionIcon { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + flex-shrink: 0; +} + +.actionIcon > * { + flex-shrink: 0; + display: block; +} + +.actionLabel { + font-size: 13px; + font-weight: 600; + line-height: 1; + white-space: nowrap; + display: inline-block; +} + +.viewerControls { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +/* Responsive: stack controls under the title row on narrow widths */ +@media (max-width: 1200px) { + .topBar { + height: auto; + min-height: 52px; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 8px 12px; + } + + /* Keep the long viewer toolbar usable if it still overflows */ + .viewerControls { + flex: 0 1 auto; + min-width: 0; + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + padding-bottom: 2px; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .viewerControls::-webkit-scrollbar { + display: none; + } + + /* Save horizontal space on smaller screens */ + .zoomSlider { + width: 64px; + } + + /* Keep download pinned to the far right on row 2 */ + .downloadSlot { + margin-left: auto; + } + + /* In file editor (Active files), keep BOTH actions on the left */ + .topBar[data-view="fileEditor"] .downloadSlot { + margin-left: 0; + } + + .topBar[data-view="fileEditor"] .right { + flex-wrap: wrap; + } +} + +/* When the title and controls wrap to two rows, center both rows */ +.topBar[data-wrapped="true"] .left, +.topBar[data-wrapped="true"] .right { + flex: 0 0 100%; + width: 100%; + justify-content: center; +} + +.topBar[data-wrapped="true"] .viewerFileRow { + justify-content: center; +} + +/* On very narrow viewports, prefer wrapping to extra rows over horizontal scrolling */ +@media (max-width: 560px) { + .topBar[data-view="viewer"][data-mobile="false"] .right { + flex-wrap: wrap; + } + + .topBar[data-view="viewer"][data-mobile="false"] .viewerControls { + overflow: visible; + padding-bottom: 0; + flex-wrap: wrap; + row-gap: 6px; + } + + .topBar[data-view="viewer"][data-mobile="false"] .viewerControls .divider { + display: none; + } + + /* Push zoom controls to their own row when space is tight */ + .topBar[data-view="viewer"][data-mobile="false"] .zoomPill { + flex: 1 1 100%; + justify-content: flex-start; + } + + /* Make download drop to its own row if needed */ + .topBar[data-view="viewer"][data-mobile="false"] .downloadSlot { + flex: 1 1 100%; + justify-content: flex-start; + margin-left: 0; + } +} + +/* Mobile workbench tweaks (use runtime mobile mode, not viewport width) */ +@media (max-width: 1200px) { + .topBar[data-view="viewer"][data-mobile="true"] .downloadSlot { + margin-left: 0; + } +} + +.topBar[data-view="viewer"][data-mobile="true"][data-wrapped="false"] .right { + justify-content: flex-start; + flex-wrap: nowrap; +} + +.topBar[data-view="viewer"][data-mobile="true"][data-wrapped="false"] .viewerControls { + flex: 1 1 auto; + overflow: hidden; +} + +/* Tighten sizing specifically inside the viewer toolbar cluster */ +.viewerControls .iconButton { + width: 28px; + height: 28px; +} + +.pagePill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0; + border-radius: 999px; + border: none; + background: transparent; + color: var(--text-secondary); +} + +.pageInput { + width: 30px; + height: 22px; + border: none; + background: transparent; + color: var(--text-primary); + font-size: 12px; + font-weight: 600; + text-align: center; + outline: none; + padding: 0; +} + +.pageInput::-webkit-outer-spin-button, +.pageInput::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.pageDivider { + color: var(--text-muted); + font-size: 12px; +} + +.pageTotal { + min-width: 18px; + text-align: left; + color: var(--text-muted); + font-size: 12px; + font-weight: 600; +} + +.zoomPill { + display: inline-flex; + align-items: center; + gap: 6px; + height: 24px; + padding: 0; + border-radius: 999px; + border: none; + background: transparent; + color: var(--text-secondary); +} + +.zoomButton { + width: 24px; + height: 24px; + border-radius: 999px; + border: none; + background: transparent; + color: var(--text-secondary); + font-size: 16px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.zoomButton:hover { + background: var(--hover-bg); + color: var(--text-primary); +} + +.zoomSlider { + width: 80px; + accent-color: var(--mantine-color-blue-6, #3b82f6); +} + +.zoomLabel { + min-width: 36px; + text-align: right; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); +} + +.divider { + width: 1px; + height: 18px; + background: var(--border-default); + margin: 0 4px; +} + +.iconButton { + width: 32px; + height: 32px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--text-secondary); + transition: background-color 0.15s ease, color 0.15s ease; +} + +.iconButton.iconTextButton { + width: auto; + min-width: 0; + height: 32px; + padding: 0 12px; + border-radius: 999px; + gap: 8px; + justify-content: flex-start; +} + +@media (max-width: 420px) { + .iconButton.iconTextButton { + padding: 0 10px; + } + + .iconButton.iconTextButton .actionLabel { + display: none; + } +} + +.iconButton:hover { + background: var(--hover-bg); + color: var(--text-primary); +} + +.iconButton:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.iconButton:disabled:hover { + background: transparent; + color: var(--text-secondary); +} + +/* --- Sign controls strip (part of normal layout) --- */ +.signStrip { + position: relative; + width: 100%; + border-top: 1px solid var(--border-default); + background: var(--bg-toolbar); +} + +.signStrip[data-open="true"] { + /* Always visible when open */ +} + +.signStripInner { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; +} + +.signStripControls { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + flex-wrap: wrap; +} + +.signStripOptions { + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + flex-wrap: nowrap; +} + +.signStripActions { + display: flex; + align-items: center; + gap: 8px; + flex: 1 1 auto; + justify-content: flex-end; + min-width: 0; + flex-wrap: nowrap; +} + +.signStripSpacer { + flex: 1 1 auto; +} + +.signStripHint { + display: inline-flex; + margin-left: 6px; +} + +.signStripButton { + color: var(--text-secondary); +} + +.signStripPill { + height: 28px; + border-radius: 999px; +} + +/* --- Signing UI (strip) --- */ +.signingRow { + width: 100%; + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.signingLeft { + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + order: 0; +} + +.signingCenter { + flex: 1 1 auto; + min-width: 120px; + order: 1; +} + +.signingMode { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + order: 2; +} + +.signingRight { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 12px; + flex: 0 0 auto; + order: 3; +} + +.signingPreviewButton { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + border-radius: 10px; + border: none; + background: transparent; + cursor: pointer; +} + +.signingPreviewButton:hover { + background: color-mix(in srgb, var(--bg-toolbar) 75%, transparent); +} + +.signingPreviewFrame { + background: #ffffff; + border-radius: 8px; + padding: 2px 8px; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; +} + +.signingPreviewChevron { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); +} + +.signingHint { + margin-left: 0; +} + +.signingHintUnit { + margin: 0 12px; +} + +.keyCap { + display: inline-flex; + vertical-align: middle; + color: var(--text-secondary); + margin: 0 8px; +} + +.signingStatus { + white-space: nowrap; +} + +.signingDivider { + width: 1px; + height: 18px; + background: var(--border-default); + margin: 0 6px; + flex: 0 0 auto; +} + +.signStripCloseButton { + width: 28px; + height: 28px; +} + +/* Mobile-only delete button (no Backspace key); hidden on desktop */ +.signStripMobileDelete { + display: none; +} + +/* Sign strip mode: Place vs Move (radio-style segmented control) */ +.signStripModeRadio { + flex: 0 0 auto; +} + +.signStripModeRadio [data-mantine-segmented-control-indicator] { + background: var(--bg-elevated); + border-radius: var(--mantine-radius-xl); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); +} + +.signingTitle { + /* Add styles for signing title */ +} + +/* Mobile: row 1 = close + radio (no title); row 2 = preview + apply */ +@media (max-width: 720px) { + .signingLeft { + order: 0; + } + + .signingTitle { + display: none; + } + + .signingCenter { + flex: 1 1 auto; + min-width: 0; + order: 2; + } + + .signingMode { + order: 1; + } + + .signingRight { + order: 3; + width: 100%; + justify-content: flex-start; + margin-left: 0; + padding-top: 6px; + border-top: 1px solid var(--border-default); + } + + .signStripMobileDelete { + display: inline-flex; + width: 28px; + height: 28px; + } +} + +/* When the strip gets narrow, split into two rows: + options on row 1, actions on row 2 (with divider line). */ +@media (max-width: 720px) { + .signStripOptions { + flex: 1 1 100%; + width: 100%; + } + + .signStripActions { + flex: 1 1 100%; + width: 100%; + justify-content: flex-start; + padding-top: 8px; + } +} diff --git a/frontend/src/core/components/tools/certSign/SignControlsStrip.tsx b/frontend/src/core/components/tools/certSign/SignControlsStrip.tsx new file mode 100644 index 0000000000..62e61baa9e --- /dev/null +++ b/frontend/src/core/components/tools/certSign/SignControlsStrip.tsx @@ -0,0 +1,634 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ActionIcon, Box, Button, Group, Menu, Modal, SegmentedControl, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import DrawIcon from '@mui/icons-material/Draw'; +import ImageIcon from '@mui/icons-material/Image'; +import OpenWithIcon from '@mui/icons-material/OpenWith'; +import TextFieldsIcon from '@mui/icons-material/TextFields'; +import CloseIcon from '@mui/icons-material/Close'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import CheckIcon from '@mui/icons-material/Check'; + +import { DEFAULT_PARAMETERS, type SignParameters } from '@app/hooks/tools/sign/useSignParameters'; +import { useSavedSignatures, type SavedSignature } from '@app/hooks/tools/sign/useSavedSignatures'; +import { DrawingCanvas } from '@app/components/annotation/shared/DrawingCanvas'; +import { ColorPicker } from '@app/components/annotation/shared/ColorPicker'; +import { TextInputWithFont } from '@app/components/annotation/shared/TextInputWithFont'; +import { buildSignaturePreview } from '@app/utils/signaturePreview'; + +import styles from '@app/components/tools/certSign/SignControlsStrip.module.css'; + +interface SignControlsStripProps { + visible: boolean; + placementMode: boolean; + onPlacementModeChange: (active: boolean) => void; + onSignatureSelected: (config: SignParameters) => void; + onComplete: () => void; + canComplete: boolean; + signatureConfig: SignParameters | null; + hasSelectedAnnotation?: boolean; + onDeleteSelected?: () => void; +} + +export default function SignControlsStrip({ + visible, + placementMode, + onPlacementModeChange, + onSignatureSelected, + onComplete, + canComplete, + signatureConfig, + hasSelectedAnnotation = false, + onDeleteSelected, +}: SignControlsStripProps) { + const { t } = useTranslation(); + const { + savedSignatures, + addSignature, + removeSignature, + isAtCapacity, + byTypeCounts, + } = useSavedSignatures(); + + const [createSignatureType, setCreateSignatureType] = useState<'canvas' | 'text' | 'image' | null>(null); + const [canvasColorPickerOpen, setCanvasColorPickerOpen] = useState(false); + const [canvasColor, setCanvasColor] = useState('#000000'); + const [canvasPenSize, setCanvasPenSize] = useState(2); + const [canvasPenSizeInput, setCanvasPenSizeInput] = useState('2'); + const latestCanvasDataRef = useRef(undefined); + + const fileInputRef = useRef(null); + const [textSignerName, setTextSignerName] = useState(DEFAULT_PARAMETERS.signerName ?? ''); + const [textFontFamily, setTextFontFamily] = useState(DEFAULT_PARAMETERS.fontFamily ?? 'Helvetica'); + const [textFontSize, setTextFontSize] = useState(DEFAULT_PARAMETERS.fontSize ?? 16); + const [textColor, setTextColor] = useState(DEFAULT_PARAMETERS.textColor ?? '#000000'); + + const renderSavedSignaturePreview = useCallback( + (sig: SavedSignature) => { + if (sig.type === 'text') { + return ( + + + {sig.signerName} + + + ); + } + + return ( + + + + ); + }, + [t] + ); + + const sortedSavedSignatures = useMemo(() => { + if (!savedSignatures.length) return []; + return [...savedSignatures].sort((a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt)); + }, [savedSignatures]); + + const hasAutoSelected = useRef(false); + useEffect(() => { + if (hasAutoSelected.current) return; + if (!sortedSavedSignatures.length) return; + if (signatureConfig?.signatureData) return; + + hasAutoSelected.current = true; + const lastSig = sortedSavedSignatures[0]; + if (lastSig.type === 'text') { + onSignatureSelected({ + ...DEFAULT_PARAMETERS, + signatureType: 'text', + signerName: lastSig.signerName, + fontFamily: lastSig.fontFamily, + fontSize: lastSig.fontSize, + textColor: lastSig.textColor, + signatureData: lastSig.dataUrl, + }); + } else { + onSignatureSelected({ + ...DEFAULT_PARAMETERS, + signatureType: lastSig.type, + signatureData: lastSig.dataUrl, + }); + } + }, [sortedSavedSignatures, signatureConfig?.signatureData, onSignatureSelected]); + + const beginPlacement = useCallback( + (config: SignParameters) => { + const nextConfig: SignParameters = { + ...DEFAULT_PARAMETERS, + ...config, + }; + + onSignatureSelected(nextConfig); + onPlacementModeChange(true); + }, + [onSignatureSelected, onPlacementModeChange] + ); + + const applySavedSignature = useCallback( + (sig: SavedSignature) => { + if (sig.type === 'text') { + beginPlacement({ + signatureType: 'text', + signerName: sig.signerName, + fontFamily: sig.fontFamily, + fontSize: sig.fontSize, + textColor: sig.textColor, + signatureData: sig.dataUrl, + }); + return; + } + beginPlacement({ signatureType: sig.type, signatureData: sig.dataUrl }); + }, + [beginPlacement] + ); + + const pausePlacement = useCallback(() => { + onPlacementModeChange(false); + }, [onPlacementModeChange]); + + const resumePlacement = useCallback(() => { + onPlacementModeChange(true); + }, [onPlacementModeChange]); + + const handleCreateSignature = useCallback((type: 'canvas' | 'text' | 'image') => { + if (type === 'image') { + fileInputRef.current?.click(); + return; + } + setCreateSignatureType(type); + if (type === 'canvas') { + setCanvasColor('#000000'); + setCanvasPenSize(2); + setCanvasPenSizeInput('2'); + latestCanvasDataRef.current = undefined; + } else if (type === 'text') { + setTextSignerName(''); + } + }, []); + + const handleCancelCreate = useCallback(() => { + setCreateSignatureType(null); + }, []); + + const saveTextToLibrary = useCallback(async () => { + const signerName = textSignerName.trim(); + if (!signerName || isAtCapacity) return null; + + const preview = await buildSignaturePreview({ + signatureType: 'text', + signerName, + fontFamily: textFontFamily, + fontSize: textFontSize, + textColor, + }); + if (!preview?.dataUrl) return null; + + const nextIndex = (byTypeCounts?.text ?? 0) + 1; + const baseLabel = t('certSign.collab.signRequest.saved.defaultTextLabel', 'Typed signature'); + await addSignature( + { + type: 'text', + dataUrl: preview.dataUrl, + signerName, + fontFamily: textFontFamily, + fontSize: textFontSize, + textColor, + }, + `${baseLabel} ${nextIndex}`, + 'localStorage' + ); + return { + signerName, + fontFamily: textFontFamily, + fontSize: textFontSize, + textColor, + dataUrl: preview.dataUrl, + }; + }, [ + addSignature, + byTypeCounts?.text, + isAtCapacity, + t, + textColor, + textFontFamily, + textFontSize, + textSignerName, + ]); + + const saveImageToLibrary = useCallback( + async (dataUrl: string) => { + if (!dataUrl || isAtCapacity) return; + const nextIndex = (byTypeCounts?.image ?? 0) + 1; + const baseLabel = t('certSign.collab.signRequest.saved.defaultImageLabel', 'Uploaded signature'); + await addSignature({ type: 'image', dataUrl }, `${baseLabel} ${nextIndex}`, 'localStorage'); + }, + [addSignature, byTypeCounts?.image, isAtCapacity, t] + ); + + const readFileAsDataUrl = useCallback(async (file: File): Promise => { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const value = reader.result; + if (typeof value === 'string') resolve(value); + else reject(new Error('Failed to read image as data URL')); + }; + reader.onerror = () => reject(reader.error ?? new Error('Failed to read file')); + reader.readAsDataURL(file); + }); + }, []); + + const saveCanvasToLibrary = useCallback( + async (dataUrl: string) => { + if (!dataUrl || isAtCapacity) return; + const nextIndex = (byTypeCounts?.canvas ?? 0) + 1; + const baseLabel = t('certSign.collab.signRequest.saved.defaultCanvasLabel', 'Drawing signature'); + await addSignature({ type: 'canvas', dataUrl }, `${baseLabel} ${nextIndex}`, 'localStorage'); + }, + [addSignature, byTypeCounts?.canvas, isAtCapacity, t] + ); + + useEffect(() => { + if (!visible || !signatureConfig) return; + + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + const isTypingTarget = + target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || (target as any)?.isContentEditable; + if (isTypingTarget) return; + + if (event.key === 'Escape') { + pausePlacement(); + return; + } + + if (event.key === 'Backspace') { + event.preventDefault(); + onDeleteSelected?.(); + } + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [pausePlacement, onDeleteSelected, signatureConfig, visible]); + + const handleCanvasSignatureChange = useCallback( + (dataUrl: string | null) => { + latestCanvasDataRef.current = dataUrl ?? undefined; + }, + [] + ); + + const handleDrawingComplete = useCallback(async () => { + const dataUrl = latestCanvasDataRef.current; + if (!dataUrl) return; + await saveCanvasToLibrary(dataUrl); + beginPlacement({ signatureType: 'canvas', signatureData: dataUrl }); + setCreateSignatureType(null); + latestCanvasDataRef.current = undefined; + }, [saveCanvasToLibrary, beginPlacement]); + + const handleSaveText = useCallback(async () => { + const saved = await saveTextToLibrary(); + if (!saved) return; + beginPlacement({ + signatureType: 'text', + signerName: saved.signerName, + fontFamily: saved.fontFamily, + fontSize: saved.fontSize, + textColor: saved.textColor, + signatureData: saved.dataUrl, + }); + setCreateSignatureType(null); + setTextSignerName(''); + }, [saveTextToLibrary, beginPlacement]); + + const handleImageSelected = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + e.target.value = ''; + if (!file) return; + try { + const dataUrl = await readFileAsDataUrl(file); + await saveImageToLibrary(dataUrl); + beginPlacement({ signatureType: 'image', signatureData: dataUrl }); + setCreateSignatureType(null); + } catch (err) { + console.error('Failed to read signature image:', err); + } + }, + [readFileAsDataUrl, saveImageToLibrary, beginPlacement] + ); + + if (!visible || !signatureConfig) return null; + + const previewNode = + signatureConfig.signatureType === 'text' ? ( +
+ {(signatureConfig.signerName ?? '').trim() || t('certSign.collab.signRequest.preview.textFallback', 'Signature')} +
+ ) : ( +
+ {signatureConfig.signatureData ? ( + {t('certSign.collab.signRequest.preview.imageAlt', + ) : ( + + {t('certSign.collab.signRequest.preview.missing', 'No preview')} + + )} +
+ ); + + return ( +
+
+
+
+ + {t('certSign.collab.signRequest.signingTitle', 'Signing')} + +
+ + + + {/* Draw Signature — auto-opens its inner canvas modal directly */} + {createSignatureType === 'canvas' && ( + setCanvasColorPickerOpen(true)} + onPenSizeChange={(size) => { + setCanvasPenSize(size); + setCanvasPenSizeInput(String(size)); + }} + onPenSizeInputChange={(input) => { + setCanvasPenSizeInput(input); + const next = Number(input); + if (Number.isFinite(next) && next > 0 && next <= 50) { + setCanvasPenSize(next); + } + }} + onSignatureDataChange={handleCanvasSignatureChange} + onDrawingComplete={handleDrawingComplete} + onModalClose={handleCancelCreate} + width={600} + height={200} + /> + )} + + {/* Type Signature Modal */} + + + + {t('certSign.collab.signRequest.text.modalHint', 'Enter your name, then click Continue to place it on the PDF.')} + + + + + + + + + + {canvasColorPickerOpen && ( + setCanvasColorPickerOpen(false)} + selectedColor={canvasColor} + onColorChange={setCanvasColor} + title={t('certSign.collab.signRequest.canvas.colorPickerTitle', 'Choose stroke colour')} + /> + )} + + +
+ ); +} diff --git a/frontend/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx b/frontend/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx new file mode 100644 index 0000000000..5298c61750 --- /dev/null +++ b/frontend/src/core/components/tools/certSign/SignRequestWorkbenchView.tsx @@ -0,0 +1,374 @@ +import { useState, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Paper, Group, Button, Text, Divider, CloseButton } from '@mantine/core'; +import { useIsPhone } from '@app/hooks/useIsMobile'; +import CancelIcon from '@mui/icons-material/Cancel'; +import FolderOpenIcon from '@mui/icons-material/FolderOpen'; +import ZoomInIcon from '@mui/icons-material/ZoomIn'; +import ZoomOutIcon from '@mui/icons-material/ZoomOut'; +import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap'; +import { LocalIcon } from '@app/components/shared/LocalIcon'; +import { Z_INDEX_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; +import { SignRequestDetail } from '@app/types/signingSession'; +import { LocalEmbedPDFWithAnnotations, AnnotationAPI } from '@app/components/viewer/LocalEmbedPDFWithAnnotations'; +import { alert } from '@app/components/toast'; +import SignControlsStrip from '@app/components/tools/certSign/SignControlsStrip'; +import { CertificateConfigModal } from '@app/components/tools/certSign/modals/CertificateConfigModal'; +import type { CertificateSubmitData } from '@app/components/tools/certSign/modals/CertificateConfigModal'; +import { SignParameters } from '@app/hooks/tools/sign/useSignParameters'; +import { useFileActions } from '@app/contexts/file/fileHooks'; + +export interface SignRequestWorkbenchData { + signRequest: SignRequestDetail; + pdfFile: File; + onSign: (certificateData: FormData) => Promise; + onDecline: () => Promise; + onBack: () => void; + canSign: boolean; +} + +interface SignRequestWorkbenchViewProps { + data: SignRequestWorkbenchData; +} + +const SignRequestWorkbenchView = ({ data }: SignRequestWorkbenchViewProps) => { + const { t } = useTranslation(); + const isPhone = useIsPhone(); + const { signRequest, pdfFile, onSign, onDecline, onBack, canSign } = data; + const { actions: fileActions } = useFileActions(); + + // Ref for annotation API + const annotationApiRef = useRef(null); + + // Signature state - start with default config if user can sign + const [signatureConfig, setSignatureConfig] = useState( + canSign + ? { + signatureType: 'canvas', + signerName: '', + fontFamily: 'Helvetica', + fontSize: 16, + textColor: '#000000', + } + : null + ); + const [previewCount, setPreviewCount] = useState(0); + const [placementMode, setPlacementMode] = useState(true); + const [hasSelectedAnnotation, setHasSelectedAnnotation] = useState(false); + + // Certificate modal state + const [certificateModalOpen, setCertificateModalOpen] = useState(false); + + // Process state + const [signing, setSigning] = useState(false); + const [declining, setDeclining] = useState(false); + + // Show/hide sign controls strip - always visible when user can sign + const signControlsVisible = canSign && signatureConfig !== null; + + // Check for selected annotation periodically + useEffect(() => { + if (!signControlsVisible || !annotationApiRef.current) { + setHasSelectedAnnotation(false); + return; + } + const check = () => { + const has = (annotationApiRef.current as any)?.getHasSelectedAnnotation?.(); + setHasSelectedAnnotation(Boolean(has)); + }; + check(); + const id = setInterval(check, 350); + return () => clearInterval(id); + }, [signControlsVisible]); + + const handleSignatureSelected = (config: SignParameters) => { + setSignatureConfig(config); + }; + + const handleOpenCertificateModal = () => { + if (previewCount === 0) { + alert({ + alertType: 'error', + title: t('common.error'), + body: t('certSign.collab.signRequest.noSignatures', 'Please place at least one signature on the PDF'), + }); + return; + } + setCertificateModalOpen(true); + }; + + const handleSign = async (certData: CertificateSubmitData, reason?: string, location?: string) => { + const previews = annotationApiRef.current?.getSignaturePreviews() || []; + console.log('handleSign called, previews:', previews.length, 'signatures'); + + setSigning(true); + try { + const formData = new FormData(); + + if (certData.certType === 'UPLOAD') { + const { uploadFormat, p12File, privateKeyFile, certFile, jksFile, password } = certData; + formData.append('certType', uploadFormat); + switch (uploadFormat) { + case 'PKCS12': + case 'PFX': + if (!p12File) { + alert({ + alertType: 'error', + title: t('common.error'), + body: t('certSign.collab.signRequest.noCertificate', 'Please select a certificate file'), + }); + setSigning(false); + return; + } + formData.append('p12File', p12File); + break; + case 'PEM': + if (!privateKeyFile || !certFile) { + alert({ + alertType: 'error', + title: t('common.error'), + body: t('certSign.collab.signRequest.noCertificate', 'Please select a certificate file'), + }); + setSigning(false); + return; + } + formData.append('privateKeyFile', privateKeyFile); + formData.append('certFile', certFile); + break; + case 'JKS': + if (!jksFile) { + alert({ + alertType: 'error', + title: t('common.error'), + body: t('certSign.collab.signRequest.noCertificate', 'Please select a certificate file'), + }); + setSigning(false); + return; + } + formData.append('jksFile', jksFile); + break; + } + if (password) { + formData.append('password', password); + } + } else { + formData.append('certType', certData.certType); + } + + // Add signature appearance settings from sign request + if (signRequest.showSignature !== undefined) { + formData.append('showSignature', signRequest.showSignature.toString()); + } + if (signRequest.pageNumber !== undefined && signRequest.pageNumber !== null) { + formData.append('pageNumber', signRequest.pageNumber.toString()); + } + + // Participant-provided reason/location override session defaults + if (reason && reason.trim()) { + formData.append('reason', reason); + } else if (signRequest.reason) { + formData.append('reason', signRequest.reason); + } + + if (location && location.trim()) { + formData.append('location', location); + } else if (signRequest.location) { + formData.append('location', signRequest.location); + } + + if (signRequest.showLogo !== undefined) { + formData.append('showLogo', signRequest.showLogo.toString()); + } + + // Add all wet signatures from previews + if (previews.length > 0) { + const wetSignaturesJson = previews.map((preview) => ({ + type: preview.signatureType, + data: preview.signatureData, + page: preview.pageIndex, + x: preview.x, + y: preview.y, + width: preview.width, + height: preview.height, + })); + + console.log('Sending wet signatures to backend:', wetSignaturesJson.length, 'signatures'); + formData.append('wetSignaturesData', JSON.stringify(wetSignaturesJson)); + } + + await onSign(formData); + setCertificateModalOpen(false); + } catch (error) { + console.error('Failed to sign document:', error); + } finally { + setSigning(false); + } + }; + + const handleDecline = async () => { + setDeclining(true); + try { + await onDecline(); + } catch (error) { + console.error('Failed to decline request:', error); + setDeclining(false); + } + }; + + const handleAddToActiveFiles = async () => { + await fileActions.addFiles([pdfFile]); + alert({ + alertType: 'success', + title: t('success'), + body: t('certSign.collab.signRequest.addedToFiles', 'Document added to active files'), + expandable: false, + durationMs: 2500, + }); + onBack(); + }; + + const handleDeleteSelected = () => { + (annotationApiRef.current as any)?.deleteSelectedAnnotation?.(); + }; + + const handlePlaceSignature = ( + id: string, + pageIndex: number, + x: number, + y: number, + width: number, + height: number + ) => { + console.log('Signature placed:', { id, pageIndex, x, y, width, height }); + }; + + return ( +
+ {/* Top Control Bar */} + + + + +
+ + {signRequest.documentName} + + {!isPhone && ( + + {t('certSign.collab.signRequest.from', 'From')}: {signRequest.ownerUsername} •{' '} + {new Date(signRequest.createdAt).toLocaleDateString()} + + )} +
+
+ + + + {signRequest.myStatus !== 'SIGNED' && signRequest.myStatus !== 'DECLINED' && ( + + )} + {!isPhone && ( + <> + + + + + + + + )} + + + +
+
+ + {/* Sign Controls Strip - always shown when user can sign */} + {canSign && signControlsVisible && ( + 0} + signatureConfig={signatureConfig} + hasSelectedAnnotation={hasSelectedAnnotation} + onDeleteSelected={handleDeleteSelected} + /> + )} + + {/* PDF Viewer (full width) */} +
+ {}} + placementMode={placementMode} + signatureData={signatureConfig?.signatureData} + signatureType={signatureConfig?.signatureType} + onPlaceSignature={handlePlaceSignature} + onPreviewCountChange={setPreviewCount} + /> +
+ + {/* Certificate Configuration Modal */} + {canSign && ( + setCertificateModalOpen(false)} + onSign={handleSign} + signatureCount={previewCount} + disabled={signing} + defaultReason={signRequest.reason || ''} + defaultLocation={signRequest.location || ''} + /> + )} + +
+ ); +}; + +export default SignRequestWorkbenchView; diff --git a/frontend/src/core/components/tools/certSign/SignatureSettingsDisplay.tsx b/frontend/src/core/components/tools/certSign/SignatureSettingsDisplay.tsx new file mode 100644 index 0000000000..00d0658bbd --- /dev/null +++ b/frontend/src/core/components/tools/certSign/SignatureSettingsDisplay.tsx @@ -0,0 +1,126 @@ +import { useTranslation } from 'react-i18next'; +import { Stack, Paper, Text, Group, Badge } from '@mantine/core'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; + +interface SignatureSettingsDisplayProps { + showSignature: boolean; + pageNumber?: number | null; + reason?: string | null; + location?: string | null; + showLogo: boolean; +} + +const SignatureSettingsDisplay = ({ + showSignature, + pageNumber, + reason, + location, + showLogo, +}: SignatureSettingsDisplayProps) => { + const { t } = useTranslation(); + + return ( + + + + + + {t('certSign.appearance.visibility', 'Visibility')} + + + {showSignature ? ( + <> + + + {t('certSign.appearance.visible', 'Visible')} + + + ) : ( + <> + + + {t('certSign.appearance.invisible', 'Invisible')} + + + )} + + + + {showSignature && ( + <> + {pageNumber && ( + + + {t('certSign.pageNumber', 'Page Number')} + + + {pageNumber} + + + )} + + {reason && ( + + + {t('certSign.reason', 'Reason')} + + + {reason} + + + )} + + {location && ( + + + {t('certSign.location', 'Location')} + + + {location} + + + )} + + + + {t('certSign.logoTitle', 'Logo')} + + + {showLogo ? ( + <> + + + {t('certSign.showLogo', 'Show Logo')} + + + ) : ( + <> + + + {t('certSign.noLogo', 'No Logo')} + + + )} + + + + )} + + + + + + {t( + 'certSign.collab.signRequest.signatureInfo', + 'These settings are configured by the document owner' + )} + + + + ); +}; + +export default SignatureSettingsDisplay; diff --git a/frontend/src/core/components/tools/certSign/SignatureSettingsInput.tsx b/frontend/src/core/components/tools/certSign/SignatureSettingsInput.tsx new file mode 100644 index 0000000000..194e0a6d09 --- /dev/null +++ b/frontend/src/core/components/tools/certSign/SignatureSettingsInput.tsx @@ -0,0 +1,109 @@ +import { Stack, Text, Button, TextInput, NumberInput, Switch } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +export interface SignatureSettings { + showSignature?: boolean; + pageNumber?: number; + reason?: string; + location?: string; + showLogo?: boolean; + includeSummaryPage?: boolean; +} + +interface SignatureSettingsInputProps { + value: SignatureSettings; + onChange: (settings: SignatureSettings) => void; + disabled?: boolean; +} + +const SignatureSettingsInput = ({ value, onChange, disabled = false }: SignatureSettingsInputProps) => { + const { t } = useTranslation(); + + const handleChange = (key: keyof SignatureSettings, val: any) => { + onChange({ ...value, [key]: val }); + }; + + return ( + + + {t('certSign.collab.signatureSettings.title', 'Signature Appearance')} + + + {t('certSign.collab.signatureSettings.description', 'Configure how signatures will appear for all participants')} + + + {/* Signature Visibility */} +
+ + +
+ + {/* Visible Signature Options */} + {value.showSignature && ( + + handleChange('reason', event.currentTarget.value)} + disabled={disabled} + size="xs" + /> + handleChange('location', event.currentTarget.value)} + disabled={disabled} + size="xs" + /> + handleChange('pageNumber', val || 1)} + min={1} + disabled={disabled} + size="xs" + /> + handleChange('showLogo', event.currentTarget.checked)} + disabled={disabled} + size="sm" + /> + + )} + + {/* Summary Page Toggle */} + handleChange('includeSummaryPage', event.currentTarget.checked)} + disabled={disabled} + size="sm" + /> +
+ ); +}; + +export default SignatureSettingsInput; diff --git a/frontend/src/core/components/tools/certSign/WetSignatureInput.tsx b/frontend/src/core/components/tools/certSign/WetSignatureInput.tsx new file mode 100644 index 0000000000..a2350ba1a4 --- /dev/null +++ b/frontend/src/core/components/tools/certSign/WetSignatureInput.tsx @@ -0,0 +1,287 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Stack, + SegmentedControl, + Text, + Radio, + FileInput, + PasswordInput, + Divider, +} from '@mantine/core'; +import { DrawingCanvas } from '@app/components/annotation/shared/DrawingCanvas'; +import { ImageUploader } from '@app/components/annotation/shared/ImageUploader'; +import { TextInputWithFont } from '@app/components/annotation/shared/TextInputWithFont'; +import { ColorPicker } from '@app/components/annotation/shared/ColorPicker'; + +type SignatureType = 'canvas' | 'image' | 'text'; +type CertificateType = 'SERVER' | 'USER_CERT' | 'UPLOAD'; + +interface WetSignatureInputProps { + onSignatureDataChange: (data: string | undefined) => void; + onSignatureTypeChange: (type: SignatureType) => void; + onCertTypeChange: (type: CertificateType) => void; + onP12FileChange: (file: File | null) => void; + onPasswordChange: (password: string) => void; + certType: CertificateType; + p12File: File | null; + password: string; + disabled?: boolean; +} + +const WetSignatureInput = ({ + onSignatureDataChange, + onSignatureTypeChange, + onCertTypeChange, + onP12FileChange, + onPasswordChange, + certType, + p12File, + password, + disabled = false, +}: WetSignatureInputProps) => { + const { t } = useTranslation(); + + // Signature type state + const [signatureType, setSignatureType] = useState('canvas'); + + // Canvas drawing state + const [selectedColor, setSelectedColor] = useState('#000000'); + const [penSize, setPenSize] = useState(2); + const [penSizeInput, setPenSizeInput] = useState('2'); + const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); + const [canvasSignatureData, setCanvasSignatureData] = useState(); + + // Image upload state + const [imageSignatureData, setImageSignatureData] = useState(); + + // Text signature state + const [signerName, setSignerName] = useState(''); + const [fontSize, setFontSize] = useState(16); + const [fontFamily, setFontFamily] = useState('Helvetica'); + const [textColor, setTextColor] = useState('#000000'); + + // Handle signature type change + const handleSignatureTypeChange = useCallback( + (type: SignatureType) => { + setSignatureType(type); + onSignatureTypeChange(type); + + // Update signature data based on type + if (type === 'canvas') { + onSignatureDataChange(canvasSignatureData); + } else if (type === 'image') { + onSignatureDataChange(imageSignatureData); + } else if (type === 'text') { + // For text signatures, we pass the signer name + onSignatureDataChange(signerName || undefined); + } + }, + [canvasSignatureData, imageSignatureData, signerName, onSignatureTypeChange, onSignatureDataChange] + ); + + // Handle canvas signature change + const handleCanvasSignatureChange = useCallback( + (data: string | null) => { + const nextValue = data ?? undefined; + setCanvasSignatureData(nextValue); + if (signatureType === 'canvas') { + onSignatureDataChange(nextValue); + } + }, + [signatureType, onSignatureDataChange] + ); + + // Handle image upload + const handleImageChange = useCallback( + async (file: File | null) => { + if (file && !disabled) { + try { + const result = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + if (e.target?.result) { + resolve(e.target.result as string); + } else { + reject(new Error('Failed to read file')); + } + }; + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); + + setImageSignatureData(result); + if (signatureType === 'image') { + onSignatureDataChange(result); + } + } catch (error) { + console.error('Error reading file:', error); + } + } else if (!file) { + setImageSignatureData(undefined); + if (signatureType === 'image') { + onSignatureDataChange(undefined); + } + } + }, + [disabled, signatureType, onSignatureDataChange] + ); + + // Handle text signature changes + useEffect(() => { + if (signatureType === 'text') { + onSignatureDataChange(signerName || undefined); + } + }, [signatureType, signerName, onSignatureDataChange]); + + const renderSignatureBuilder = () => { + if (signatureType === 'canvas') { + return ( + + + {t('certSign.collab.signRequest.drawSignature', 'Draw your signature below')} + + setIsColorPickerOpen(true)} + onPenSizeChange={setPenSize} + onPenSizeInputChange={setPenSizeInput} + onSignatureDataChange={handleCanvasSignatureChange} + onDrawingComplete={() => {}} + disabled={disabled} + initialSignatureData={canvasSignatureData} + /> + + ); + } + + if (signatureType === 'image') { + return ( + + + {t('certSign.collab.signRequest.uploadSignature', 'Upload your signature image')} + + + + ); + } + + return ( + + + {t('certSign.collab.signRequest.typeSignature', 'Type your name to create a signature')} + + {}} + label={t('certSign.collab.signRequest.signatureText', 'Signature Text')} + placeholder={t('certSign.collab.signRequest.signatureTextPlaceholder', 'Enter your name...')} + fontLabel={t('certSign.collab.signRequest.fontFamily', 'Font Family')} + fontSizeLabel={t('certSign.collab.signRequest.fontSize', 'Font Size')} + fontSizePlaceholder={t('certSign.collab.signRequest.fontSizePlaceholder', 'Size')} + /> + + ); + }; + + return ( + + {/* Signature Type Selector */} + + + {t('certSign.collab.signRequest.signatureTypeLabel', 'Signature Type')} + + handleSignatureTypeChange(value as SignatureType)} + data={[ + { label: t('sign.type.canvas', 'Draw'), value: 'canvas' }, + { label: t('sign.type.image', 'Upload'), value: 'image' }, + { label: t('sign.type.text', 'Type'), value: 'text' }, + ]} + disabled={disabled} + /> + + + {/* Signature Builder */} + {renderSignatureBuilder()} + + + + {/* Certificate Selection */} + + + {t('certSign.collab.signRequest.certificateChoice', 'Certificate Choice')} + + onCertTypeChange(value as CertificateType)} + > + + + + + + + + {certType === 'UPLOAD' && ( + + + onPasswordChange(event.currentTarget.value)} + size="xs" + disabled={disabled} + /> + + )} + + + {/* Color Picker Modal */} + setIsColorPickerOpen(false)} + selectedColor={selectedColor} + onColorChange={setSelectedColor} + title={t('sign.canvas.colorPickerTitle', 'Choose stroke colour')} + /> + + ); +}; + +export default WetSignatureInput; diff --git a/frontend/src/core/components/tools/certSign/modals/AddParticipantsFlow.tsx b/frontend/src/core/components/tools/certSign/modals/AddParticipantsFlow.tsx new file mode 100644 index 0000000000..81b8d8560f --- /dev/null +++ b/frontend/src/core/components/tools/certSign/modals/AddParticipantsFlow.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { Modal, Stack, TextInput, Button, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import AddIcon from '@mui/icons-material/Add'; +import UserSelector from '@app/components/shared/UserSelector'; + +interface AddParticipantsFlowProps { + opened: boolean; + onClose: () => void; + onSubmit: (userIds: number[], defaultReason?: string) => Promise; +} + +export const AddParticipantsFlow: React.FC = ({ + opened, + onClose, + onSubmit, +}) => { + const { t } = useTranslation(); + const [selectedUserIds, setSelectedUserIds] = useState([]); + const [defaultReason, setDefaultReason] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const handleClose = () => { + setSelectedUserIds([]); + setDefaultReason(''); + onClose(); + }; + + const handleSubmit = async () => { + setSubmitting(true); + try { + await onSubmit(selectedUserIds, defaultReason.trim() || undefined); + handleClose(); + } catch (error) { + console.error('Failed to add participants:', error); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + setDefaultReason(e.currentTarget.value)} + placeholder={t('certSign.collab.addParticipants.reasonPlaceholder', 'e.g. Approval, Review...')} + size="sm" + /> + + + + + + + + ); +}; diff --git a/frontend/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx b/frontend/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx new file mode 100644 index 0000000000..373ad8df72 --- /dev/null +++ b/frontend/src/core/components/tools/certSign/modals/CertificateConfigModal.tsx @@ -0,0 +1,168 @@ +import { Modal, Stack, Group, Button, Text, Collapse, TextInput } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import { CertificateSelector, CertificateType, UploadFormat } from '@app/components/tools/certSign/CertificateSelector'; + +export interface CertificateSubmitData { + certType: CertificateType; + uploadFormat: UploadFormat; + p12File: File | null; + privateKeyFile: File | null; + certFile: File | null; + jksFile: File | null; + password: string; +} + +interface CertificateConfigModalProps { + opened: boolean; + onClose: () => void; + onSign: (certData: CertificateSubmitData, reason?: string, location?: string) => Promise; + signatureCount: number; + disabled?: boolean; + defaultReason?: string; + defaultLocation?: string; +} + +export const CertificateConfigModal: React.FC = ({ + opened, + onClose, + onSign, + signatureCount, + disabled = false, + defaultReason = '', + defaultLocation = '', +}) => { + const { t } = useTranslation(); + + const [certType, setCertType] = useState('USER_CERT'); + const [uploadFormat, setUploadFormat] = useState('PKCS12'); + const [p12File, setP12File] = useState(null); + const [privateKeyFile, setPrivateKeyFile] = useState(null); + const [certFile, setCertFile] = useState(null); + const [jksFile, setJksFile] = useState(null); + const [password, setPassword] = useState(''); + const [signing, setSigning] = useState(false); + + // Advanced settings + const [showAdvanced, setShowAdvanced] = useState(false); + const [reason, setReason] = useState(defaultReason); + const [location, setLocation] = useState(defaultLocation); + + const isUploadValid = () => { + if (certType !== 'UPLOAD') return true; + switch (uploadFormat) { + case 'PKCS12': + case 'PFX': + return p12File !== null; + case 'PEM': + return privateKeyFile !== null && certFile !== null; + case 'JKS': + return jksFile !== null; + } + }; + + const isValid = + certType === 'USER_CERT' || + certType === 'SERVER' || + isUploadValid(); + + const handleSign = async () => { + if (!isValid) return; + + setSigning(true); + try { + await onSign( + { certType, uploadFormat, p12File, privateKeyFile, certFile, jksFile, password }, + reason, + location + ); + } catch (error) { + console.error('Failed to sign document:', error); + } finally { + setSigning(false); + } + }; + + return ( + + + + {t( + 'certSign.collab.signRequest.certModal.description', + 'You have placed {{count}} signature(s). Choose your certificate to complete signing.', + { count: signatureCount } + )} + + + + + {/* Advanced Settings - Optional */} +
+ + + + + setReason(e.currentTarget.value)} + disabled={disabled || signing} + /> + setLocation(e.currentTarget.value)} + disabled={disabled || signing} + /> + + +
+ + + + + +
+
+ ); +}; diff --git a/frontend/src/core/components/tools/certSign/modals/SelectSignatureModal.tsx b/frontend/src/core/components/tools/certSign/modals/SelectSignatureModal.tsx new file mode 100644 index 0000000000..6d79cc193b --- /dev/null +++ b/frontend/src/core/components/tools/certSign/modals/SelectSignatureModal.tsx @@ -0,0 +1,175 @@ +import { Modal, Stack, Button, Text, Group, Box, ActionIcon, UnstyledButton } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useSavedSignatures, SavedSignature } from '@app/hooks/tools/sign/useSavedSignatures'; +import DrawIcon from '@mui/icons-material/Draw'; +import TextFieldsIcon from '@mui/icons-material/TextFields'; +import ImageIcon from '@mui/icons-material/Image'; +import CloseIcon from '@mui/icons-material/Close'; + +interface SelectSignatureModalProps { + opened: boolean; + onClose: () => void; + onSignatureSelected: (signature: SavedSignature) => void; + onCreateNew: (type: 'canvas' | 'text' | 'image') => void; +} + +export const SelectSignatureModal: React.FC = ({ + opened, + onClose, + onSignatureSelected, + onCreateNew, +}) => { + const { t } = useTranslation(); + const { savedSignatures, removeSignature } = useSavedSignatures(); + + const sortedSavedSignatures = [...savedSignatures].sort( + (a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt) + ); + + const renderSignaturePreview = (sig: SavedSignature) => { + if (sig.type === 'text') { + return ( + + + {sig.signerName} + + + ); + } + + return ( + + + + ); + }; + + return ( + + + {sortedSavedSignatures.length > 0 && ( + <> + + {t('certSign.collab.signRequest.savedSignatures', 'Saved Signatures')} + + + {sortedSavedSignatures.map((sig) => ( + + { onSignatureSelected(sig); onClose(); }} + style={{ flex: 1, padding: '12px' }} + > + {renderSignaturePreview(sig)} + + removeSignature(sig.id)} + aria-label={t('certSign.collab.signRequest.saved.delete', 'Delete signature')} + style={{ margin: '0 6px' }} + > + + + + ))} + + + )} + + 0 ? 'md' : 0}> + {t('certSign.collab.signRequest.createNewSignature', 'Create New Signature')} + + + + + + + + + + ); +}; diff --git a/frontend/src/core/components/tools/certSign/panels/ParticipantListPanel.tsx b/frontend/src/core/components/tools/certSign/panels/ParticipantListPanel.tsx new file mode 100644 index 0000000000..202c94c94d --- /dev/null +++ b/frontend/src/core/components/tools/certSign/panels/ParticipantListPanel.tsx @@ -0,0 +1,84 @@ +import { Stack, Text, List, Group, Badge, ActionIcon } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import PendingIcon from '@mui/icons-material/Pending'; +import CancelIcon from '@mui/icons-material/Cancel'; +import DeleteIcon from '@mui/icons-material/Delete'; +import type { ParticipantInfo } from '@app/types/signingSession'; +import { getFileColor } from '@app/components/pageEditor/fileColors'; + +interface ParticipantListPanelProps { + participants: ParticipantInfo[]; + finalized: boolean; + onRemove: (participantId: number) => void; +} + +export const ParticipantListPanel: React.FC = ({ + participants, + finalized, + onRemove, +}) => { + const { t } = useTranslation(); + + const getIcon = (status: string) => { + if (status === 'SIGNED') return ; + if (status === 'DECLINED') return ; + return ; + }; + + const getColor = (status: string) => { + if (status === 'SIGNED') return 'green'; + if (status === 'DECLINED') return 'red'; + return 'orange'; + }; + + return ( + + + {t('certSign.collab.sessionDetail.participants', 'Participants')} + + + + {participants.map((participant, participantIndex) => { + const isSigned = participant.status === 'SIGNED'; + const isDeclined = participant.status === 'DECLINED'; + const annotationColor = getFileColor(participantIndex); + + return ( + + + + +
+ + {participant.name} + + + {participant.email && participant.email !== participant.name && ( + + @{participant.email} + + )} + + {t(`certSign.collab.status.${participant.status.toLowerCase()}`, participant.status)} + + + {!finalized && !isSigned && !isDeclined && ( + onRemove(participant.id)} + title={t('certSign.collab.sessionDetail.removeParticipant', 'Remove')} + > + + + )} + + + ); + })} + + + ); +}; diff --git a/frontend/src/core/components/tools/certSign/panels/SessionActionsPanel.tsx b/frontend/src/core/components/tools/certSign/panels/SessionActionsPanel.tsx new file mode 100644 index 0000000000..c3e9e2b287 --- /dev/null +++ b/frontend/src/core/components/tools/certSign/panels/SessionActionsPanel.tsx @@ -0,0 +1,99 @@ +import { Stack, Text, Button, Divider, Paper } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import AddIcon from '@mui/icons-material/Add'; +import type { SessionDetail } from '@app/types/signingSession'; + +interface SessionActionsPanelProps { + session: SessionDetail; + onAddParticipants: () => void; + onFinalize: () => void; + onLoadSignedPdf: () => void; + finalizing: boolean; + loadingPdf: boolean; +} + +export const SessionActionsPanel: React.FC = ({ + session, + onAddParticipants, + onFinalize, + onLoadSignedPdf, + finalizing, + loadingPdf, +}) => { + const { t } = useTranslation(); + + const allSigned = session.participants.every((p) => p.status === 'SIGNED'); + + return ( + + {/* Session Info - only shown when there is something to display */} + {(session.dueDate || session.message) && ( + + + {t('certSign.collab.sessionDetail.sessionInfo', 'Session Info')} + + {session.dueDate && ( + + + {t('certSign.collab.sessionDetail.dueDate', 'Due Date')} + + {session.dueDate} + + )} + {session.message && ( + + + {t('certSign.collab.sessionDetail.messageLabel', 'Message')} + + {session.message} + + )} + + )} + + {/* Primary Actions */} + {!session.finalized && ( + <> + + + + + + + + )} + + {session.finalized && ( + <> + + + )} + + ); +}; diff --git a/frontend/src/core/components/tools/certSign/steps/AddSignaturesStep.tsx b/frontend/src/core/components/tools/certSign/steps/AddSignaturesStep.tsx new file mode 100644 index 0000000000..b69ec0089f --- /dev/null +++ b/frontend/src/core/components/tools/certSign/steps/AddSignaturesStep.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; +import { Button, Stack, Text, Paper } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import AddIcon from '@mui/icons-material/Add'; +import CancelIcon from '@mui/icons-material/Cancel'; +import { SignatureTypeSelector, SignatureType } from '@app/components/shared/wetSignature/SignatureTypeSelector'; +import { DrawSignatureCanvas } from '@app/components/shared/wetSignature/DrawSignatureCanvas'; +import { UploadSignatureImage } from '@app/components/shared/wetSignature/UploadSignatureImage'; +import { TypeSignatureText } from '@app/components/shared/wetSignature/TypeSignatureText'; + +export interface PlacedSignature { + id: string; + signature: string; + type: SignatureType; + page: number; + x: number; + y: number; + width: number; + height: number; +} + +interface AddSignaturesStepProps { + onRequestPlacement: (signature: string, type: SignatureType) => void; + onCancelPlacement?: () => void; + placementMode: boolean; + disabled?: boolean; +} + +export const AddSignaturesStep: React.FC = ({ + onRequestPlacement, + onCancelPlacement, + placementMode, + disabled = false, +}) => { + const { t } = useTranslation(); + + // Current signature being created + const [signatureType, setSignatureType] = useState('draw'); + const [signature, setSignature] = useState(null); + const [signatureText, setSignatureText] = useState(''); + const [fontFamily, setFontFamily] = useState('Arial'); + const [fontSize, setFontSize] = useState(40); + const [textColor, setTextColor] = useState('#000000'); + + const hasSignature = + (signatureType === 'draw' && signature) || + (signatureType === 'upload' && signature) || + (signatureType === 'type' && signatureText && signature); + + const handlePlaceSignature = () => { + if (signature) { + onRequestPlacement(signature, signatureType); + } + }; + + return ( + + {/* Signature Creation */} + + + {t('certSign.collab.signRequest.steps.createSignature', 'Create Signature')} + + + + + + {signatureType === 'draw' && ( + + )} + + {signatureType === 'upload' && ( + + )} + + {signatureType === 'type' && ( + + )} + + {!placementMode ? ( + + ) : ( + + )} + + + + {placementMode && ( + + {t('certSign.collab.signRequest.steps.clickMultipleTimes', 'Click on the PDF multiple times to place signatures. Drag any signature to move or resize it.')} + + )} + + ); +}; diff --git a/frontend/src/core/components/tools/certSign/steps/CertificateSelectionStep.tsx b/frontend/src/core/components/tools/certSign/steps/CertificateSelectionStep.tsx new file mode 100644 index 0000000000..7228156345 --- /dev/null +++ b/frontend/src/core/components/tools/certSign/steps/CertificateSelectionStep.tsx @@ -0,0 +1,83 @@ +import { Button, Stack, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { CertificateSelector, CertificateType, UploadFormat } from '@app/components/tools/certSign/CertificateSelector'; + +interface CertificateSelectionStepProps { + certType: CertificateType; + onCertTypeChange: (certType: CertificateType) => void; + uploadFormat: UploadFormat; + onUploadFormatChange: (format: UploadFormat) => void; + p12File: File | null; + onP12FileChange: (file: File | null) => void; + privateKeyFile: File | null; + onPrivateKeyFileChange: (file: File | null) => void; + certFile: File | null; + onCertFileChange: (file: File | null) => void; + jksFile: File | null; + onJksFileChange: (file: File | null) => void; + password: string; + onPasswordChange: (password: string) => void; + onBack: () => void; + onNext: () => void; + disabled?: boolean; +} + +export const CertificateSelectionStep: React.FC = ({ + certType, + onCertTypeChange, + uploadFormat, + onUploadFormatChange, + p12File, + onP12FileChange, + privateKeyFile, + onPrivateKeyFileChange, + certFile, + onCertFileChange, + jksFile, + onJksFileChange, + password, + onPasswordChange, + onBack, + onNext, + disabled = false, +}) => { + const { t } = useTranslation(); + + // Validation: if UPLOAD type, need file and password + const isValid = + certType === 'USER_CERT' || + certType === 'SERVER' || + (certType === 'UPLOAD' && p12File && password); + + return ( + + + + + + + + + ); +}; diff --git a/frontend/src/core/components/tools/certSign/steps/ReviewSignatureStep.tsx b/frontend/src/core/components/tools/certSign/steps/ReviewSignatureStep.tsx new file mode 100644 index 0000000000..1f4fe6437f --- /dev/null +++ b/frontend/src/core/components/tools/certSign/steps/ReviewSignatureStep.tsx @@ -0,0 +1,154 @@ +import { Button, Stack, Text, Group, Divider, Paper } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import DrawIcon from '@mui/icons-material/Draw'; +import SecurityIcon from '@mui/icons-material/Security'; +import SettingsIcon from '@mui/icons-material/Settings'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import CancelIcon from '@mui/icons-material/Cancel'; +import { CertificateType, UploadFormat } from '@app/components/tools/certSign/CertificateSelector'; +import type { SignRequestDetail } from '@app/types/signingSession'; + +interface ReviewSignatureStepProps { + signatureCount: number; + certType: CertificateType; + uploadFormat: UploadFormat; + p12File: File | null; + signRequest: SignRequestDetail; + onBack: () => void; + onSign: () => void; + onDecline: () => void; + disabled?: boolean; +} + +export const ReviewSignatureStep: React.FC = ({ + signatureCount, + certType, + uploadFormat, + p12File, + signRequest, + onBack, + onSign, + onDecline, + disabled = false, +}) => { + const { t } = useTranslation(); + + const getCertTypeName = () => { + switch (certType) { + case 'USER_CERT': + return t('certSign.collab.signRequest.usePersonalCert', 'Personal Certificate'); + case 'SERVER': + return t('certSign.collab.signRequest.useServerCert', 'Organization Certificate'); + case 'UPLOAD': + return `${uploadFormat} — ${p12File?.name || t('certSign.collab.signRequest.uploadCert', 'Custom Certificate')}`; + default: + return ''; + } + }; + + return ( + + + {t('certSign.collab.signRequest.steps.reviewTitle', 'Review Before Signing')} + + + {/* Signatures Summary */} +
+ + + + {t('certSign.collab.signRequest.steps.yourSignatures', 'Your Signatures ({{count}})', { + count: signatureCount, + })} + + + + + {signatureCount === 1 + ? t('certSign.collab.signRequest.steps.oneSignature', '1 signature will be applied to the PDF') + : t('certSign.collab.signRequest.steps.multipleSignatures', '{{count}} signatures will be applied to the PDF', { + count: signatureCount, + })} + + +
+ + + + {/* Certificate Info */} +
+ + + + {t('certSign.collab.signRequest.steps.certificate', 'Certificate')} + + + {getCertTypeName()} +
+ + + + {/* Settings from Owner */} +
+ + + + {t('certSign.collab.signRequest.signatureSettings', 'Signature Settings')} + + + + + {t('certSign.collab.signRequest.signatureInfo', 'These settings are configured by the document owner')} + + + + {t('certSign.collab.signRequest.steps.visibility', 'Visibility:')}{' '} + {signRequest.showSignature + ? t('certSign.collab.signRequest.steps.visible', 'Visible') + : t('certSign.collab.signRequest.steps.invisible', 'Invisible')} + + {signRequest.reason && ( + + {t('certSign.collab.signRequest.steps.reason', 'Reason:')} {signRequest.reason} + + )} + {signRequest.location && ( + + {t('certSign.collab.signRequest.steps.location', 'Location:')}{' '} + {signRequest.location} + + )} + + +
+ + + + {/* Action Buttons */} + + + + + +
+ ); +}; diff --git a/frontend/src/core/components/tools/certSign/steps/SignatureCreationStep.tsx b/frontend/src/core/components/tools/certSign/steps/SignatureCreationStep.tsx new file mode 100644 index 0000000000..1eb0f6b5dd --- /dev/null +++ b/frontend/src/core/components/tools/certSign/steps/SignatureCreationStep.tsx @@ -0,0 +1,93 @@ +import { Button, Stack } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { SignatureTypeSelector, SignatureType } from '@app/components/shared/wetSignature/SignatureTypeSelector'; +import { DrawSignatureCanvas } from '@app/components/shared/wetSignature/DrawSignatureCanvas'; +import { UploadSignatureImage } from '@app/components/shared/wetSignature/UploadSignatureImage'; +import { TypeSignatureText } from '@app/components/shared/wetSignature/TypeSignatureText'; + +interface SignatureCreationStepProps { + signatureType: SignatureType; + onSignatureTypeChange: (type: SignatureType) => void; + signature: string | null; + onSignatureChange: (signature: string | null) => void; + // For type signature + signatureText: string; + fontFamily: string; + fontSize: number; + textColor: string; + onSignatureTextChange: (text: string) => void; + onFontFamilyChange: (font: string) => void; + onFontSizeChange: (size: number) => void; + onTextColorChange: (color: string) => void; + onNext: () => void; + disabled?: boolean; +} + +export const SignatureCreationStep: React.FC = ({ + signatureType, + onSignatureTypeChange, + signature, + onSignatureChange, + signatureText, + fontFamily, + fontSize, + textColor, + onSignatureTextChange, + onFontFamilyChange, + onFontSizeChange, + onTextColorChange, + onNext, + disabled = false, +}) => { + const { t } = useTranslation(); + + const hasSignature = + (signatureType === 'draw' && signature) || + (signatureType === 'upload' && signature) || + (signatureType === 'type' && signatureText && signature); + + return ( + + + + {signatureType === 'draw' && ( + + )} + + {signatureType === 'upload' && ( + + )} + + {signatureType === 'type' && ( + + )} + + + + ); +}; diff --git a/frontend/src/core/components/tools/certSign/steps/SignaturePlacementStep.tsx b/frontend/src/core/components/tools/certSign/steps/SignaturePlacementStep.tsx new file mode 100644 index 0000000000..529a1c58a5 --- /dev/null +++ b/frontend/src/core/components/tools/certSign/steps/SignaturePlacementStep.tsx @@ -0,0 +1,52 @@ +import { Button, Stack, Text, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; + +interface SignaturePlacementStepProps { + isPlaced: boolean; + placementInfo: { page: number; x: number; y: number } | null; + onBack: () => void; + onNext: () => void; + disabled?: boolean; + children?: React.ReactNode; // PDF viewer with placement capability +} + +export const SignaturePlacementStep: React.FC = ({ + isPlaced, + placementInfo, + onBack, + onNext, + disabled = false, + children, +}) => { + const { t } = useTranslation(); + + return ( + + + {isPlaced + ? t( + 'certSign.collab.signRequest.steps.signaturePlaced', + 'Signature placed on page {{page}}. You can adjust the position by clicking again or continue to review.', + { page: placementInfo?.page || 1 } + ) + : t( + 'certSign.collab.signRequest.steps.clickToPlace', + 'Click on the PDF where you would like your signature to appear.' + )} + + + {/* PDF Viewer (passed as children) */} +
{children}
+ + + + + +
+ ); +}; diff --git a/frontend/src/core/components/tooltips/useCertificateChoiceTips.ts b/frontend/src/core/components/tooltips/useCertificateChoiceTips.ts new file mode 100644 index 0000000000..26f1d41f72 --- /dev/null +++ b/frontend/src/core/components/tooltips/useCertificateChoiceTips.ts @@ -0,0 +1,53 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '@app/types/tips'; + +export const useCertificateChoiceTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t('certificateChoice.tooltip.header', 'Certificate Types'), + }, + tips: [ + { + title: t('certificateChoice.tooltip.personal.title', 'Personal Certificate'), + description: t( + 'certificateChoice.tooltip.personal.description', + 'An auto-generated certificate unique to your user account. Suitable for individual signatures.' + ), + bullets: [ + t('certificateChoice.tooltip.personal.bullet1', 'Generated automatically on first use'), + t('certificateChoice.tooltip.personal.bullet2', 'Tied to your user account'), + t('certificateChoice.tooltip.personal.bullet3', 'Cannot be shared with other users'), + t('certificateChoice.tooltip.personal.bullet4', 'Best for: Personal documents, individual accountability'), + ], + }, + { + title: t('certificateChoice.tooltip.organization.title', 'Organization Certificate'), + description: t( + 'certificateChoice.tooltip.organization.description', + 'A shared certificate provided by your organization. Used for company-wide signing authority.' + ), + bullets: [ + t('certificateChoice.tooltip.organization.bullet1', 'Managed by system administrators'), + t('certificateChoice.tooltip.organization.bullet2', 'Shared across authorized users'), + t('certificateChoice.tooltip.organization.bullet3', 'Represents company identity, not individual'), + t('certificateChoice.tooltip.organization.bullet4', 'Best for: Official documents, team signatures'), + ], + }, + { + title: t('certificateChoice.tooltip.upload.title', 'Upload Custom P12'), + description: t( + 'certificateChoice.tooltip.upload.description', + 'Use your own PKCS#12 certificate file. Provides full control over certificate properties.' + ), + bullets: [ + t('certificateChoice.tooltip.upload.bullet1', 'Requires P12/PFX file and password'), + t('certificateChoice.tooltip.upload.bullet2', 'Can be issued by external Certificate Authorities'), + t('certificateChoice.tooltip.upload.bullet3', 'Higher trust level for legal documents'), + t('certificateChoice.tooltip.upload.bullet4', 'Best for: Legally binding contracts, external validation'), + ], + }, + ], + }; +}; diff --git a/frontend/src/core/components/tooltips/useGroupSigningTips.ts b/frontend/src/core/components/tooltips/useGroupSigningTips.ts new file mode 100644 index 0000000000..d653b59560 --- /dev/null +++ b/frontend/src/core/components/tooltips/useGroupSigningTips.ts @@ -0,0 +1,50 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '@app/types/tips'; + +export const useGroupSigningTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t('groupSigning.tooltip.header', 'About Group Signing'), + }, + tips: [ + { + title: t('groupSigning.tooltip.sequential.title', 'Sequential Signing'), + description: t( + 'groupSigning.tooltip.sequential.description', + 'Participants sign documents in the order you specify. Each signer receives a notification when it is their turn.' + ), + bullets: [ + t('groupSigning.tooltip.sequential.bullet1', 'First participant must sign before the second can access the document'), + t('groupSigning.tooltip.sequential.bullet2', 'Ensures proper signing order for legal compliance'), + t('groupSigning.tooltip.sequential.bullet3', 'You can reorder participants by dragging them in the list'), + ], + }, + { + title: t('groupSigning.tooltip.roles.title', 'Participant Roles'), + description: t( + 'groupSigning.tooltip.roles.description', + 'You control the signature appearance settings for all participants.' + ), + bullets: [ + t('groupSigning.tooltip.roles.bullet1', 'Owner (you): Creates session, configures signature defaults, finalizes document'), + t('groupSigning.tooltip.roles.bullet2', 'Participants: Create their signature, choose certificate, place on PDF'), + t('groupSigning.tooltip.roles.bullet3', 'Participants cannot modify signature visibility, reason, or location settings'), + ], + }, + { + title: t('groupSigning.tooltip.finalization.title', 'Finalization Process'), + description: t( + 'groupSigning.tooltip.finalization.description', + 'Once all participants have signed (or you choose to finalize early), you can generate the final signed PDF.' + ), + bullets: [ + t('groupSigning.tooltip.finalization.bullet1', 'All signatures are applied in the participant order you specified'), + t('groupSigning.tooltip.finalization.bullet2', 'You can finalize with partial signatures if needed'), + t('groupSigning.tooltip.finalization.bullet3', 'Once finalized, the session cannot be modified'), + ], + }, + ], + }; +}; diff --git a/frontend/src/core/components/tooltips/useSessionManagementTips.ts b/frontend/src/core/components/tooltips/useSessionManagementTips.ts new file mode 100644 index 0000000000..e532bed049 --- /dev/null +++ b/frontend/src/core/components/tooltips/useSessionManagementTips.ts @@ -0,0 +1,63 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '@app/types/tips'; + +export const useSessionManagementTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t('sessionManagement.tooltip.header', 'Managing Signing Sessions'), + }, + tips: [ + { + title: t('sessionManagement.tooltip.addParticipants.title', 'Adding Participants'), + description: t( + 'sessionManagement.tooltip.addParticipants.description', + 'You can add more participants to an active session at any time before finalization.' + ), + bullets: [ + t('sessionManagement.tooltip.addParticipants.bullet1', 'New participants added to the end of signing order'), + t('sessionManagement.tooltip.addParticipants.bullet2', 'Cannot add participants after session is finalized'), + t('sessionManagement.tooltip.addParticipants.bullet3', 'Each participant receives a notification when it\'s their turn'), + ], + }, + { + title: t('sessionManagement.tooltip.finalization.title', 'Session Finalization'), + description: t( + 'sessionManagement.tooltip.finalization.description', + 'Finalization combines all signatures into a single signed PDF. This action cannot be undone.' + ), + bullets: [ + t('sessionManagement.tooltip.finalization.bullet1', 'Full finalization: All participants have signed'), + t('sessionManagement.tooltip.finalization.bullet2', 'Partial finalization: Some participants haven\'t signed yet'), + t('sessionManagement.tooltip.finalization.bullet3', 'Unsigned participants will be excluded from the final document'), + t('sessionManagement.tooltip.finalization.bullet4', 'Once finalized, you can load the signed PDF into active files'), + ], + }, + { + title: t('sessionManagement.tooltip.signatureOrder.title', 'Signature Order'), + description: t( + 'sessionManagement.tooltip.signatureOrder.description', + 'The order you specify when creating the session determines who signs first.' + ), + bullets: [ + t('sessionManagement.tooltip.signatureOrder.bullet1', 'Each signature is applied sequentially to the PDF'), + t('sessionManagement.tooltip.signatureOrder.bullet2', 'Later signers can see earlier signatures'), + t('sessionManagement.tooltip.signatureOrder.bullet3', 'Critical for approval workflows and legal chains of custody'), + ], + }, + { + title: t('sessionManagement.tooltip.participantRemoval.title', 'Removing Participants'), + description: t( + 'sessionManagement.tooltip.participantRemoval.description', + 'Participants can be removed from sessions before they sign.' + ), + bullets: [ + t('sessionManagement.tooltip.participantRemoval.bullet1', 'Cannot remove participants who have already signed'), + t('sessionManagement.tooltip.participantRemoval.bullet2', 'Removed participants no longer receive notifications'), + t('sessionManagement.tooltip.participantRemoval.bullet3', 'Signing order adjusts automatically'), + ], + }, + ], + }; +}; diff --git a/frontend/src/core/components/tooltips/useSignatureSettingsTips.ts b/frontend/src/core/components/tooltips/useSignatureSettingsTips.ts new file mode 100644 index 0000000000..c1da24955f --- /dev/null +++ b/frontend/src/core/components/tooltips/useSignatureSettingsTips.ts @@ -0,0 +1,62 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '@app/types/tips'; + +export const useSignatureSettingsTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t('signatureSettings.tooltip.header', 'Signature Appearance Settings'), + }, + tips: [ + { + title: t('signatureSettings.tooltip.visibility.title', 'Signature Visibility'), + description: t( + 'signatureSettings.tooltip.visibility.description', + 'Controls whether the signature is visible on the document or embedded invisibly.' + ), + bullets: [ + t('signatureSettings.tooltip.visibility.bullet1', 'Visible: Signature appears on PDF with custom appearance'), + t('signatureSettings.tooltip.visibility.bullet2', 'Invisible: Certificate embedded without visual mark'), + t('signatureSettings.tooltip.visibility.bullet3', 'Invisible signatures still provide cryptographic validation'), + ], + }, + { + title: t('signatureSettings.tooltip.reason.title', 'Signature Reason'), + description: t( + 'signatureSettings.tooltip.reason.description', + 'Optional text explaining why the document is being signed. Stored in certificate metadata.' + ), + bullets: [ + t('signatureSettings.tooltip.reason.bullet1', 'Examples: "Approval", "Contract Agreement", "Review Complete"'), + t('signatureSettings.tooltip.reason.bullet2', 'Visible in PDF signature properties'), + t('signatureSettings.tooltip.reason.bullet3', 'Useful for audit trails and compliance'), + ], + }, + { + title: t('signatureSettings.tooltip.location.title', 'Signature Location'), + description: t( + 'signatureSettings.tooltip.location.description', + 'Optional geographic location where the signature was applied. Stored in certificate metadata.' + ), + bullets: [ + t('signatureSettings.tooltip.location.bullet1', 'Examples: "New York, USA", "London Office", "Remote"'), + t('signatureSettings.tooltip.location.bullet2', 'Not the same as page position'), + t('signatureSettings.tooltip.location.bullet3', 'May be required for certain legal jurisdictions'), + ], + }, + { + title: t('signatureSettings.tooltip.logo.title', 'Company Logo'), + description: t( + 'signatureSettings.tooltip.logo.description', + 'Add a company logo to visible signatures for branding and authenticity.' + ), + bullets: [ + t('signatureSettings.tooltip.logo.bullet1', 'Displayed alongside signature and text'), + t('signatureSettings.tooltip.logo.bullet2', 'Supports PNG, JPG formats'), + t('signatureSettings.tooltip.logo.bullet3', 'Enhances professional appearance'), + ], + }, + ], + }; +}; diff --git a/frontend/src/core/components/tooltips/useWetSignatureTips.ts b/frontend/src/core/components/tooltips/useWetSignatureTips.ts new file mode 100644 index 0000000000..00e7c39025 --- /dev/null +++ b/frontend/src/core/components/tooltips/useWetSignatureTips.ts @@ -0,0 +1,50 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '@app/types/tips'; + +export const useWetSignatureTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t('wetSignature.tooltip.header', 'Signature Creation Methods'), + }, + tips: [ + { + title: t('wetSignature.tooltip.draw.title', 'Draw Signature'), + description: t( + 'wetSignature.tooltip.draw.description', + 'Create a handwritten signature using your mouse or touchscreen. Best for personal, authentic signatures.' + ), + bullets: [ + t('wetSignature.tooltip.draw.bullet1', 'Customize pen color and thickness'), + t('wetSignature.tooltip.draw.bullet2', 'Clear and redraw until satisfied'), + t('wetSignature.tooltip.draw.bullet3', 'Works on touch devices (tablets, phones)'), + ], + }, + { + title: t('wetSignature.tooltip.upload.title', 'Upload Signature Image'), + description: t( + 'wetSignature.tooltip.upload.description', + 'Upload a pre-created signature image. Ideal if you have a scanned signature or company logo.' + ), + bullets: [ + t('wetSignature.tooltip.upload.bullet1', 'Supports PNG, JPG, and other image formats'), + t('wetSignature.tooltip.upload.bullet2', 'Transparent backgrounds recommended for best results'), + t('wetSignature.tooltip.upload.bullet3', 'Image will be resized to fit signature area'), + ], + }, + { + title: t('wetSignature.tooltip.type.title', 'Type Signature'), + description: t( + 'wetSignature.tooltip.type.description', + 'Generate a signature from typed text. Fast and consistent, suitable for business documents.' + ), + bullets: [ + t('wetSignature.tooltip.type.bullet1', 'Choose from multiple fonts'), + t('wetSignature.tooltip.type.bullet2', 'Customize text size and color'), + t('wetSignature.tooltip.type.bullet3', 'Perfect for standardized signatures'), + ], + }, + ], + }; +}; diff --git a/frontend/src/core/components/viewer/DocumentReadyWrapper.tsx b/frontend/src/core/components/viewer/DocumentReadyWrapper.tsx index 3330f36ce6..879ba9288b 100644 --- a/frontend/src/core/components/viewer/DocumentReadyWrapper.tsx +++ b/frontend/src/core/components/viewer/DocumentReadyWrapper.tsx @@ -15,11 +15,8 @@ export function DocumentReadyWrapper({ children, fallback = null }: DocumentRead const checkActiveDocument = async () => { await ready; - - // Try to get the active document from the plugin's provides() const docManagerApi = plugin.provides?.(); if (docManagerApi) { - // Try different methods to get the active document const activeDoc = docManagerApi.getActiveDocument?.(); if (activeDoc?.id) { setActiveDocumentId(activeDoc.id); diff --git a/frontend/src/core/components/viewer/LocalEmbedPDFWithAnnotations.tsx b/frontend/src/core/components/viewer/LocalEmbedPDFWithAnnotations.tsx index 9b5a86fd22..fc234c6d9c 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDFWithAnnotations.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDFWithAnnotations.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, useImperativeHandle, forwardRef, useRef } from 'react'; import { createPluginRegistration } from '@embedpdf/core'; import type { PluginRegistry } from '@embedpdf/core'; import { EmbedPDF } from '@embedpdf/core/react'; @@ -9,8 +9,8 @@ import { Viewport, ViewportPluginPackage } from '@embedpdf/plugin-viewport/react import { Scroller, ScrollPluginPackage } from '@embedpdf/plugin-scroll/react'; import { DocumentManagerPluginPackage } from '@embedpdf/plugin-document-manager/react'; import { RenderPluginPackage } from '@embedpdf/plugin-render/react'; -import { ZoomPluginPackage } from '@embedpdf/plugin-zoom/react'; -import { InteractionManagerPluginPackage, PagePointerProvider, GlobalPointerProvider } from '@embedpdf/plugin-interaction-manager/react'; +import { ZoomPluginPackage, ZoomMode } from '@embedpdf/plugin-zoom/react'; +import { InteractionManagerPluginPackage, PagePointerProvider, GlobalPointerProvider, useInteractionManagerCapability } from '@embedpdf/plugin-interaction-manager/react'; import { SelectionLayer, SelectionPluginPackage } from '@embedpdf/plugin-selection/react'; import { TilingLayer, TilingPluginPackage } from '@embedpdf/plugin-tiling/react'; import { PanPluginPackage } from '@embedpdf/plugin-pan/react'; @@ -19,17 +19,16 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react'; import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react'; import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react'; import { Rotation, PdfAnnotationSubtype } from '@embedpdf/models'; -import type { PdfAnnotationObject } from '@embedpdf/models'; -import type { AnnotationEvent } from '@embedpdf/plugin-annotation'; + // Import annotation plugins import { HistoryPluginPackage } from '@embedpdf/plugin-history/react'; import { AnnotationLayer, AnnotationPluginPackage } from '@embedpdf/plugin-annotation/react'; import { CustomSearchLayer } from '@app/components/viewer/CustomSearchLayer'; -import { ZoomAPIBridge } from '@app/components/viewer/ZoomAPIBridge'; import ToolLoadingFallback from '@app/components/tools/ToolLoadingFallback'; -import { Center, Stack, Text } from '@mantine/core'; +import { ActionIcon, Center, Stack, Text, Tooltip } from '@mantine/core'; +import CloseIcon from '@mui/icons-material/Close'; import { ScrollAPIBridge } from '@app/components/viewer/ScrollAPIBridge'; import { SelectionAPIBridge } from '@app/components/viewer/SelectionAPIBridge'; import { PanAPIBridge } from '@app/components/viewer/PanAPIBridge'; @@ -38,21 +37,139 @@ import { SearchAPIBridge } from '@app/components/viewer/SearchAPIBridge'; import { ThumbnailAPIBridge } from '@app/components/viewer/ThumbnailAPIBridge'; import { RotateAPIBridge } from '@app/components/viewer/RotateAPIBridge'; import { DocumentReadyWrapper } from '@app/components/viewer/DocumentReadyWrapper'; +import { + Z_INDEX_SIGNATURE_OVERLAY, + Z_INDEX_SIGNATURE_OVERLAY_DELETE, + Z_INDEX_SIGNATURE_OVERLAY_HANDLE, +} from '@app/styles/zIndex'; + +/** Rendered inside EmbedPDF context; exposes interaction manager pause/resume via ref. */ +function InteractionPauseBridge({ bridgeRef }: { + bridgeRef: React.MutableRefObject<{ pause: () => void; resume: () => void } | null>; +}) { + const { provides } = useInteractionManagerCapability(); + useEffect(() => { + if (provides) { + bridgeRef.current = { pause: () => provides.pause(), resume: () => provides.resume() }; + } + return () => { bridgeRef.current = null; }; + }, [provides, bridgeRef]); + return null; +} const DOCUMENT_NAME = 'stirling-pdf-signing-viewer'; +export interface SignaturePreview { + id: string; + pageIndex: number; + x: number; + y: number; + width: number; + height: number; + signatureData: string; // Base64 PNG image + signatureType: 'canvas' | 'image' | 'text'; + color?: string; // Per-participant color (rgb(...) string); falls back to default blue + participantName?: string; // Shown in tooltip on hover +} + interface LocalEmbedPDFWithAnnotationsProps { file?: File | Blob; url?: string | null; - onAnnotationChange?: (annotations: PdfAnnotationObject[]) => void; + onAnnotationChange?: (annotations: SignaturePreview[]) => void; + placementMode?: boolean; + signatureData?: string; + signatureType?: 'canvas' | 'image' | 'text'; + onPlaceSignature?: (id: string, pageIndex: number, x: number, y: number, width: number, height: number) => void; + onPreviewCountChange?: (count: number) => void; + initialSignatures?: SignaturePreview[]; // Initial signatures to display (read-only preview) + readOnly?: boolean; // If true, signature previews cannot be moved or deleted } -export function LocalEmbedPDFWithAnnotations({ +export interface AnnotationAPI { + setActiveTool: (toolId: string | null) => void; + setToolDefaults: (toolId: string, defaults: any) => void; + getActiveTool: () => any; + getPageAnnotations: (pageIndex: number) => Promise; + getAllAnnotations: () => Promise; + getSignaturePreviews: () => SignaturePreview[]; + clearPreviews: () => void; + zoomIn: () => void; + zoomOut: () => void; + resetZoom: () => void; +} + +export const LocalEmbedPDFWithAnnotations = forwardRef(({ file, url, - onAnnotationChange -}: LocalEmbedPDFWithAnnotationsProps) { + onAnnotationChange, + placementMode = false, + signatureData, + signatureType, + onPlaceSignature, + onPreviewCountChange, + initialSignatures = [], + readOnly = false +}, ref) => { const [pdfUrl, setPdfUrl] = useState(null); + const annotationApiRef = useRef(null); + const zoomApiRef = useRef(null); + const containerRef = useRef(null); + + // State for signature preview overlays (support multiple) + const [signaturePreviews, setSignaturePreviews] = useState(initialSignatures); + + // Track if a drag operation just occurred to prevent click from firing + const isDraggingRef = useRef(false); + const interactionPauseRef = useRef<{ pause: () => void; resume: () => void } | null>(null); + + // Track cursor position over a specific page for hover preview + const [cursorOnPage, setCursorOnPage] = useState<{ pageIndex: number; x: number; y: number } | null>(null); + + // Expose annotation API to parent + useImperativeHandle(ref, () => ({ + setActiveTool: (toolId: string | null) => { + annotationApiRef.current?.setActiveTool(toolId); + }, + setToolDefaults: (toolId: string, defaults: any) => { + annotationApiRef.current?.setToolDefaults(toolId, defaults); + }, + getActiveTool: () => { + return annotationApiRef.current?.getActiveTool(); + }, + getPageAnnotations: async (pageIndex: number) => { + if (!annotationApiRef.current?.getPageAnnotations) return []; + const task = annotationApiRef.current.getPageAnnotations({ pageIndex }); + if (task?.toPromise) { + return await task.toPromise(); + } + return []; + }, + getAllAnnotations: async () => { + // Get all annotations across all pages + // Note: In practice, we'll use getPageAnnotations for the specific page + // where the user placed their signature, so this method is optional + if (!annotationApiRef.current?.getPageAnnotations) return []; + + // Would need document page count to iterate through all pages + // For signing workflow, we track annotations via onAnnotationChange callback instead + return []; + }, + getSignaturePreviews: () => { + return signaturePreviews; + }, + clearPreviews: () => { + setSignaturePreviews([]); + }, + zoomIn: () => { + zoomApiRef.current?.zoomIn(); + }, + zoomOut: () => { + zoomApiRef.current?.zoomOut(); + }, + resetZoom: () => { + zoomApiRef.current?.resetZoom(); + }, + }), [signaturePreviews]); // Convert File to URL if needed useEffect(() => { @@ -65,6 +182,16 @@ export function LocalEmbedPDFWithAnnotations({ } }, [file, url]); + // Notify parent when signature previews change + useEffect(() => { + if (onAnnotationChange) { + onAnnotationChange(signaturePreviews); + } + if (onPreviewCountChange) { + onPreviewCountChange(signaturePreviews.length); + } + }, [signaturePreviews, onAnnotationChange, onPreviewCountChange]); + // Create plugins configuration with annotation support const plugins = useMemo(() => { if (!pdfUrl) return []; @@ -113,7 +240,7 @@ export function LocalEmbedPDFWithAnnotations({ // Register zoom plugin createPluginRegistration(ZoomPluginPackage, { - defaultZoomLevel: 1.4, + defaultZoomLevel: ZoomMode.FitWidth, minZoom: 0.2, maxZoom: 3.0, }), @@ -178,7 +305,7 @@ export function LocalEmbedPDFWithAnnotations({ } return ( -
{ @@ -198,15 +326,21 @@ export function LocalEmbedPDFWithAnnotations({ const annotationApi = annotationPlugin.provides(); if (!annotationApi) return; - // Add custom signature stamp tool + // Store reference for parent component access + annotationApiRef.current = annotationApi; + + // Add custom signature image tool + // Using FreeText with appearance for better image support annotationApi.addTool({ id: 'signatureStamp', name: 'Digital Signature', - interaction: { exclusive: false, cursor: 'copy' }, + interaction: { exclusive: false, cursor: 'crosshair' }, matchScore: () => 0, defaults: { type: PdfAnnotationSubtype.STAMP, - // Will be set dynamically when user creates signature + // Image data will be set dynamically via setToolDefaults + width: 150, + height: 75, }, }); @@ -224,17 +358,21 @@ export function LocalEmbedPDFWithAnnotations({ }, }); - // Listen for annotation events to notify parent - if (onAnnotationChange) { - annotationApi.onAnnotationEvent((event: AnnotationEvent) => { - if (event.type !== 'loaded' && event.committed) { - onAnnotationChange([event.annotation]); - } - }); + // Wire zoom API so parent can call zoomIn/zoomOut/resetZoom + const zoomPlugin = registry.getPlugin('zoom'); + if (zoomPlugin?.provides) { + const zoomApi = zoomPlugin.provides(); + zoomApiRef.current = { + zoomIn: () => zoomApi.zoomIn?.(), + zoomOut: () => zoomApi.zoomOut?.(), + resetZoom: () => zoomApi.requestZoom?.(ZoomMode.FitWidth, { vx: 0.5, vy: 0 }), + }; } + + }} > - + @@ -254,7 +392,7 @@ export function LocalEmbedPDFWithAnnotations({ e.preventDefault()} onDrop={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()} + onMouseMove={(e) => { + if (!placementMode || !signatureData) return; + const rect = e.currentTarget.getBoundingClientRect(); + setCursorOnPage({ pageIndex, x: e.clientX - rect.left, y: e.clientY - rect.top }); + }} + onMouseLeave={() => { + setCursorOnPage(prev => prev?.pageIndex === pageIndex ? null : prev); + }} + onClick={(e) => { + if (isDraggingRef.current) return; + + if (placementMode && onPlaceSignature) { + const rect = e.currentTarget.getBoundingClientRect(); + // Store as fractions (0–1) of the rendered page so overlays + // remain correct at any zoom level (scale not in new API) + const sigWidth = 150 / width; + const sigHeight = 75 / height; + const rawX = (e.clientX - rect.left) / width; + const rawY = (e.clientY - rect.top) / height; + const x = Math.max(0, Math.min(rawX - sigWidth / 2, 1 - sigWidth)); + const y = Math.max(0, Math.min(rawY - sigHeight / 2, 1 - sigHeight)); + + const newPreview = { + id: `sig-preview-${Date.now()}-${Math.random()}`, + pageIndex, + x, + y, + width: sigWidth, + height: sigHeight, + signatureData: signatureData || '', + signatureType: signatureType || 'image', + }; + setSignaturePreviews(prev => [...prev, newPreview]); + onPlaceSignature(newPreview.id, pageIndex, x * width, y * height, sigWidth * width, sigHeight * height); + } + }} > @@ -293,11 +469,239 @@ export function LocalEmbedPDFWithAnnotations({ + {/* Annotation layer for signatures */} + + {/* Signature preview overlays (support multiple) */} + {signaturePreviews + .filter(preview => preview.pageIndex === pageIndex) + .map((preview) => { + if (!preview.signatureData) return null; + const color = preview.color ?? 'rgb(0, 122, 204)'; + const colorOpacity = (opacity: number) => + color.startsWith('rgb(') + ? color.replace('rgb(', 'rgba(').replace(')', `, ${opacity})`) + : color; + return ( + +
+ {/* Delete button - only show when not read-only */} + {!readOnly && ( + { + e.stopPropagation(); + setSignaturePreviews(prev => prev.filter(p => p.id !== preview.id)); + }} + aria-label="Delete signature" + > + + + )} + +
{ + if ((e.target as HTMLElement).dataset.resizeHandle) return; + e.stopPropagation(); + e.preventDefault(); + const el = e.currentTarget; + el.setPointerCapture(e.pointerId); + interactionPauseRef.current?.pause(); + + const startX = e.clientX; + const startY = e.clientY; + const startLeft = preview.x; + const startTop = preview.y; + + const handlePointerMove = (moveEvent: PointerEvent) => { + isDraggingRef.current = true; + const deltaX = (moveEvent.clientX - startX) / width; + const deltaY = (moveEvent.clientY - startY) / height; + setSignaturePreviews(prev => prev.map(p => + p.id === preview.id + ? { ...p, x: startLeft + deltaX, y: startTop + deltaY } + : p + )); + }; + + const handlePointerUp = (upEvent: PointerEvent) => { + el.removeEventListener('pointermove', handlePointerMove); + el.removeEventListener('pointerup', handlePointerUp); + el.releasePointerCapture(upEvent.pointerId); + interactionPauseRef.current?.resume(); + window.getSelection()?.removeAllRanges(); + setTimeout(() => { isDraggingRef.current = false; }, 10); + }; + + el.addEventListener('pointermove', handlePointerMove); + el.addEventListener('pointerup', handlePointerUp); + }} + > + Signature preview + + {/* Resize handles */} + {[ + { position: 'nw', cursor: 'nw-resize', top: -4, left: -4 }, + { position: 'ne', cursor: 'ne-resize', top: -4, right: -4 }, + { position: 'sw', cursor: 'sw-resize', bottom: -4, left: -4 }, + { position: 'se', cursor: 'se-resize', bottom: -4, right: -4 }, + ].map((handle) => ( +
{ + e.stopPropagation(); + e.preventDefault(); + const el = e.currentTarget; + el.setPointerCapture(e.pointerId); + interactionPauseRef.current?.pause(); + + const startX = e.clientX; + const startY = e.clientY; + const startWidth = preview.width; + const startHeight = preview.height; + const startLeft = preview.x; + const startTop = preview.y; + + const handlePointerMove = (moveEvent: PointerEvent) => { + isDraggingRef.current = true; + const deltaX = (moveEvent.clientX - startX) / width; + const deltaY = (moveEvent.clientY - startY) / height; + + let newWidth = startWidth; + let newHeight = startHeight; + let newX = startLeft; + let newY = startTop; + + // Min sizes as fractions: 50px / pageWidth, 25px / pageHeight + const minW = 50 / width; + const minH = 25 / height; + + if (handle.position.includes('e')) { + newWidth = Math.max(minW, startWidth + deltaX); + } + if (handle.position.includes('w')) { + newWidth = Math.max(minW, startWidth - deltaX); + newX = startLeft + (startWidth - newWidth); + } + if (handle.position.includes('s')) { + newHeight = Math.max(minH, startHeight + deltaY); + } + if (handle.position.includes('n')) { + newHeight = Math.max(minH, startHeight - deltaY); + newY = startTop + (startHeight - newHeight); + } + + setSignaturePreviews(prev => prev.map(p => + p.id === preview.id + ? { ...p, x: newX, y: newY, width: newWidth, height: newHeight } + : p + )); + }; + + const handlePointerUp = (upEvent: PointerEvent) => { + el.removeEventListener('pointermove', handlePointerMove); + el.removeEventListener('pointerup', handlePointerUp); + el.releasePointerCapture(upEvent.pointerId); + interactionPauseRef.current?.resume(); + window.getSelection()?.removeAllRanges(); + setTimeout(() => { isDraggingRef.current = false; }, 10); + }; + + el.addEventListener('pointermove', handlePointerMove); + el.addEventListener('pointerup', handlePointerUp); + }} + /> + ))} +
+
+ + ); + })} + + {/* Hover preview: ghost signature following cursor in placement mode */} + {placementMode && signatureData && cursorOnPage?.pageIndex === pageIndex && ( + + )}
@@ -310,4 +714,6 @@ export function LocalEmbedPDFWithAnnotations({
); -} +}); + +LocalEmbedPDFWithAnnotations.displayName = 'LocalEmbedPDFWithAnnotations'; diff --git a/frontend/src/core/contexts/FileContext.tsx b/frontend/src/core/contexts/FileContext.tsx index c84d921d47..2e2eb1aa44 100644 --- a/frontend/src/core/contexts/FileContext.tsx +++ b/frontend/src/core/contexts/FileContext.tsx @@ -214,6 +214,40 @@ function FileContextInner({ return stirlingFiles; }, [enablePersistence, requestConfirmation]); + const addFilesWithOptions = useCallback( + async ( + files: File[], + options?: { + insertAfterPageId?: string; + selectFiles?: boolean; + autoUnzip?: boolean; + autoUnzipFileLimit?: number; + skipAutoUnzip?: boolean; + confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise; + allowDuplicates?: boolean; + } + ): Promise => { + const stirlingFiles = await addFiles( + { + files, + ...options, + }, + stateRef, + filesRef, + dispatch, + lifecycleManager, + enablePersistence + ); + + if (options?.selectFiles && stirlingFiles.length > 0) { + selectFiles(stirlingFiles); + } + + return stirlingFiles; + }, + [enablePersistence] + ); + const addStirlingFileStubsAction = useCallback(async (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }): Promise => { // StirlingFileStubs preserve all metadata - perfect for FileManager use case! const result = await addStirlingFileStubs(stirlingFileStubs, options, stateRef, filesRef, dispatch, lifecycleManager); @@ -326,6 +360,7 @@ function FileContextInner({ const actions = useMemo(() => ({ ...baseActions, addFiles: addRawFiles, + addFilesWithOptions, addStirlingFileStubs: addStirlingFileStubsAction, removeFiles: async (fileIds: FileId[], deleteFromStorage?: boolean) => { // Remove from memory and cleanup resources diff --git a/frontend/src/core/contexts/FileManagerContext.tsx b/frontend/src/core/contexts/FileManagerContext.tsx index cc63fd714b..3ca5307314 100644 --- a/frontend/src/core/contexts/FileManagerContext.tsx +++ b/frontend/src/core/contexts/FileManagerContext.tsx @@ -1,10 +1,20 @@ import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react'; +import { Button, Group, Modal, Stack, Text } from '@mantine/core'; import { fileStorage } from '@app/services/fileStorage'; +import { useFileActions } from '@app/contexts/FileContext'; import { zipFileService } from '@app/services/zipFileService'; import { StirlingFileStub } from '@app/types/fileContext'; import { downloadFiles } from '@app/utils/downloadUtils'; import { FileId } from '@app/types/file'; import { groupFilesByOriginal } from '@app/utils/fileHistoryUtils'; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; +import apiClient from '@app/services/apiClient'; +import { alert } from '@app/components/toast'; +import { + extractLatestFilesFromBundle, + parseContentDispositionFilename, +} from '@app/services/shareBundleUtils'; +import { useTranslation } from 'react-i18next'; import { openFilesFromDisk } from '@app/services/openFilesFromDisk'; export { pendingFilePathMappings } from '@app/services/pendingFilePathMappings'; @@ -12,6 +22,7 @@ export { pendingFilePathMappings } from '@app/services/pendingFilePathMappings'; interface FileManagerContextValue { // State activeSource: 'recent' | 'local' | 'drive'; + storageFilter: 'all' | 'local' | 'sharedWithMe' | 'sharedByMe'; selectedFileIds: FileId[]; searchTerm: string; selectedFiles: StirlingFileStub[]; @@ -26,6 +37,7 @@ interface FileManagerContextValue { // Handlers onSourceChange: (source: 'recent' | 'local' | 'drive') => void; + onStorageFilterChange: (filter: 'all' | 'local' | 'sharedWithMe' | 'sharedByMe') => void; onLocalFileClick: () => void; onFileSelect: (file: StirlingFileStub, index: number, shiftKey?: boolean) => void; onFileRemove: (index: number) => void; @@ -41,8 +53,10 @@ interface FileManagerContextValue { onToggleExpansion: (fileId: FileId) => void; onAddToRecents: (file: StirlingFileStub) => void; onUnzipFile: (file: StirlingFileStub) => Promise; + onMakeCopy: (file: StirlingFileStub) => Promise; onNewFilesSelect: (files: File[]) => void; onGoogleDriveSelect: (files: File[]) => void; + refreshRecentFiles: () => Promise; // External props recentFiles: StirlingFileStub[]; @@ -69,6 +83,8 @@ interface FileManagerProviderProps { activeFileIds: FileId[]; } +type RemoteDeleteChoice = 'local' | 'server' | 'both' | 'leave' | 'cancel'; + export const FileManagerProvider: React.FC = ({ children, recentFiles, @@ -84,12 +100,19 @@ export const FileManagerProvider: React.FC = ({ activeFileIds, }) => { const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent'); + const [storageFilter, setStorageFilter] = useState< + 'all' | 'local' | 'sharedWithMe' | 'sharedByMe' + >('all'); const [selectedFileIds, setSelectedFileIds] = useState([]); const [searchTerm, setSearchTerm] = useState(''); const [lastClickedIndex, setLastClickedIndex] = useState(null); const [expandedFileIds, setExpandedFileIds] = useState>(new Set()); const [loadedHistoryFiles, setLoadedHistoryFiles] = useState>(new Map()); // Cache for loaded history const fileInputRef = useRef(null); + const [deletePromptFile, setDeletePromptFile] = useState(null); + const deletePromptResolveRef = useRef<((choice: RemoteDeleteChoice) => void) | null>(null); + const { t } = useTranslation(); + const { actions } = useFileActions(); // Track blob URLs for cleanup const createdBlobUrls = useRef>(new Set()); @@ -117,8 +140,23 @@ export const FileManagerProvider: React.FC = ({ if (!recentFiles || recentFiles.length === 0) return []; // Only return leaf files - history files will be handled by separate components + if (storageFilter === 'sharedWithMe') { + return recentFiles.filter( + (file) => file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink + ); + } + if (storageFilter === 'sharedByMe') { + return recentFiles.filter( + (file) => file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks + ); + } + if (storageFilter === 'local') { + return recentFiles.filter( + (file) => !file.remoteStorageId && !file.remoteSharedViaLink + ); + } return recentFiles; - }, [recentFiles]); + }, [recentFiles, storageFilter]); const selectedFiles = selectedFileIds.length === 0 ? [] : displayFiles.filter(file => selectedFilesSet.has(file.id)); @@ -138,6 +176,27 @@ export const FileManagerProvider: React.FC = ({ } }, []); + const handleStorageFilterChange = useCallback((filter: 'all' | 'local' | 'sharedWithMe' | 'sharedByMe') => { + setStorageFilter(filter); + setSelectedFileIds([]); + setLastClickedIndex(null); + }, []); + + + const requestDeleteChoice = useCallback((file: StirlingFileStub): Promise => { + return new Promise((resolve) => { + deletePromptResolveRef.current = resolve; + setDeletePromptFile(file); + }); + }, []); + + const resolveDeleteChoice = useCallback((choice: RemoteDeleteChoice) => { + const resolver = deletePromptResolveRef.current; + deletePromptResolveRef.current = null; + setDeletePromptFile(null); + resolver?.(choice); + }, []); + const handleLocalFileClick = useCallback(async () => { console.log('[FileManager] Opening file dialog...'); @@ -282,6 +341,96 @@ export const FileManagerProvider: React.FC = ({ // Shared internal delete logic const performFileDelete = useCallback(async (fileToRemove: StirlingFileStub, fileIndex: number) => { + let deleteChoice: RemoteDeleteChoice = 'local'; + if (fileToRemove.remoteStorageId) { + deleteChoice = await requestDeleteChoice(fileToRemove); + if (deleteChoice === 'cancel') { + return; + } + } + + const canDeleteServer = fileToRemove.remoteOwnedByCurrentUser !== false; + const shouldDeleteServer = + canDeleteServer && (deleteChoice === 'server' || deleteChoice === 'both'); + const shouldDeleteLocal = deleteChoice === 'local' || deleteChoice === 'both'; + const isServerOnly = + Boolean(fileToRemove.remoteStorageId) && fileToRemove.id.startsWith('server-'); + const canLeaveShare = + fileToRemove.remoteOwnedByCurrentUser === false && + !fileToRemove.remoteSharedViaLink && + Boolean(fileToRemove.remoteStorageId); + + if (deleteChoice === 'leave' && canLeaveShare && fileToRemove.remoteStorageId) { + try { + await apiClient.delete( + `/api/v1/storage/files/${fileToRemove.remoteStorageId}/shares/self`, + { suppressErrorToast: true } as any + ); + await refreshRecentFiles(); + alert({ + alertType: 'success', + title: t('fileManager.leaveShareSuccess', 'Removed from your shared list.'), + expandable: false, + durationMs: 2500, + }); + return; + } catch (error) { + console.error('Failed to leave shared file:', error); + alert({ + alertType: 'error', + title: t('fileManager.leaveShareFailed', 'Could not remove the shared file.'), + expandable: false, + durationMs: 3500, + }); + return; + } + } + + if (shouldDeleteServer && fileToRemove.remoteStorageId) { + try { + await apiClient.delete( + `/api/v1/storage/files/${fileToRemove.remoteStorageId}`, + { suppressErrorToast: true } as any + ); + } catch (error) { + console.error('Failed to delete file from server:', error); + alert({ + alertType: 'error', + title: t( + 'fileManager.removeServerFailed', + 'Could not remove the file from the server.' + ), + expandable: false, + durationMs: 3500, + }); + return; + } + + if (!shouldDeleteLocal) { + const originalFileId = (fileToRemove.originalFileId || fileToRemove.id) as FileId; + const chain = await fileStorage.getHistoryChainStubs(originalFileId); + for (const stub of chain) { + await fileStorage.updateFileMetadata(stub.id, { + remoteStorageId: undefined, + remoteStorageUpdatedAt: undefined, + }); + } + await refreshRecentFiles(); + alert({ + alertType: 'success', + title: t('fileManager.removeServerSuccess', 'Removed from server.'), + expandable: false, + durationMs: 2500, + }); + return; + } + } + + if (shouldDeleteLocal && isServerOnly) { + await refreshRecentFiles(); + return; + } + const deletedFileId = fileToRemove.id; // Get all stored files to analyze lineages @@ -332,7 +481,16 @@ export const FileManagerProvider: React.FC = ({ // Refresh to ensure consistent state await refreshRecentFiles(); - }, [getSafeFilesToDelete, setSelectedFileIds, setExpandedFileIds, setLoadedHistoryFiles, onFileRemove, refreshRecentFiles]); + }, [ + getSafeFilesToDelete, + setSelectedFileIds, + setExpandedFileIds, + setLoadedHistoryFiles, + onFileRemove, + refreshRecentFiles, + requestDeleteChoice, + t, + ]); const handleFileRemove = useCallback(async (index: number) => { const fileToRemove = filteredFiles[index]; @@ -603,6 +761,60 @@ export const FileManagerProvider: React.FC = ({ } }, [refreshRecentFiles]); + const handleMakeCopy = useCallback( + async (file: StirlingFileStub) => { + if (!file.remoteStorageId) { + return; + } + try { + const response = await apiClient.get( + `/api/v1/storage/files/${file.remoteStorageId}/download`, + { responseType: 'blob', suppressErrorToast: true, skipAuthRedirect: true } as any + ); + const contentType = + (response.headers && + (response.headers['content-type'] || response.headers['Content-Type'])) || + ''; + const disposition = + (response.headers && + (response.headers['content-disposition'] || response.headers['Content-Disposition'])) || + ''; + const filename = parseContentDispositionFilename(disposition) || file.name || 'shared-file'; + const blob = response.data as Blob; + const contentTypeValue = contentType || blob.type; + const latestFiles = await extractLatestFilesFromBundle( + blob, + filename, + contentTypeValue + ); + if (latestFiles.length > 0) { + await actions.addFilesWithOptions(latestFiles, { + selectFiles: true, + allowDuplicates: true, + autoUnzip: false, + skipAutoUnzip: false, + }); + await refreshRecentFiles(); + alert({ + alertType: 'success', + title: t('fileManager.copyCreated', 'Copy saved to this device.'), + expandable: false, + durationMs: 2500, + }); + } + } catch (error) { + console.error('Failed to create a copy:', error); + alert({ + alertType: 'error', + title: t('fileManager.copyFailed', 'Could not create a copy.'), + expandable: false, + durationMs: 3500, + }); + } + }, + [actions, refreshRecentFiles, t] + ); + // Cleanup blob URLs when component unmounts useEffect(() => { return () => { @@ -618,6 +830,7 @@ export const FileManagerProvider: React.FC = ({ useEffect(() => { if (!isOpen) { setActiveSource('recent'); + setStorageFilter('all'); setSelectedFileIds([]); setSearchTerm(''); setLastClickedIndex(null); @@ -627,6 +840,7 @@ export const FileManagerProvider: React.FC = ({ const contextValue: FileManagerContextValue = useMemo(() => ({ // State activeSource, + storageFilter, selectedFileIds, searchTerm, selectedFiles, @@ -641,6 +855,7 @@ export const FileManagerProvider: React.FC = ({ // Handlers onSourceChange: handleSourceChange, + onStorageFilterChange: handleStorageFilterChange, onLocalFileClick: handleLocalFileClick, onFileSelect: handleFileSelect, onFileRemove: handleFileRemove, @@ -656,8 +871,10 @@ export const FileManagerProvider: React.FC = ({ onToggleExpansion: handleToggleExpansion, onAddToRecents: handleAddToRecents, onUnzipFile: handleUnzipFile, + onMakeCopy: handleMakeCopy, onNewFilesSelect, onGoogleDriveSelect: handleGoogleDriveSelect, + refreshRecentFiles, // External props recentFiles, @@ -665,6 +882,7 @@ export const FileManagerProvider: React.FC = ({ modalHeight, }), [ activeSource, + storageFilter, selectedFileIds, searchTerm, selectedFiles, @@ -676,6 +894,7 @@ export const FileManagerProvider: React.FC = ({ isLoading, activeFileIds, handleSourceChange, + handleStorageFilterChange, handleLocalFileClick, handleFileSelect, handleFileRemove, @@ -691,16 +910,92 @@ export const FileManagerProvider: React.FC = ({ handleToggleExpansion, handleAddToRecents, handleUnzipFile, + handleMakeCopy, onNewFilesSelect, handleGoogleDriveSelect, recentFiles, isFileSupported, modalHeight, - ]); + refreshRecentFiles, + ]); + + const deletePromptIsServerOnly = + Boolean(deletePromptFile?.remoteStorageId) && deletePromptFile?.id?.startsWith('server-'); + const deletePromptIsShared = deletePromptFile?.remoteOwnedByCurrentUser === false; + const deletePromptCanLeaveShare = + deletePromptIsShared && + !deletePromptFile?.remoteSharedViaLink && + Boolean(deletePromptFile?.remoteStorageId); return ( {children} + resolveDeleteChoice('cancel')} + centered + title={t('fileManager.removeFileTitle', 'Remove file')} + zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL} + > + + + {deletePromptIsServerOnly + ? deletePromptIsShared + ? deletePromptCanLeaveShare + ? t( + 'fileManager.removeSharedServerOnlyPrompt', + 'This file is shared with you and stored only on the server. Remove it from your list?' + ) + : t( + 'fileManager.removeSharedServerOnlyBlockedPrompt', + 'This file is shared with you and stored only on the server.' + ) + : t( + 'fileManager.removeServerOnlyPrompt', + 'This file is stored only on your server. Would you like to remove it from the server?' + ) + : deletePromptIsShared + ? t( + 'fileManager.removeSharedPrompt', + 'This file is shared with you. You can remove it from this device or your shared list.' + ) + : t( + 'fileManager.removeFilePrompt', + 'This file is saved on this device and on your server. Where would you like to remove it from?' + )} + + + {deletePromptFile?.name} + + + + {!deletePromptIsServerOnly && ( + + )} + {deletePromptCanLeaveShare && ( + + )} + {deletePromptFile?.remoteOwnedByCurrentUser !== false && ( + <> + + {!deletePromptIsServerOnly && ( + + )} + + )} + + + ); }; diff --git a/frontend/src/core/contexts/FilesModalContext.tsx b/frontend/src/core/contexts/FilesModalContext.tsx index aa1d79d1ca..d9d1c3a406 100644 --- a/frontend/src/core/contexts/FilesModalContext.tsx +++ b/frontend/src/core/contexts/FilesModalContext.tsx @@ -5,6 +5,15 @@ import { useFileContext } from '@app/contexts/file/fileHooks'; import { StirlingFileStub } from '@app/types/fileContext'; import type { FileId } from '@app/types/file'; import { fileStorage } from '@app/services/fileStorage'; +import apiClient from '@app/services/apiClient'; +import { alert } from '@app/components/toast'; +import { + extractLatestFilesFromBundle, + getShareBundleEntryRootId, + isZipBundle, + loadShareBundleEntries, + parseContentDispositionFilename, +} from '@app/services/shareBundleUtils'; interface FilesModalContextType { isFilesModalOpen: boolean; @@ -27,6 +36,152 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch const [insertAfterPage, setInsertAfterPage] = useState(); const [customHandler, setCustomHandler] = useState<((files: File[], insertAfterPage?: number) => void) | undefined>(); + const importBundleToWorkbench = useCallback( + async ( + blob: Blob, + filename: string, + contentType: string, + remoteStorageId?: number, + remoteStorageUpdatedAt?: number, + remoteOwnerUsername?: string, + remoteOwnedByCurrentUser?: boolean, + remoteSharedViaLink?: boolean, + remoteShareToken?: string + ): Promise => { + const bundle = isZipBundle(contentType, filename) ? await loadShareBundleEntries(blob) : null; + if (bundle) { + const { manifest, rootOrder, sortedEntries, files } = bundle; + + const stirlingFiles = await actions.addFilesWithOptions(files, { + selectFiles: false, + autoUnzip: false, + skipAutoUnzip: false, + allowDuplicates: true, + }); + + const idMap = new Map(); + for (let i = 0; i < stirlingFiles.length; i += 1) { + idMap.set(sortedEntries[i].logicalId, stirlingFiles[i].fileId as FileId); + } + + const rootIdMap = new Map(); + for (const rootLogicalId of rootOrder) { + const mappedId = idMap.get(rootLogicalId); + if (mappedId) { + rootIdMap.set(rootLogicalId, mappedId); + } + } + + const remoteUpdatedAt = remoteStorageUpdatedAt ?? Date.now(); + for (const entry of sortedEntries) { + const newId = idMap.get(entry.logicalId); + if (!newId) continue; + const parentId = entry.parentLogicalId + ? idMap.get(entry.parentLogicalId) + : undefined; + const rootId = + rootIdMap.get(getShareBundleEntryRootId(manifest, entry)) || + idMap.get(manifest.rootLogicalId) || + newId; + const updates = { + versionNumber: entry.versionNumber, + originalFileId: rootId, + parentFileId: parentId, + toolHistory: entry.toolHistory, + isLeaf: entry.isLeaf, + remoteStorageId, + remoteStorageUpdatedAt: remoteUpdatedAt, + remoteOwnerUsername, + remoteOwnedByCurrentUser, + remoteSharedViaLink, + remoteShareToken, + }; + actions.updateStirlingFileStub(newId, updates); + await fileStorage.updateFileMetadata(newId, updates); + } + + const selectedIds: FileId[] = []; + for (const rootId of rootOrder) { + const rootEntries = sortedEntries.filter( + (entry) => getShareBundleEntryRootId(manifest, entry) === rootId + ); + const latestEntry = rootEntries[rootEntries.length - 1]; + if (!latestEntry) { + continue; + } + const latestId = idMap.get(latestEntry.logicalId); + if (latestId) { + selectedIds.push(latestId); + } + } + + return selectedIds; + } + + const file = new File([blob], filename, { type: contentType || blob.type }); + const stirlingFiles = await actions.addFilesWithOptions([file], { + selectFiles: false, + autoUnzip: false, + skipAutoUnzip: false, + allowDuplicates: true, + }); + const fileId = stirlingFiles[0]?.fileId as FileId | undefined; + if (fileId && remoteStorageId) { + const remoteUpdatedAt = remoteStorageUpdatedAt ?? Date.now(); + const updates = { + remoteStorageId, + remoteStorageUpdatedAt: remoteUpdatedAt, + remoteOwnerUsername, + remoteOwnedByCurrentUser, + remoteSharedViaLink, + remoteShareToken, + }; + actions.updateStirlingFileStub(fileId, updates); + await fileStorage.updateFileMetadata(fileId, updates); + } + return fileId ? [fileId] : []; + }, + [actions, fileStorage] + ); + + const downloadServerFile = useCallback(async (remoteId: number) => { + const response = await apiClient.get(`/api/v1/storage/files/${remoteId}/download`, { + responseType: 'blob', + suppressErrorToast: true, + skipAuthRedirect: true, + } as any); + const contentType = + (response.headers && (response.headers['content-type'] || response.headers['Content-Type'])) || + ''; + const disposition = + (response.headers && + (response.headers['content-disposition'] || response.headers['Content-Disposition'])) || + ''; + const filename = parseContentDispositionFilename(disposition) || 'server-file'; + const blob = response.data as Blob; + const contentTypeValue = contentType || blob.type; + return { blob, filename, contentType: contentTypeValue }; + }, []); + + const downloadShareLinkFile = useCallback(async (shareToken: string) => { + const response = await apiClient.get(`/api/v1/storage/share-links/${shareToken}`, { + responseType: 'blob', + suppressErrorToast: true, + skipAuthRedirect: true, + } as any); + const contentType = + (response.headers && (response.headers['content-type'] || response.headers['Content-Type'])) || + ''; + const disposition = + (response.headers && + (response.headers['content-disposition'] || response.headers['Content-Disposition'])) || + ''; + const filename = parseContentDispositionFilename(disposition) || 'shared-file'; + const blob = response.data as Blob; + const contentTypeValue = contentType || blob.type; + return { blob, filename, contentType: contentTypeValue }; + }, []); + const openFilesModal = useCallback((options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => { setInsertAfterPage(options?.insertAfterPage); setCustomHandler(() => options?.customHandler); @@ -61,40 +216,122 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch }, [addFiles, closeFilesModal, insertAfterPage, customHandler, actions, fileCtx]); const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => { + const serverOnlyStubs = stirlingFileStubs.filter( + (stub) => stub.remoteStorageId && stub.id.startsWith('server-') + ); + const sharedLinkStubs = stirlingFileStubs.filter( + (stub) => stub.remoteShareToken + ); + const localStubs = stirlingFileStubs.filter( + (stub) => !serverOnlyStubs.includes(stub) && !sharedLinkStubs.includes(stub) + ); + if (customHandler) { - // Load the actual files from storage for custom handler try { const loadedFiles: File[] = []; - for (const stub of stirlingFileStubs) { + for (const stub of localStubs) { const stirlingFile = await fileStorage.getStirlingFile(stub.id); if (stirlingFile) { loadedFiles.push(stirlingFile); } } - + for (const stub of serverOnlyStubs) { + if (!stub.remoteStorageId) continue; + const { blob, filename, contentType } = await downloadServerFile(stub.remoteStorageId); + const latestFiles = await extractLatestFilesFromBundle(blob, filename, contentType); + loadedFiles.push(...latestFiles); + } + for (const stub of sharedLinkStubs) { + if (!stub.remoteShareToken) continue; + const { blob, filename, contentType } = await downloadShareLinkFile(stub.remoteShareToken); + const latestFiles = await extractLatestFilesFromBundle(blob, filename, contentType); + loadedFiles.push(...latestFiles); + } + if (loadedFiles.length > 0) { customHandler(loadedFiles, insertAfterPage); } } catch (error) { console.error('Failed to load files for custom handler:', error); + alert({ + alertType: 'error', + title: 'Unable to download one or more server files.', + expandable: false, + durationMs: 3500, + }); } - } else { - // Normal case - use addStirlingFileStubs to preserve metadata (auto-selects new) - if (actions.addStirlingFileStubs) { - await actions.addStirlingFileStubs(stirlingFileStubs, { selectFiles: true }); - // Merge all requested IDs into selection (covers files that already existed) - const requestedIds = stirlingFileStubs.map((s) => s.id); - if (requestedIds.length > 0) { - const currentSelected = fileCtx.selectors.getSelectedStirlingFileStubs().map((s) => s.id); - const nextSelection = Array.from(new Set([...currentSelected, ...requestedIds])); - actions.setSelectedFiles(nextSelection); - } - } else { - console.error('addStirlingFileStubs action not available'); - } + closeFilesModal(); + return; } + + const selectedFromServer: FileId[] = []; + try { + for (const stub of serverOnlyStubs) { + if (!stub.remoteStorageId) continue; + const { blob, filename, contentType } = await downloadServerFile(stub.remoteStorageId); + const importedIds = await importBundleToWorkbench( + blob, + filename, + contentType, + stub.remoteStorageId, + stub.remoteStorageUpdatedAt, + stub.remoteOwnerUsername, + stub.remoteOwnedByCurrentUser, + stub.remoteSharedViaLink, + stub.remoteShareToken + ); + selectedFromServer.push(...importedIds); + } + for (const stub of sharedLinkStubs) { + if (!stub.remoteShareToken) continue; + const { blob, filename, contentType } = await downloadShareLinkFile(stub.remoteShareToken); + const importedIds = await importBundleToWorkbench( + blob, + filename, + contentType, + stub.remoteStorageId, + stub.remoteStorageUpdatedAt, + stub.remoteOwnerUsername, + stub.remoteOwnedByCurrentUser, + true, + stub.remoteShareToken + ); + selectedFromServer.push(...importedIds); + } + } catch (error) { + console.error('Failed to load server files:', error); + alert({ + alertType: 'error', + title: 'Unable to download one or more server files.', + expandable: false, + durationMs: 3500, + }); + } + + if (actions.addStirlingFileStubs) { + await actions.addStirlingFileStubs(localStubs, { selectFiles: false }); + const requestedIds = localStubs.map((s) => s.id); + const nextSelection = Array.from( + new Set([...requestedIds, ...selectedFromServer]) + ); + actions.setSelectedFiles(nextSelection); + } else { + console.error('addStirlingFileStubs action not available'); + } + closeFilesModal(); - }, [actions.addStirlingFileStubs, closeFilesModal, customHandler, insertAfterPage, actions, fileCtx]); + }, [ + actions.addStirlingFileStubs, + actions, + closeFilesModal, + customHandler, + insertAfterPage, + fileCtx, + downloadServerFile, + downloadShareLinkFile, + extractLatestFilesFromBundle, + importBundleToWorkbench, + ]); const setModalCloseCallback = useCallback((callback: () => void) => { setOnModalClose(() => callback); diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx index 83d4fe0326..dbc74ddda9 100644 --- a/frontend/src/core/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx @@ -32,6 +32,8 @@ export interface CustomWorkbenchViewRegistration { label: string; icon?: React.ReactNode; component: React.ComponentType<{ data: any }>; + hideTopControls?: boolean; + hideToolPanel?: boolean; } export interface CustomWorkbenchViewInstance extends CustomWorkbenchViewRegistration { @@ -201,8 +203,14 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { } }, [actions, navigationState.workbench]); - const setCustomWorkbenchViewData = useCallback((id: string, data: any) => { - setCustomViewData(prev => ({ ...prev, [id]: data })); + const setCustomWorkbenchViewData = useCallback((id: string, dataOrUpdater: any | ((prev: any) => any)) => { + setCustomViewData(prev => { + const currentData = prev[id]; + const newData = typeof dataOrUpdater === 'function' + ? dataOrUpdater(currentData) + : dataOrUpdater; + return { ...prev, [id]: newData }; + }); }, []); const clearCustomWorkbenchViewData = useCallback((id: string) => { diff --git a/frontend/src/core/contexts/file/fileActions.ts b/frontend/src/core/contexts/file/fileActions.ts index e7d03ff9ff..771247ef11 100644 --- a/frontend/src/core/contexts/file/fileActions.ts +++ b/frontend/src/core/contexts/file/fileActions.ts @@ -234,6 +234,7 @@ interface AddFileOptions { autoUnzipFileLimit?: number; skipAutoUnzip?: boolean; // When true: always unzip (except HTML). Used for file uploads. When false: respect autoUnzip/autoUnzipFileLimit preferences. Used for tool outputs. confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise; // Optional callback to confirm extraction of large ZIP files + allowDuplicates?: boolean; } /** @@ -258,7 +259,7 @@ export async function addFiles( // Build quickKey lookup from existing files for deduplication const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId); - const { files = [] } = options; + const { files = [], allowDuplicates = false } = options; // ZIP pre-processing: Extract ZIP files with configurable behavior // - File uploads: skipAutoUnzip=true → always extract (except HTML) @@ -317,7 +318,7 @@ export async function addFiles( const quickKey = createQuickKey(file); // Soft deduplication: Check if file already exists by metadata - if (existingQuickKeys.has(quickKey)) { + if (!allowDuplicates && existingQuickKeys.has(quickKey)) { continue; } @@ -329,7 +330,7 @@ export async function addFiles( // Check for pending file path mapping from Tauri file dialog (desktop only) try { - const { pendingFilePathMappings } = await import('@app/contexts/FileManagerContext'); + const { pendingFilePathMappings } = await import('@app/services/pendingFilePathMappings'); console.log(`[FileActions] Checking for localFilePath mapping for quickKey: ${quickKey}`); console.log(`[FileActions] Available mappings:`, Array.from(pendingFilePathMappings.keys())); const localFilePath = pendingFilePathMappings.get(quickKey); @@ -351,7 +352,9 @@ export async function addFiles( fileStub.insertAfterPageId = options.insertAfterPageId; } - existingQuickKeys.add(quickKey); + if (!allowDuplicates) { + existingQuickKeys.add(quickKey); + } stirlingFileStubs.push(fileStub); // Dispatch immediately so each file appears as soon as it is processed diff --git a/frontend/src/core/hooks/signing/useSigningSessions.ts b/frontend/src/core/hooks/signing/useSigningSessions.ts new file mode 100644 index 0000000000..8f049d6a1d --- /dev/null +++ b/frontend/src/core/hooks/signing/useSigningSessions.ts @@ -0,0 +1,93 @@ +import { useState, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import apiClient from '@app/services/apiClient'; +import { alert } from '@app/components/toast'; +import { SignRequestSummary, SessionSummary } from '@app/types/signingSession'; + +export interface UseSigningSessionsOptions { + enabled?: boolean; + autoRefreshInterval?: number; // milliseconds, 0 to disable +} + +export interface UseSigningSessionsResult { + signRequests: SignRequestSummary[]; + mySessions: SessionSummary[]; + loading: boolean; + error: Error | null; + refetch: () => Promise; +} + +/** + * Hook to fetch signing sessions data (sign requests and user's sessions). + * Supports auto-refresh for real-time updates. + */ +export const useSigningSessions = ( + options: UseSigningSessionsOptions = {} +): UseSigningSessionsResult => { + const { enabled = true, autoRefreshInterval = 0 } = options; + const { t } = useTranslation(); + + const [signRequests, setSignRequests] = useState([]); + const [mySessions, setMySessions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + if (!enabled) return; + + setLoading(true); + setError(null); + + try { + const [requestsResponse, sessionsResponse] = await Promise.all([ + apiClient.get('/api/v1/security/cert-sign/sign-requests'), + apiClient.get('/api/v1/security/cert-sign/sessions'), + ]); + + setSignRequests(requestsResponse.data); + setMySessions(sessionsResponse.data); + } catch (err) { + const errorObj = err instanceof Error ? err : new Error('Failed to fetch signing data'); + setError(errorObj); + console.error('Failed to fetch signing data:', err); + + alert({ + alertType: 'warning', + title: t('common.error'), + body: t('certSign.fetchFailed', 'Failed to load signing data'), + expandable: false, + durationMs: 2500, + }); + } finally { + setLoading(false); + } + }, [enabled, t]); + + // Initial fetch + useEffect(() => { + if (enabled) { + fetchData(); + } + }, [enabled, fetchData]); + + // Auto-refresh + useEffect(() => { + if (!enabled || !autoRefreshInterval || autoRefreshInterval <= 0) { + return; + } + + const interval = setInterval(() => { + fetchData(); + }, autoRefreshInterval); + + return () => clearInterval(interval); + }, [enabled, autoRefreshInterval, fetchData]); + + return { + signRequests, + mySessions, + loading, + error, + refetch: fetchData, + }; +}; diff --git a/frontend/src/core/hooks/signing/useSigningWorkbench.ts b/frontend/src/core/hooks/signing/useSigningWorkbench.ts new file mode 100644 index 0000000000..63a6e8b3bc --- /dev/null +++ b/frontend/src/core/hooks/signing/useSigningWorkbench.ts @@ -0,0 +1,77 @@ +import { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; +import SignRequestWorkbenchView from '@app/components/tools/certSign/SignRequestWorkbenchView'; +import SessionDetailWorkbenchView from '@app/components/tools/certSign/SessionDetailWorkbenchView'; + +export interface WorkbenchRegistration { + id: string; + workbenchId: string; + label: string; + component: React.ComponentType; +} + +export interface UseSigningWorkbenchResult { + signRequestWorkbench: { + id: string; + type: string; + }; + sessionDetailWorkbench: { + id: string; + type: string; + }; +} + +/** + * Hook to manage custom workbench registration for signing workflows. + * Automatically registers and unregisters workbenches on mount/unmount. + */ +export const useSigningWorkbench = (): UseSigningWorkbenchResult => { + const { t } = useTranslation(); + const { + registerCustomWorkbenchView, + unregisterCustomWorkbenchView, + } = useToolWorkflow(); + + // Define workbench IDs as constants + const SIGN_REQUEST_WORKBENCH_ID = 'signRequestWorkbench'; + const SIGN_REQUEST_WORKBENCH_TYPE = 'custom:signRequestWorkbench' as const; + const SESSION_DETAIL_WORKBENCH_ID = 'sessionDetailWorkbench'; + const SESSION_DETAIL_WORKBENCH_TYPE = 'custom:sessionDetailWorkbench' as const; + + // Register workbenches on mount + useEffect(() => { + registerCustomWorkbenchView({ + id: SIGN_REQUEST_WORKBENCH_ID, + workbenchId: SIGN_REQUEST_WORKBENCH_TYPE, + label: t('certSign.collab.signRequest.workbenchTitle', 'Sign Request'), + component: SignRequestWorkbenchView, + }); + + registerCustomWorkbenchView({ + id: SESSION_DETAIL_WORKBENCH_ID, + workbenchId: SESSION_DETAIL_WORKBENCH_TYPE, + label: t('certSign.collab.sessionDetail.workbenchTitle', 'Session Management'), + component: SessionDetailWorkbenchView, + }); + + return () => { + unregisterCustomWorkbenchView(SIGN_REQUEST_WORKBENCH_ID); + unregisterCustomWorkbenchView(SESSION_DETAIL_WORKBENCH_ID); + }; + }, [registerCustomWorkbenchView, unregisterCustomWorkbenchView, t]); + + return useMemo( + () => ({ + signRequestWorkbench: { + id: SIGN_REQUEST_WORKBENCH_ID, + type: SIGN_REQUEST_WORKBENCH_TYPE, + }, + sessionDetailWorkbench: { + id: SESSION_DETAIL_WORKBENCH_ID, + type: SESSION_DETAIL_WORKBENCH_TYPE, + }, + }), + [] + ); +}; diff --git a/frontend/src/core/hooks/useFileManager.ts b/frontend/src/core/hooks/useFileManager.ts index c07b235913..073451f74e 100644 --- a/frontend/src/core/hooks/useFileManager.ts +++ b/frontend/src/core/hooks/useFileManager.ts @@ -3,10 +3,74 @@ import { useIndexedDB } from '@app/contexts/IndexedDBContext'; import { fileStorage } from '@app/services/fileStorage'; import { StirlingFileStub, StirlingFile } from '@app/types/fileContext'; import { FileId } from '@app/types/fileContext'; +import apiClient from '@app/services/apiClient'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; + +interface StoredFileResponse { + id: number; + fileName: string; + contentType?: string | null; + sizeBytes: number; + createdAt?: string | null; + updatedAt?: string | null; + owner?: string | null; + ownedByCurrentUser?: boolean; + accessRole?: string | null; + shareLinks?: Array<{ token?: string | null }>; + sharedWithUsers?: string[]; + filePurpose?: string | null; +} + +interface AccessedShareLinkResponse { + shareToken?: string | null; + fileId?: number | null; + fileName?: string | null; + owner?: string | null; + ownedByCurrentUser?: boolean; + createdAt?: string | null; + lastAccessedAt?: string | null; +} export const useFileManager = () => { const [loading, setLoading] = useState(false); const indexedDB = useIndexedDB(); + const { config } = useAppConfig(); + + const normalizeServerFileName = useCallback((fileName: string | undefined | null): string => { + const fallback = fileName?.trim() || 'server-file'; + const lowerName = fallback.toLowerCase(); + const historySuffix = '-history.zip'; + if (lowerName.endsWith(historySuffix)) { + return fallback.slice(0, fallback.length - historySuffix.length) || fallback; + } + if (lowerName.endsWith('.zip')) { + const knownInnerExt = [ + 'pdf', + 'doc', + 'docx', + 'ppt', + 'pptx', + 'xls', + 'xlsx', + 'png', + 'jpg', + 'jpeg', + 'tif', + 'tiff', + 'txt', + 'csv', + 'rtf', + 'html', + 'epub', + ]; + for (const ext of knownInnerExt) { + if (lowerName.endsWith(`.${ext}.zip`)) { + return fallback.slice(0, fallback.length - 4) || fallback; + } + } + } + return fallback; + }, []); const convertToFile = useCallback(async (fileStub: StirlingFileStub): Promise => { if (!indexedDB) { @@ -32,9 +96,237 @@ export const useFileManager = () => { // Load only leaf files metadata (processed files that haven't been used as input for other tools) const stirlingFileStubs = await fileStorage.getLeafStirlingFileStubs(); + const remoteIdSet = new Set( + stirlingFileStubs + .map((stub) => stub.remoteStorageId) + .filter((id): id is number => typeof id === 'number') + ); + let combinedStubs = stirlingFileStubs; + + const shouldFetchServerFiles = config?.storageEnabled === true; + + if (shouldFetchServerFiles) { + try { + const response = await apiClient.get( + '/api/v1/storage/files', + { suppressErrorToast: true, skipAuthRedirect: true } as any + ); + const serverFiles = Array.isArray(response.data) ? response.data : []; + const serverStubs: StirlingFileStub[] = []; + const serverMap = new Map(); + serverFiles.forEach((file) => { + if (file && typeof file.id === 'number') { + serverMap.set(file.id, file); + } + }); + + const updatedLocalStubs = stirlingFileStubs.map((stub) => { + if (!stub.remoteStorageId) { + return stub; + } + const serverFile = serverMap.get(stub.remoteStorageId); + if (!serverFile) { + if (stub.remoteSharedViaLink) { + return { + ...stub, + remoteOwnedByCurrentUser: false, + }; + } + return { + ...stub, + remoteStorageId: undefined, + remoteStorageUpdatedAt: undefined, + remoteOwnerUsername: undefined, + remoteOwnedByCurrentUser: undefined, + remoteAccessRole: undefined, + remoteSharedViaLink: false, + remoteHasShareLinks: undefined, + }; + } + // If this server file is a signing-workflow file, detach it from the file manager + if (serverFile.filePurpose && serverFile.filePurpose !== 'generic') { + return { + ...stub, + remoteStorageId: undefined, + remoteStorageUpdatedAt: undefined, + remoteOwnerUsername: undefined, + remoteOwnedByCurrentUser: undefined, + remoteAccessRole: undefined, + remoteSharedViaLink: false, + remoteHasShareLinks: undefined, + }; + } + const updatedAtMs = serverFile.updatedAt + ? new Date(serverFile.updatedAt).getTime() + : serverFile.createdAt + ? new Date(serverFile.createdAt).getTime() + : undefined; + return { + ...stub, + remoteOwnerUsername: serverFile.owner ?? stub.remoteOwnerUsername, + remoteOwnedByCurrentUser: + typeof serverFile.ownedByCurrentUser === 'boolean' + ? serverFile.ownedByCurrentUser + : stub.remoteOwnedByCurrentUser, + remoteAccessRole: serverFile.accessRole ?? stub.remoteAccessRole, + remoteSharedViaLink: stub.remoteSharedViaLink, + remoteHasShareLinks: Boolean(serverFile.shareLinks?.length), + remoteStorageUpdatedAt: + typeof updatedAtMs === 'number' && Number.isFinite(updatedAtMs) + ? updatedAtMs + : stub.remoteStorageUpdatedAt, + }; + }); + + for (const file of serverFiles) { + if (!file || typeof file.id !== 'number') { + continue; + } + if (remoteIdSet.has(file.id)) { + continue; + } + // Skip signing-workflow files — only accessible via SignPopout + if (file.filePurpose && file.filePurpose !== 'generic') { + continue; + } + const updatedAtMs = file.updatedAt + ? new Date(file.updatedAt).getTime() + : file.createdAt + ? new Date(file.createdAt).getTime() + : Date.now(); + const name = normalizeServerFileName(file.fileName); + const lastModified = Number.isFinite(updatedAtMs) ? updatedAtMs : Date.now(); + const id = `server-${file.id}` as FileId; + serverStubs.push({ + id, + name, + type: file.contentType || 'application/octet-stream', + size: file.sizeBytes ?? 0, + lastModified, + createdAt: lastModified, + isLeaf: true, + originalFileId: id, + versionNumber: 1, + toolHistory: [], + quickKey: `${name}|${file.sizeBytes ?? 0}|${lastModified}`, + remoteStorageId: file.id, + remoteStorageUpdatedAt: lastModified, + remoteOwnerUsername: file.owner ?? undefined, + remoteOwnedByCurrentUser: + typeof file.ownedByCurrentUser === 'boolean' + ? file.ownedByCurrentUser + : undefined, + remoteAccessRole: file.accessRole ?? undefined, + remoteSharedViaLink: false, + remoteHasShareLinks: Boolean(file.shareLinks?.length), + }); + } + + combinedStubs = [...updatedLocalStubs, ...serverStubs]; + } catch (error) { + console.warn('Failed to load server files:', error); + } + + if (config?.storageShareLinksEnabled === true) { + try { + const sharedResponse = await apiClient.get( + '/api/v1/storage/share-links/accessed', + { suppressErrorToast: true, skipAuthRedirect: true } as any + ); + const sharedLinks = Array.isArray(sharedResponse.data) ? sharedResponse.data : []; + const allowedShareTokens = new Set( + sharedLinks + .map((link) => link.shareToken) + .filter((token): token is string => Boolean(token)) + ); + const shareClearUpdates: Array> = []; + combinedStubs = combinedStubs.map((stub) => { + if ( + stub.remoteSharedViaLink && + stub.remoteShareToken && + !allowedShareTokens.has(stub.remoteShareToken) + ) { + const cleared = { + ...stub, + remoteStorageId: undefined, + remoteStorageUpdatedAt: undefined, + remoteOwnerUsername: undefined, + remoteOwnedByCurrentUser: undefined, + remoteSharedViaLink: false, + remoteHasShareLinks: undefined, + remoteShareToken: undefined, + }; + shareClearUpdates.push( + fileStorage.updateFileMetadata(stub.id, { + remoteStorageId: undefined, + remoteStorageUpdatedAt: undefined, + remoteOwnerUsername: undefined, + remoteOwnedByCurrentUser: undefined, + remoteSharedViaLink: false, + remoteHasShareLinks: undefined, + remoteShareToken: undefined, + }) + ); + return cleared; + } + return stub; + }); + if (shareClearUpdates.length > 0) { + await Promise.all(shareClearUpdates); + } + const existingShareTokens = new Set( + combinedStubs + .map((stub) => stub.remoteShareToken) + .filter((token): token is string => Boolean(token)) + ); + const sharedStubs: StirlingFileStub[] = []; + + for (const link of sharedLinks) { + if (!link || !link.shareToken) { + continue; + } + if (existingShareTokens.has(link.shareToken)) { + continue; + } + const createdAtMs = link.lastAccessedAt + ? new Date(link.lastAccessedAt).getTime() + : link.createdAt + ? new Date(link.createdAt).getTime() + : Date.now(); + const lastModified = Number.isFinite(createdAtMs) ? createdAtMs : Date.now(); + const name = normalizeServerFileName(link.fileName || 'shared-file'); + const id = `shared-${link.shareToken}` as FileId; + sharedStubs.push({ + id, + name, + type: 'application/octet-stream', + size: 0, + lastModified, + createdAt: lastModified, + isLeaf: true, + originalFileId: id, + versionNumber: 1, + toolHistory: [], + quickKey: `${name}|0|${lastModified}`, + remoteStorageId: link.fileId ?? undefined, + remoteStorageUpdatedAt: lastModified, + remoteOwnerUsername: link.owner ?? undefined, + remoteOwnedByCurrentUser: false, + remoteSharedViaLink: true, + remoteHasShareLinks: false, + remoteShareToken: link.shareToken, + }); + } + + combinedStubs = [...combinedStubs, ...sharedStubs]; + } catch (error) { + console.warn('Failed to load shared links:', error); + } + } + } // For now, only regular files - drafts will be handled separately in the future - const sortedFiles = stirlingFileStubs.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0)); + const sortedFiles = combinedStubs.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0)); return sortedFiles; } catch (error) { @@ -43,7 +335,7 @@ export const useFileManager = () => { } finally { setLoading(false); } - }, [indexedDB]); + }, [indexedDB, config?.enableLogin, config?.storageEnabled, config?.storageShareLinksEnabled, normalizeServerFileName]); const handleRemoveFile = useCallback(async (index: number, files: StirlingFileStub[], setFiles: (files: StirlingFileStub[]) => void) => { const file = files[index]; diff --git a/frontend/src/core/hooks/useIsMobile.ts b/frontend/src/core/hooks/useIsMobile.ts index aab30293c8..9446fe577d 100644 --- a/frontend/src/core/hooks/useIsMobile.ts +++ b/frontend/src/core/hooks/useIsMobile.ts @@ -7,3 +7,11 @@ import { useMediaQuery } from '@mantine/hooks'; export const useIsMobile = (): boolean => { return useMediaQuery('(max-width: 1024px)') ?? false; }; + +/** + * Custom hook to detect phone-sized viewport (≤768px) + * Use for layouts that need a more compact single-column arrangement + */ +export const useIsPhone = (): boolean => { + return useMediaQuery('(max-width: 768px)') ?? false; +}; diff --git a/frontend/src/core/pages/HomePage.tsx b/frontend/src/core/pages/HomePage.tsx index 4a319a1865..7fb59f27c7 100644 --- a/frontend/src/core/pages/HomePage.tsx +++ b/frontend/src/core/pages/HomePage.tsx @@ -45,6 +45,7 @@ export default function HomePage() { readerMode, setLeftPanelView, toolAvailability, + customWorkbenchViews, } = useToolWorkflow(); const { openFilesModal } = useFilesModalContext(); @@ -104,6 +105,10 @@ export default function HomePage() { navigationState.workbench, ]); + const hideToolPanel = customWorkbenchViews.find( + (v) => v.workbenchId === navigationState.workbench + )?.hideToolPanel ?? false; + const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); const brandIconSrc = useLogoPath(); const { wordmark } = useLogoAssets(); @@ -178,6 +183,14 @@ export default function HomePage() { } }, [isMobile, readerMode, selectedToolKey]); + // Automatically switch to workbench slide when a custom workbench (e.g. signing) is active on mobile. + // hideToolPanel is true for all custom workbenches that take over the full screen. + useEffect(() => { + if (isMobile && hideToolPanel) { + setActiveMobileView('workbench'); + } + }, [isMobile, hideToolPanel]); + // When navigating back to tools view in mobile with a workbench-only tool, show tool picker useEffect(() => { if (isMobile && activeMobileView === 'tools' && selectedTool) { @@ -314,7 +327,7 @@ export default function HomePage() { className="flex-nowrap flex" > - + {!hideToolPanel && } diff --git a/frontend/src/core/services/fileStorage.ts b/frontend/src/core/services/fileStorage.ts index e7b5a203a9..e481452a8d 100644 --- a/frontend/src/core/services/fileStorage.ts +++ b/frontend/src/core/services/fileStorage.ts @@ -53,9 +53,18 @@ class FileStorageService { type: stirlingFile.type, size: stirlingFile.size, lastModified: stirlingFile.lastModified, + createdAt: stub.createdAt, data: arrayBuffer, thumbnail: stub.thumbnailUrl, isLeaf: stub.isLeaf ?? true, + remoteStorageId: stub.remoteStorageId, + remoteStorageUpdatedAt: stub.remoteStorageUpdatedAt, + remoteOwnerUsername: stub.remoteOwnerUsername, + remoteOwnedByCurrentUser: stub.remoteOwnedByCurrentUser, + remoteAccessRole: stub.remoteAccessRole, + remoteSharedViaLink: stub.remoteSharedViaLink, + remoteHasShareLinks: stub.remoteHasShareLinks, + remoteShareToken: stub.remoteShareToken, // History data from stub versionNumber: stub.versionNumber ?? 1, @@ -160,11 +169,19 @@ class FileStorageService { quickKey: record.quickKey, thumbnailUrl: record.thumbnail, isLeaf: record.isLeaf, + remoteStorageId: record.remoteStorageId, + remoteStorageUpdatedAt: record.remoteStorageUpdatedAt, + remoteOwnerUsername: record.remoteOwnerUsername, + remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser, + remoteAccessRole: record.remoteAccessRole, + remoteSharedViaLink: record.remoteSharedViaLink, + remoteHasShareLinks: record.remoteHasShareLinks, + remoteShareToken: record.remoteShareToken, versionNumber: record.versionNumber, originalFileId: record.originalFileId, parentFileId: record.parentFileId, toolHistory: record.toolHistory, - createdAt: Date.now() // Current session + createdAt: record.createdAt || Date.now() }; resolve(stub); @@ -200,11 +217,19 @@ class FileStorageService { quickKey: record.quickKey, thumbnailUrl: record.thumbnail, isLeaf: record.isLeaf, + remoteStorageId: record.remoteStorageId, + remoteStorageUpdatedAt: record.remoteStorageUpdatedAt, + remoteOwnerUsername: record.remoteOwnerUsername, + remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser, + remoteAccessRole: record.remoteAccessRole, + remoteSharedViaLink: record.remoteSharedViaLink, + remoteHasShareLinks: record.remoteHasShareLinks, + remoteShareToken: record.remoteShareToken, versionNumber: record.versionNumber || 1, originalFileId: record.originalFileId || record.id, parentFileId: record.parentFileId, toolHistory: record.toolHistory || [], - createdAt: Date.now() + createdAt: record.createdAt || Date.now() }); } cursor.continue(); @@ -215,6 +240,16 @@ class FileStorageService { }); } + /** + * Get all history stubs for a given original file ID. + */ + async getHistoryChainStubs(originalFileId: FileId): Promise { + const stubs = await this.getAllStirlingFileStubs(); + return stubs + .filter((stub) => (stub.originalFileId || stub.id) === originalFileId) + .sort((a, b) => (a.versionNumber || 1) - (b.versionNumber || 1)); + } + /** * Get leaf StirlingFileStubs only - for unprocessed files */ @@ -243,11 +278,19 @@ class FileStorageService { quickKey: record.quickKey, thumbnailUrl: record.thumbnail, isLeaf: record.isLeaf, + remoteStorageId: record.remoteStorageId, + remoteStorageUpdatedAt: record.remoteStorageUpdatedAt, + remoteOwnerUsername: record.remoteOwnerUsername, + remoteOwnedByCurrentUser: record.remoteOwnedByCurrentUser, + remoteAccessRole: record.remoteAccessRole, + remoteSharedViaLink: record.remoteSharedViaLink, + remoteHasShareLinks: record.remoteHasShareLinks, + remoteShareToken: record.remoteShareToken, versionNumber: record.versionNumber || 1, originalFileId: record.originalFileId || record.id, parentFileId: record.parentFileId, toolHistory: record.toolHistory || [], - createdAt: Date.now() + createdAt: record.createdAt || Date.now() }); } cursor.continue(); @@ -473,6 +516,38 @@ class FileStorageService { return false; } } + + /** + * Update metadata fields for a stored file record. + */ + async updateFileMetadata(fileId: FileId, updates: Partial): Promise { + try { + const db = await this.getDatabase(); + const transaction = db.transaction([this.storeName], 'readwrite'); + const store = transaction.objectStore(this.storeName); + const record = await new Promise((resolve, reject) => { + const request = store.get(fileId); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result as StoredStirlingFileRecord | undefined); + }); + + if (!record) { + return false; + } + + const updatedRecord = { ...record, ...updates }; + await new Promise((resolve, reject) => { + const request = store.put(updatedRecord); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + + return true; + } catch (error) { + console.error('Failed to update file metadata:', error); + return false; + } + } } // Export singleton instance diff --git a/frontend/src/core/services/serverStorageBundle.ts b/frontend/src/core/services/serverStorageBundle.ts new file mode 100644 index 0000000000..36da384ef5 --- /dev/null +++ b/frontend/src/core/services/serverStorageBundle.ts @@ -0,0 +1,169 @@ +import JSZip from 'jszip'; + +import { fileStorage } from '@app/services/fileStorage'; +import type { FileId, ToolOperation } from '@app/types/file'; +import type { StirlingFileStub } from '@app/types/fileContext'; + +interface ShareBundleEntry { + logicalId: string; + rootLogicalId: string; + parentLogicalId?: string; + versionNumber: number; + name: string; + type: string; + size: number; + lastModified: number; + toolHistory?: ToolOperation[]; + filePath: string; + isLeaf: boolean; +} + +export interface ShareBundleManifest { + schemaVersion: 1; + rootLogicalId: string; + rootLogicalIds: string[]; + createdAt: number; + entries: ShareBundleEntry[]; +} + +function sanitizeFilename(name: string): string { + const trimmed = name?.trim(); + if (!trimmed) return 'file'; + return trimmed.replace(/[\\/:*?"<>|]/g, '_'); +} + +export async function buildHistoryBundle(originalFileIds: FileId[] | FileId): Promise<{ + bundleFile: File; + manifest: ShareBundleManifest; +}> { + const roots = Array.isArray(originalFileIds) ? originalFileIds : [originalFileIds]; + const uniqueRoots = Array.from(new Set(roots)); + const allStubs: Array<{ rootId: FileId; stubs: Awaited> }> = []; + + for (const rootId of uniqueRoots) { + const stubs = await fileStorage.getHistoryChainStubs(rootId); + if (stubs.length === 0) { + throw new Error('No history chain found for file.'); + } + allStubs.push({ rootId, stubs }); + } + + const zip = new JSZip(); + const entries: ShareBundleEntry[] = []; + + for (const chain of allStubs) { + for (const stub of chain.stubs) { + const file = await fileStorage.getStirlingFile(stub.id); + if (!file) { + throw new Error(`Missing file data for ${stub.name || stub.id}`); + } + + const logicalId = stub.id; + const filePath = `files/${logicalId}/${sanitizeFilename(stub.name || 'file')}`; + const buffer = await file.arrayBuffer(); + zip.file(filePath, buffer); + + entries.push({ + logicalId, + rootLogicalId: chain.rootId, + parentLogicalId: stub.parentFileId, + versionNumber: stub.versionNumber || 1, + name: stub.name, + type: stub.type, + size: stub.size, + lastModified: stub.lastModified, + toolHistory: stub.toolHistory, + filePath, + isLeaf: Boolean(stub.isLeaf), + }); + } + } + + const manifest: ShareBundleManifest = { + schemaVersion: 1, + rootLogicalId: uniqueRoots[0], + rootLogicalIds: uniqueRoots, + createdAt: Date.now(), + entries, + }; + + zip.file('stirling-share.json', JSON.stringify(manifest, null, 2)); + + const zipBlob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); + + const firstStubName = allStubs[0]?.stubs[0]?.name || 'shared'; + const rootName = sanitizeFilename(firstStubName); + const bundleFile = new File([zipBlob], `${rootName}-history.zip`, { + type: 'application/zip', + lastModified: Date.now(), + }); + + return { bundleFile, manifest }; +} + +export async function buildSharePackage( + stubs: StirlingFileStub[] +): Promise<{ + bundleFile: File; + manifest: ShareBundleManifest; +}> { + if (stubs.length === 0) { + throw new Error('No files provided for sharing.'); + } + + const zip = new JSZip(); + const entries: ShareBundleEntry[] = []; + + for (const stub of stubs) { + const file = await fileStorage.getStirlingFile(stub.id as FileId); + if (!file) { + throw new Error(`Missing file data for ${stub.name || stub.id}`); + } + + const logicalId = stub.id as string; + const filePath = `files/${logicalId}/${sanitizeFilename(stub.name || 'file')}`; + const buffer = await file.arrayBuffer(); + zip.file(filePath, buffer); + + entries.push({ + logicalId, + rootLogicalId: logicalId, + versionNumber: stub.versionNumber || 1, + name: stub.name, + type: stub.type, + size: stub.size, + lastModified: stub.lastModified, + toolHistory: stub.toolHistory, + filePath, + isLeaf: true, + }); + } + + const rootLogicalIds = entries.map((entry) => entry.logicalId); + const manifest: ShareBundleManifest = { + schemaVersion: 1, + rootLogicalId: rootLogicalIds[0], + rootLogicalIds, + createdAt: Date.now(), + entries, + }; + + zip.file('stirling-share.json', JSON.stringify(manifest, null, 2)); + + const zipBlob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + }); + + const bundleFile = new File([zipBlob], `shared-files.zip`, { + type: 'application/zip', + lastModified: Date.now(), + }); + + return { bundleFile, manifest }; +} diff --git a/frontend/src/core/services/serverStorageUpload.ts b/frontend/src/core/services/serverStorageUpload.ts new file mode 100644 index 0000000000..6a4569c82c --- /dev/null +++ b/frontend/src/core/services/serverStorageUpload.ts @@ -0,0 +1,125 @@ +import apiClient from '@app/services/apiClient'; +import { fileStorage } from '@app/services/fileStorage'; +import { buildHistoryBundle, buildSharePackage } from '@app/services/serverStorageBundle'; +import type { FileId } from '@app/types/file'; +import type { StirlingFileStub } from '@app/types/fileContext'; + +function resolveUpdatedAt(value: unknown): number { + if (!value) { + return Date.now(); + } + if (typeof value === 'number') { + return Number.isFinite(value) ? value : Date.now(); + } + const parsed = new Date(String(value)).getTime(); + return Number.isFinite(parsed) ? parsed : Date.now(); +} + +export async function uploadHistoryChain( + originalFileId: FileId, + existingRemoteId?: number +): Promise<{ remoteId: number; updatedAt: number; chain: StirlingFileStub[] }> { + const chain = await fileStorage.getHistoryChainStubs(originalFileId); + if (chain.length === 0) { + throw new Error('No history chain found.'); + } + + const finalStub = + chain.slice().reverse().find((stub) => stub.isLeaf !== false) || chain[chain.length - 1]; + const finalFile = await fileStorage.getStirlingFile(finalStub.id); + if (!finalFile) { + throw new Error('Missing final file data for sharing.'); + } + + const { bundleFile, manifest } = await buildHistoryBundle(originalFileId); + const auditLog = new File([JSON.stringify(manifest, null, 2)], 'audit-log.json', { + type: 'application/json', + lastModified: Date.now(), + }); + const formData = new FormData(); + formData.append('file', finalFile, finalFile.name); + formData.append('historyBundle', bundleFile, bundleFile.name); + formData.append('auditLog', auditLog, auditLog.name); + + if (existingRemoteId) { + const response = await apiClient.put(`/api/v1/storage/files/${existingRemoteId}`, formData); + const updatedAt = resolveUpdatedAt(response.data?.updatedAt); + return { remoteId: existingRemoteId, updatedAt, chain }; + } + + const response = await apiClient.post('/api/v1/storage/files', formData); + const remoteId = response.data?.id as number | undefined; + if (!remoteId) { + throw new Error('Missing stored file ID in response.'); + } + + const updatedAt = resolveUpdatedAt(response.data?.updatedAt); + return { remoteId, updatedAt, chain }; +} + +export async function uploadHistoryChains( + originalFileIds: FileId[], + existingRemoteId?: number +): Promise<{ remoteId: number; updatedAt: number; chain: StirlingFileStub[] }> { + const uniqueRoots = Array.from(new Set(originalFileIds)); + const chainMap = new Map(); + const combinedChain: StirlingFileStub[] = []; + const seenIds = new Set(); + const leafStubs: StirlingFileStub[] = []; + + for (const rootId of uniqueRoots) { + const chain = await fileStorage.getHistoryChainStubs(rootId); + if (chain.length === 0) { + throw new Error('No history chain found.'); + } + chainMap.set(rootId, chain); + const finalStub = + chain.slice().reverse().find((stub) => stub.isLeaf !== false) || chain[chain.length - 1]; + if (finalStub) { + leafStubs.push(finalStub); + } + for (const stub of chain) { + if (!seenIds.has(stub.id as FileId)) { + seenIds.add(stub.id as FileId); + combinedChain.push(stub); + } + } + } + + let shareFile: File; + if (leafStubs.length === 1) { + const finalFile = await fileStorage.getStirlingFile(leafStubs[0].id); + if (!finalFile) { + throw new Error('Missing final file data for sharing.'); + } + shareFile = finalFile; + } else { + const { bundleFile } = await buildSharePackage(leafStubs); + shareFile = bundleFile; + } + + const { bundleFile, manifest } = await buildHistoryBundle(uniqueRoots); + const auditLog = new File([JSON.stringify(manifest, null, 2)], 'audit-log.json', { + type: 'application/json', + lastModified: Date.now(), + }); + const formData = new FormData(); + formData.append('file', shareFile, shareFile.name); + formData.append('historyBundle', bundleFile, bundleFile.name); + formData.append('auditLog', auditLog, auditLog.name); + + if (existingRemoteId) { + const response = await apiClient.put(`/api/v1/storage/files/${existingRemoteId}`, formData); + const updatedAt = resolveUpdatedAt(response.data?.updatedAt); + return { remoteId: existingRemoteId, updatedAt, chain: combinedChain }; + } + + const response = await apiClient.post('/api/v1/storage/files', formData); + const remoteId = response.data?.id as number | undefined; + if (!remoteId) { + throw new Error('Missing stored file ID in response.'); + } + + const updatedAt = resolveUpdatedAt(response.data?.updatedAt); + return { remoteId, updatedAt, chain: combinedChain }; +} diff --git a/frontend/src/core/services/shareBundleUtils.ts b/frontend/src/core/services/shareBundleUtils.ts new file mode 100644 index 0000000000..02cb6d6365 --- /dev/null +++ b/frontend/src/core/services/shareBundleUtils.ts @@ -0,0 +1,119 @@ +import JSZip from 'jszip'; + +import type { ShareBundleManifest } from '@app/services/serverStorageBundle'; + +const MANIFEST_FILENAME = 'stirling-share.json'; + +export function parseContentDispositionFilename(disposition?: string): string | null { + if (!disposition) return null; + const filenameMatch = /filename="([^"]+)"/i.exec(disposition); + if (filenameMatch?.[1]) return filenameMatch[1]; + const utf8Match = /filename\*=UTF-8''([^;]+)/i.exec(disposition); + if (utf8Match?.[1]) { + try { + return decodeURIComponent(utf8Match[1]); + } catch { + return utf8Match[1]; + } + } + return null; +} + +export function isZipBundle(contentType: string, filename: string): boolean { + return contentType.includes('zip') || filename.toLowerCase().endsWith('.zip'); +} + +export function getShareBundleEntryRootId( + manifest: ShareBundleManifest, + entry: ShareBundleManifest['entries'][number] +): string { + return entry.rootLogicalId || manifest.rootLogicalId; +} + +export function resolveShareBundleOrder(manifest: ShareBundleManifest): { + rootOrder: string[]; + sortedEntries: ShareBundleManifest['entries']; +} { + const entryRootId = (entry: ShareBundleManifest['entries'][number]) => + getShareBundleEntryRootId(manifest, entry); + const rootOrder = + manifest.rootLogicalIds && manifest.rootLogicalIds.length > 0 + ? manifest.rootLogicalIds + : Array.from(new Set(manifest.entries.map(entryRootId))); + const sortedEntries: ShareBundleManifest['entries'] = []; + for (const rootId of rootOrder) { + const rootEntries = manifest.entries + .filter((entry) => entryRootId(entry) === rootId) + .sort((a, b) => a.versionNumber - b.versionNumber); + sortedEntries.push(...rootEntries); + } + return { rootOrder, sortedEntries }; +} + +export async function loadShareBundleEntries( + blob: Blob +): Promise<{ + manifest: ShareBundleManifest; + rootOrder: string[]; + sortedEntries: ShareBundleManifest['entries']; + files: File[]; +} | null> { + const zip = await JSZip.loadAsync(blob); + const manifestEntry = zip.file(MANIFEST_FILENAME); + if (!manifestEntry) { + return null; + } + + const manifestText = await manifestEntry.async('text'); + const manifest = JSON.parse(manifestText) as ShareBundleManifest; + const { rootOrder, sortedEntries } = resolveShareBundleOrder(manifest); + + const files: File[] = []; + for (const entry of sortedEntries) { + const zipEntry = zip.file(entry.filePath); + if (!zipEntry) { + throw new Error(`Missing file entry ${entry.filePath}`); + } + const fileBlob = await zipEntry.async('blob'); + files.push( + new File([fileBlob], entry.name, { + type: entry.type, + lastModified: entry.lastModified, + }) + ); + } + + return { manifest, rootOrder, sortedEntries, files }; +} + +export async function extractLatestFilesFromBundle( + blob: Blob, + filename: string, + contentType: string +): Promise { + if (!isZipBundle(contentType, filename)) { + return [new File([blob], filename, { type: contentType || blob.type })]; + } + + const bundle = await loadShareBundleEntries(blob); + if (!bundle) { + return [new File([blob], filename, { type: contentType || blob.type })]; + } + + const { manifest, rootOrder, sortedEntries, files } = bundle; + const latestByRoot = new Map(); + for (let i = 0; i < sortedEntries.length; i += 1) { + const entry = sortedEntries[i]; + latestByRoot.set(getShareBundleEntryRootId(manifest, entry), files[i]); + } + + const latestFiles = rootOrder + .map((rootId) => latestByRoot.get(rootId)) + .filter((file): file is File => Boolean(file)); + + if (latestFiles.length > 0) { + return latestFiles; + } + + return [new File([blob], filename, { type: contentType || blob.type })]; +} diff --git a/frontend/src/core/styles/zIndex.ts b/frontend/src/core/styles/zIndex.ts index 11f2016554..86f0df1fe6 100644 --- a/frontend/src/core/styles/zIndex.ts +++ b/frontend/src/core/styles/zIndex.ts @@ -28,4 +28,10 @@ export const Z_INDEX_SIGN_IN_MODAL = 9000; // Toast notifications and error displays - Always on top (higher than rainbow theme at 10000) export const Z_INDEX_TOAST = 10001; +// Signature preview overlays inside the PDF viewer +export const Z_INDEX_SIGNATURE_DRAG_BLOCKER = 999; +export const Z_INDEX_SIGNATURE_OVERLAY = 1000; +export const Z_INDEX_SIGNATURE_OVERLAY_HANDLE = 1001; +export const Z_INDEX_SIGNATURE_OVERLAY_DELETE = 1002; + diff --git a/frontend/src/core/theme/mantineTheme.ts b/frontend/src/core/theme/mantineTheme.ts index 905c8b1fac..2254566a6d 100644 --- a/frontend/src/core/theme/mantineTheme.ts +++ b/frontend/src/core/theme/mantineTheme.ts @@ -221,14 +221,9 @@ export const mantineTheme = createTheme({ }, option: { color: 'var(--text-primary)', - '&[data-hovered]': { - backgroundColor: 'var(--hover-bg)', - }, - '&[data-selected]': { - backgroundColor: 'var(--color-primary-100)', - color: 'var(--color-primary-900)', - }, - }, + '--combobox-option-hover': 'var(--hover-bg)', + '--combobox-option-selected': 'var(--color-primary-100)', + } as any, }, }, @@ -254,14 +249,9 @@ export const mantineTheme = createTheme({ }, option: { color: 'var(--text-primary)', - '&[data-hovered]': { - backgroundColor: 'var(--hover-bg)', - }, - '&[data-selected]': { - backgroundColor: 'var(--color-primary-100)', - color: 'var(--color-primary-900)', - }, - }, + '--combobox-option-hover': 'var(--hover-bg)', + '--combobox-option-selected': 'var(--color-primary-100)', + } as any, }, }, Tooltip: { @@ -356,7 +346,7 @@ export const mantineTheme = createTheme({ }, control: { color: 'var(--text-secondary)', - '&[data-active]': { + '&[dataActive]': { backgroundColor: 'var(--bg-surface)', color: 'var(--text-primary)', boxShadow: 'var(--shadow-sm)', diff --git a/frontend/src/core/types/appConfig.ts b/frontend/src/core/types/appConfig.ts index 7a913f84ae..536ca0cb7b 100644 --- a/frontend/src/core/types/appConfig.ts +++ b/frontend/src/core/types/appConfig.ts @@ -44,6 +44,11 @@ export interface AppConfig { isNewUser?: boolean; defaultHideUnavailableTools?: boolean; defaultHideUnavailableConversions?: boolean; + storageEnabled?: boolean; + storageSharingEnabled?: boolean; + storageShareLinksEnabled?: boolean; + storageShareEmailEnabled?: boolean; + storageGroupSigningEnabled?: boolean; hideDisabledToolsGoogleDrive?: boolean; hideDisabledToolsMobileQRScanner?: boolean; googleDriveEnabled?: boolean; diff --git a/frontend/src/core/types/file.ts b/frontend/src/core/types/file.ts index 8375164876..3d6db056f2 100644 --- a/frontend/src/core/types/file.ts +++ b/frontend/src/core/types/file.ts @@ -35,4 +35,23 @@ export interface BaseFileMetadata { versionNumber: number; // Version number in chain parentFileId?: FileId; // Immediate parent file ID toolHistory?: ToolOperation[]; // Tool chain for history tracking + + // Remote storage tracking + remoteStorageId?: number; // Server-side storage ID for this file chain + remoteStorageUpdatedAt?: number; // Timestamp when chain was last uploaded + remoteOwnerUsername?: string; // Server-side owner username (if known) + remoteOwnedByCurrentUser?: boolean; // Ownership flag for server files + remoteAccessRole?: string; // Access role for shared server files + remoteSharedViaLink?: boolean; // True when imported from a share link + remoteHasShareLinks?: boolean; // True when owner has shared this file + remoteShareToken?: string; // Share token when file is from a share link +} + +/** + * Minimal file shape used by signing workflow components. + * Both StirlingFile (extends File) and StirlingFileStub are assignable to this. + */ +export interface FileState { + name: string; + size: number; } diff --git a/frontend/src/core/types/fileContext.ts b/frontend/src/core/types/fileContext.ts index 81365c38c9..3f7f824cf6 100644 --- a/frontend/src/core/types/fileContext.ts +++ b/frontend/src/core/types/fileContext.ts @@ -289,6 +289,18 @@ export type FileContextAction = export interface FileContextActions { // File management - lightweight actions only addFiles: (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise; + addFilesWithOptions: ( + files: File[], + options?: { + insertAfterPageId?: string; + selectFiles?: boolean; + autoUnzip?: boolean; + autoUnzipFileLimit?: number; + skipAutoUnzip?: boolean; + confirmLargeExtraction?: (fileCount: number, fileName: string) => Promise; + allowDuplicates?: boolean; + } + ) => Promise; addStirlingFileStubs: (stirlingFileStubs: StirlingFileStub[], options?: { insertAfterPageId?: string; selectFiles?: boolean }) => Promise; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise; updateStirlingFileStub: (id: FileId, updates: Partial) => void; diff --git a/frontend/src/core/types/signingSession.ts b/frontend/src/core/types/signingSession.ts new file mode 100644 index 0000000000..5ff527cc54 --- /dev/null +++ b/frontend/src/core/types/signingSession.ts @@ -0,0 +1,80 @@ +export interface WetSignatureMetadata { + type: 'canvas' | 'image' | 'text'; + data: string; // Base64-encoded image data or text content + page: number; // Zero-indexed page number + x: number; // X coordinate in PDF points + y: number; // Y coordinate in PDF points (top-left origin) + width: number; // Width in PDF points + height: number; // Height in PDF points +} + +export interface SessionSummary { + sessionId: string; + documentName: string; + createdAt: string; + participantCount: number; + signedCount: number; + finalized: boolean; +} + +export interface SessionDetail { + sessionId: string; + documentName: string; + ownerEmail: string; + message: string; + dueDate: string; + createdAt: string; + updatedAt: string; + finalized: boolean; + participants: ParticipantInfo[]; +} + +export interface ParticipantInfo { + id: number; + userId: number; + email: string; + name: string; + status: 'PENDING' | 'NOTIFIED' | 'VIEWED' | 'SIGNED' | 'DECLINED'; + lastUpdated: string; + // Signature appearance settings (owner-controlled) + showSignature?: boolean; + pageNumber?: number; + reason?: string; + location?: string; + showLogo?: boolean; + // Wet signatures (visual annotations placed by participant) + wetSignatures?: WetSignatureMetadata[]; +} + +export interface UserSummary { + userId: number; + username: string; + displayName: string; + teamName: string | null; + enabled: boolean; +} + +export interface SignRequestSummary { + sessionId: string; + documentName: string; + ownerUsername: string; + createdAt: string; + dueDate: string; + myStatus: 'PENDING' | 'NOTIFIED' | 'VIEWED' | 'SIGNED' | 'DECLINED'; +} + +export interface SignRequestDetail { + sessionId: string; + documentName: string; + ownerUsername: string; + message: string; + dueDate: string; + createdAt: string; + myStatus: 'PENDING' | 'NOTIFIED' | 'VIEWED' | 'SIGNED' | 'DECLINED'; + // Signature appearance settings (read-only, configured by owner) + showSignature?: boolean; + pageNumber?: number; + reason?: string; + location?: string; + showLogo?: boolean; +} diff --git a/frontend/src/desktop/services/tauriHttpClient.ts b/frontend/src/desktop/services/tauriHttpClient.ts index a887f05f14..14914cf238 100644 --- a/frontend/src/desktop/services/tauriHttpClient.ts +++ b/frontend/src/desktop/services/tauriHttpClient.ts @@ -30,6 +30,7 @@ export interface TauriHttpRequestConfig { // Axios compatibility properties (ignored by Tauri HTTP) suppressErrorToast?: boolean; cancelToken?: any; + signal?: AbortSignal; } export interface TauriHttpError extends Error { @@ -201,6 +202,7 @@ class TauriHttpClient { headers, body, credentials, + ...(finalConfig.signal ? { signal: finalConfig.signal } : {}), }; // Always enable dangerous settings for HTTPS to allow connections to servers with: diff --git a/frontend/src/proprietary/App.tsx b/frontend/src/proprietary/App.tsx index 8a03293d80..75fec399c7 100644 --- a/frontend/src/proprietary/App.tsx +++ b/frontend/src/proprietary/App.tsx @@ -10,6 +10,7 @@ import Login from "@app/routes/Login"; import Signup from "@app/routes/Signup"; import AuthCallback from "@app/routes/AuthCallback"; import InviteAccept from "@app/routes/InviteAccept"; +import ShareLinkPage from "@app/routes/ShareLinkPage"; import MobileScannerPage from "@app/pages/MobileScannerPage"; import Onboarding from "@app/components/onboarding/Onboarding"; @@ -59,7 +60,7 @@ export default function App() { } /> } /> } /> - + } /> {/* Main app routes - Landing handles auth logic */} } /> diff --git a/frontend/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/src/proprietary/components/shared/config/configNavSections.tsx index 0073696f9e..142cb46e80 100644 --- a/frontend/src/proprietary/components/shared/config/configNavSections.tsx +++ b/frontend/src/proprietary/components/shared/config/configNavSections.tsx @@ -15,6 +15,7 @@ import AdminFeaturesSection from '@app/components/shared/config/configSections/A import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection'; import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection'; import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection'; +import AdminStorageSharingSection from '@app/components/shared/config/configSections/AdminStorageSharingSection'; import ApiKeys from '@app/components/shared/config/configSections/ApiKeys'; import AccountSection from '@app/components/shared/config/configSections/AccountSection'; import GeneralSection from '@app/components/shared/config/configSections/GeneralSection'; @@ -98,6 +99,14 @@ export const useConfigNavSections = ( disabled: requiresLogin, disabledTooltip: requiresLogin ? enableLoginTooltip : undefined }, + { + key: 'adminStorageSharing', + label: t('settings.configuration.storageSharing', 'File Storage & Sharing'), + icon: 'storage-rounded', + component: , + disabled: requiresLogin, + disabledTooltip: requiresLogin ? enableLoginTooltip : undefined + }, { key: 'adminEndpoints', label: t('settings.configuration.endpoints', 'Endpoints'), diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx new file mode 100644 index 0000000000..b7ee40e621 --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminStorageSharingSection.tsx @@ -0,0 +1,294 @@ +import { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Anchor, Group, Loader, Paper, Stack, Switch, Text } from '@mantine/core'; +import { useNavigate } from 'react-router-dom'; +import { alert } from '@app/components/toast'; +import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal'; +import { useRestartServer } from '@app/components/shared/config/useRestartServer'; +import { useAdminSettings } from '@app/hooks/useAdminSettings'; +import PendingBadge from '@app/components/shared/config/PendingBadge'; +import apiClient from '@app/services/apiClient'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; +import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner'; +import { SettingsStickyFooter } from '@app/components/shared/config/SettingsStickyFooter'; +import { useSettingsDirty } from '@app/hooks/useSettingsDirty'; + +interface StorageSharingSettingsData { + enabled?: boolean; + sharing?: { + enabled?: boolean; + linkEnabled?: boolean; + emailEnabled?: boolean; + }; + signing?: { + enabled?: boolean; + }; + system?: { + frontendUrl?: string; + }; + mail?: { + enabled?: boolean; + }; +} + +export default function AdminStorageSharingSection() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { loginEnabled, validateLoginEnabled, getDisabledStyles } = useLoginRequired(); + const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); + + const { + settings, + setSettings, + loading, + saving, + fetchSettings, + saveSettings, + isFieldPending, + } = useAdminSettings({ + sectionName: 'storage', + fetchTransformer: async () => { + const [storageResponse, systemResponse, mailResponse] = await Promise.all([ + apiClient.get('/api/v1/admin/settings/section/storage'), + apiClient.get('/api/v1/admin/settings/section/system'), + apiClient.get('/api/v1/admin/settings/section/mail'), + ]); + + const storageData = storageResponse.data || {}; + const systemData = systemResponse.data || {}; + const mailData = mailResponse.data || {}; + + return { + ...storageData, + system: { frontendUrl: systemData.frontendUrl || '' }, + mail: { enabled: mailData.enabled || false }, + }; + }, + saveTransformer: (currentSettings) => ({ + sectionData: { + enabled: currentSettings.enabled, + sharing: { + enabled: currentSettings.sharing?.enabled, + linkEnabled: currentSettings.sharing?.linkEnabled, + emailEnabled: currentSettings.sharing?.emailEnabled, + }, + signing: { + enabled: currentSettings.signing?.enabled, + }, + }, + }), + }); + + useEffect(() => { + if (loginEnabled) { + fetchSettings(); + } + }, [loginEnabled]); + + const storageEnabled = settings.enabled ?? false; + const sharingEnabled = storageEnabled && (settings.sharing?.enabled ?? false); + const frontendUrlConfigured = Boolean(settings.system?.frontendUrl?.trim()); + const mailEnabled = Boolean(settings.mail?.enabled); + + const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(settings, loading); + + const handleDiscard = useCallback(() => { + setSettings(resetToSnapshot()); + }, [resetToSnapshot, setSettings]); + + const handleSave = async () => { + if (!validateLoginEnabled()) { + return; + } + try { + markSaved(); + await saveSettings(); + showRestartModal(); + } catch (_error) { + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: t('admin.settings.saveError', 'Failed to save settings'), + }); + } + }; + + if (loginEnabled && loading) { + return ( + + + + ); + } + + return ( +
+
+ + +
+ {t('admin.settings.storage.title', 'File Storage & Sharing')} + + {t('admin.settings.storage.description', 'Control server storage and sharing options.')} + +
+ + + + + {t('admin.settings.storage.enabled.label', 'Enable Server File Storage')} + {isFieldPending('enabled') && } + + + {t('admin.settings.storage.enabled.description', 'Allow users to store files on the server.')} + + setSettings({ ...settings, enabled: e.currentTarget.checked })} + disabled={!loginEnabled} + styles={getDisabledStyles()} + /> + + + + + + + {t('admin.settings.storage.sharing.enabled.label', 'Enable Sharing')} + {isFieldPending('sharing.enabled') && } + + + {t('admin.settings.storage.sharing.enabled.description', 'Allow users to share stored files.')} + + + setSettings({ + ...settings, + sharing: { ...settings.sharing, enabled: e.currentTarget.checked }, + }) + } + disabled={!loginEnabled || !storageEnabled} + styles={getDisabledStyles()} + /> + + + + + + + {t('admin.settings.storage.sharing.links.label', 'Enable Share Links')} + {isFieldPending('sharing.linkEnabled') && } + + + {t('admin.settings.storage.sharing.links.description', 'Allow sharing via signed-in links.')} + + {!frontendUrlConfigured && ( + + {t('admin.settings.storage.sharing.links.frontendUrlNote', 'Requires a Frontend URL. ')} + { + e.preventDefault(); + navigate('/settings/adminGeneral#frontendUrl'); + }} + c="orange" + td="underline" + > + {t('admin.settings.storage.sharing.links.frontendUrlLink', 'Configure in System Settings')} + + + )} + + setSettings({ + ...settings, + sharing: { ...settings.sharing, linkEnabled: e.currentTarget.checked }, + }) + } + disabled={!loginEnabled || !sharingEnabled || !frontendUrlConfigured} + styles={getDisabledStyles()} + /> + + + + + + + {t('admin.settings.storage.sharing.email.label', 'Enable Email Sharing')} + {isFieldPending('sharing.emailEnabled') && } + + + {t('admin.settings.storage.sharing.email.description', 'Allow sharing with email addresses.')} + + {!mailEnabled && ( + + {t('admin.settings.storage.sharing.email.mailNote', 'Requires mail configuration. ')} + { + e.preventDefault(); + navigate('/settings/adminConnections'); + }} + c="orange" + td="underline" + > + {t('admin.settings.storage.sharing.email.mailLink', 'Configure Mail Settings')} + + + )} + + setSettings({ + ...settings, + sharing: { ...settings.sharing, emailEnabled: e.currentTarget.checked }, + }) + } + disabled={!loginEnabled || !sharingEnabled || !mailEnabled} + styles={getDisabledStyles()} + /> + + + + + + + {t('admin.settings.storage.signing.enabled.label', 'Enable Group Signing (Alpha)')} + {isFieldPending('signing.enabled') && } + + + {t('admin.settings.storage.signing.enabled.description', 'Allow users to create multi-participant document signing sessions. Requires server file storage to be enabled.')} + + + setSettings({ + ...settings, + signing: { ...settings.signing, enabled: e.currentTarget.checked }, + }) + } + disabled={!loginEnabled || !storageEnabled} + styles={getDisabledStyles()} + /> + + + + +
+
+ +
+ ); +} diff --git a/frontend/src/proprietary/components/workflow/ParticipantView.tsx b/frontend/src/proprietary/components/workflow/ParticipantView.tsx new file mode 100644 index 0000000000..c3df54fdd3 --- /dev/null +++ b/frontend/src/proprietary/components/workflow/ParticipantView.tsx @@ -0,0 +1,279 @@ +import React, { useState } from 'react'; +import { + Stack, + Card, + Text, + Badge, + Group, + Button, + Loader, + Alert, + TextInput, + FileInput, + Select, +} from '@mantine/core'; +import { useParticipantSession } from '@app/hooks/workflow/useParticipantSession'; +import InfoIcon from '@mui/icons-material/Info'; +import DownloadIcon from '@mui/icons-material/Download'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import CancelIcon from '@mui/icons-material/Cancel'; + +interface ParticipantViewProps { + token: string; +} + +const ParticipantView: React.FC = ({ token }) => { + const { session, participant, loading, error, submitSignature, decline, downloadDocument } = + useParticipantSession(token); + + const [certType, setCertType] = useState('P12'); + const [password, setPassword] = useState(''); + const [certFile, setCertFile] = useState(null); + const [location, setLocation] = useState(''); + const [reason, setReason] = useState('Document Signing'); + const [showSignature, _setShowSignature] = useState(true); + const [pageNumber, setPageNumber] = useState(1); + const [declineReason, _setDeclineReason] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + + const handleSubmitSignature = async () => { + if (!certFile && certType !== 'SERVER') { + setNotification({ type: 'error', message: 'Please select a certificate file' }); + return; + } + + setIsSubmitting(true); + setNotification(null); + try { + await submitSignature({ + participantToken: token, + certType, + password, + p12File: certType === 'P12' ? certFile || undefined : undefined, + jksFile: certType === 'JKS' ? certFile || undefined : undefined, + showSignature, + pageNumber, + location, + reason, + showLogo: true, + }); + setNotification({ type: 'success', message: 'Signature submitted successfully!' }); + } catch (err: any) { + setNotification({ type: 'error', message: `Failed to submit signature: ${err.message}` }); + } finally { + setIsSubmitting(false); + } + }; + + const handleDecline = async () => { + if (window.confirm('Are you sure you want to decline signing this document?')) { + setNotification(null); + try { + await decline(token, declineReason || 'Declined by participant'); + setNotification({ type: 'success', message: 'You have declined this signing request.' }); + } catch (err: any) { + setNotification({ type: 'error', message: `Failed to decline: ${err.message}` }); + } + } + }; + + if (loading && !session) { + return ( + + + Loading session... + + ); + } + + if (error) { + return ( + } color="red" title="Error"> + {error} + + ); + } + + if (!session || !participant) { + return ( + } color="orange"> + Session not found or access denied. + + ); + } + + const getStatusBadge = (status: string) => { + switch (status) { + case 'SIGNED': + return Signed; + case 'DECLINED': + return Declined; + case 'VIEWED': + return Viewed; + case 'NOTIFIED': + return Notified; + case 'PENDING': + return Pending; + default: + return {status}; + } + }; + + const canSign = !participant.hasCompleted && !participant.isExpired && session.status === 'IN_PROGRESS'; + + return ( + + {notification && ( + : } + color={notification.type === 'success' ? 'green' : 'red'} + withCloseButton + onClose={() => setNotification(null)} + > + {notification.message} + + )} + + + +
+ + {session.documentName} + + + From: {session.ownerUsername} + +
+ {getStatusBadge(participant.status)} +
+ + {session.message && ( + } color="blue" variant="light"> + {session.message} + + )} + + {session.dueDate && ( + + Due Date: {session.dueDate} + + )} + + + + +
+
+ + {canSign && ( + + + + Sign Document + + +