Compare commits

...
Author SHA1 Message Date
Connor Yoh f603ba12ce personal certs working 2026-01-20 10:26:54 +00:00
Connor Yoh da80344c08 Demo of wet signature bad ui 2026-01-20 09:56:35 +00:00
Connor Yoh ffd404a24b User signing 2026-01-15 18:00:46 +00:00
Connor Yoh f2eee030c3 Working for sessions 2025-12-03 16:41:56 +00:00
Connor Yoh ce10dacf15 Working with sessions and Database 2025-12-02 21:13:28 +00:00
Connor Yoh 60e2a67200 json mode 2025-12-02 15:14:16 +00:00
Connor YohandClaude 3e87faf07e Complete shared signing workflow implementation
**Backend fixes:**
- SigningSessionController: Gracefully skip SERVER certificate type when feature unavailable
- Change createSession endpoint to return ResponseEntity<SigningSession> with JSON content type
- Prevents errors in non-proprietary/unlicensed deployments

**Frontend enhancements:**
- SigningWorkflow: Auto-detect JSON session files and skip to finalization
- Add JSON file support to tool registry (supportedFormats: pdf, json)
- Enable session owners to re-upload JSON and finalize without recreating
- Debug logging for troubleshooting session detection

**Routing:**
- Core App.tsx: Add query param detection for participant URLs
- Proprietary App.tsx: Add /signing-session route with React Router integration
- Both apps now support: /signing-session?sessionId={id}&token={token}

**Complete end-to-end workflow:**
1. Owner creates session → downloads JSON with participant tokens
2. Participants access unique URLs → submit certificates (PEM/PKCS12/PFX/JKS)
3. Owner re-uploads JSON → clicks finalize → downloads signed PDF
4. SERVER certificate type gracefully skipped when feature unavailable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 13:49:22 +00:00
Connor YohandClaude 3b64da76ff Add session finalization with PDF download
**SigningWorkflow.tsx enhancements:**
- Add finalization state management (loading, error)
- Extract sessionId from result JSON file asynchronously
- Finalization handler calls /finalize endpoint with extracted sessionId
- Auto-download signed PDF on success
- Display finalization button in review section when results present
- Show helpful tooltip explaining finalization process
- Error handling with user-friendly messages

**User flow:**
1. User creates session → receives signing-session-{id}.json
2. Shares participant URLs (from JSON)
3. Participants submit certificates via their URLs
4. User returns to tool with JSON file
5. Clicks "Finalize and download signed PDF"
6. Backend applies all signatures in order
7. Signed PDF auto-downloads

**UI components:**
- Alert with finalization tooltip (blue)
- Green finalization button with CheckCircle icon
- Error alert if finalization fails (red)
- Loading state during API call

**API:**
- POST /api/v1/security/cert-sign/sessions/{sessionId}/finalize
- Response: Binary PDF blob
- Downloads as: signed-{sessionId}.pdf

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 11:11:29 +00:00
Connor YohandClaude ffbecc592b Add participant certificate submission UI
**New component:** ParticipantCertificateSubmission.tsx
- Standalone page for participants to submit certificates via shareToken URL
- URL pattern: /signing-session?sessionId={uuid}&token={shareToken}
- Validates token against session participants
- Supports all certificate types: PEM, PKCS12, PFX, JKS, SERVER
- Configurable signature appearance (visible/invisible, page, reason, location, name, logo)
- Error handling for invalid tokens, already signed, and API failures
- Success state with confirmation message

**App.tsx routing:**
- Check query params for sessionId + token on load
- Route to participant submission page if both present
- Otherwise show normal home page
- No React Router dependency (simple query param parsing)

**Integration:**
- Uses existing FileUploadButton component
- Follows Mantine UI patterns from SigningCollaborationSettings
- API: POST /api/v1/security/cert-sign/sessions/{sessionId}/participants/{email}/certificate

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:49:39 +00:00
Connor YohandClaude 2e3e297e07 Add shared signing translation keys (en-GB only)
- Add home.signingWorkflow.* keys for tool registration
- Add certSign.collab.* keys for session creation UI
- Add certSign.collab.participant.* keys for certificate submission page
- Add certSign.collab.finalize.* keys for finalization button
- Total: 35+ new translation keys
- Follows project convention: en-GB only (not en-US)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:48:53 +00:00
Connor YohandClaude b9d68ccfdd Remove offline dependency mirror configuration
- Remove local-maven repository from build.gradle and settings.gradle
- Delete local-maven directory with cached POM files
- Preserve dependency version updates and plugin removals from original commit
- Build verified to work without offline mirror

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 10:48:32 +00:00
ConnorYoh a9bc7a562d Fix build with offline dependency mirror 2025-12-01 18:07:42 +00:00
ConnorYoh efbb45717b Add shared signing user stories 2025-11-28 16:13:26 +00:00
74 changed files with 7801 additions and 85 deletions
+3 -3
View File
@@ -33,16 +33,16 @@ dependencies {
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1'
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
api 'org.apache.commons:commons-lang3:3.19.0'
api 'org.apache.commons:commons-lang3:3.17.0'
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
api "org.apache.pdfbox:preflight:$pdfboxVersion"
api 'com.github.junrar:junrar:7.5.5' // RAR archive support for CBR files
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:2.10'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13"
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14"
api 'jakarta.mail:jakarta.mail-api:2.1.5'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
}
@@ -0,0 +1,14 @@
package stirling.software.common.model.api.security;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AddParticipantsRequest {
private List<Long> participantUserIds;
}
@@ -0,0 +1,48 @@
package stirling.software.common.model.api.security;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class CreateSigningSessionRequest extends PDFFile {
@Schema(description = "Owner email used for activity updates")
private String ownerEmail;
@Schema(
description = "User IDs of participants to invite for signing",
requiredMode = Schema.RequiredMode.REQUIRED)
private List<Long> participantUserIds;
@Schema(description = "Optional message included in notifications")
private String message;
@Schema(description = "Optional due date for reminders (ISO-8601 date)")
private String dueDate;
@Schema(description = "Whether to send notifications immediately")
private Boolean notifyOnCreate;
// Signature appearance settings (owner-controlled, applied to all participants)
@Schema(description = "Whether to show visible signature")
private Boolean showSignature;
@Schema(description = "Page number for signature (1-indexed)")
private Integer pageNumber;
@Schema(description = "Signature reason")
private String reason;
@Schema(description = "Signature location")
private String location;
@Schema(description = "Whether to show Stirling PDF logo in signature")
private Boolean showLogo;
}
@@ -0,0 +1,17 @@
package stirling.software.common.model.api.security;
import java.util.List;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class NotifySigningParticipantsRequest {
@Schema(description = "Participants to notify; defaults to all if omitted")
private List<String> participantEmails;
@Schema(description = "Notification message to deliver")
private String message;
}
@@ -0,0 +1,50 @@
package stirling.software.common.model.api.security;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class ParticipantCertificateRequest {
@Schema(
description = "Certificate type for the participant",
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER"},
requiredMode = Schema.RequiredMode.REQUIRED)
private String certType;
@Schema(description = "Password for keystore or private key", format = "password")
private String password;
@Schema(description = "Private key for PEM flow")
private MultipartFile privateKeyFile;
@Schema(description = "Certificate for PEM flow")
private MultipartFile certFile;
@Schema(description = "PKCS12/PFX keystore")
private MultipartFile p12File;
@Schema(description = "JKS keystore")
private MultipartFile jksFile;
@Schema(description = "Display the signature visually")
private Boolean showSignature;
@Schema(description = "Page number for visible signature (1-indexed)")
private Integer pageNumber;
@Schema(description = "Custom signer name override")
private String name;
@Schema(description = "Signing reason")
private String reason;
@Schema(description = "Signing location")
private String location;
@Schema(description = "Show the Stirling PDF logo in the appearance")
private Boolean showLogo;
}
@@ -0,0 +1,21 @@
package stirling.software.common.model.api.security;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ParticipantCertificateSubmission {
private String certType;
private String password;
private byte[] privateKey;
private byte[] certificate;
private byte[] p12Keystore;
private byte[] jksKeystore;
private Boolean showSignature;
private Integer pageNumber;
private String name;
private String reason;
private String location;
private Boolean showLogo;
}
@@ -0,0 +1,25 @@
package stirling.software.common.model.api.security;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ParticipantDTO {
private Long userId;
private String username;
private String displayName;
private ParticipantStatus status;
private LocalDateTime lastUpdated;
// Signature appearance settings (owner-controlled)
private Boolean showSignature;
private Integer pageNumber;
private String reason;
private String location;
private Boolean showLogo;
}
@@ -0,0 +1,9 @@
package stirling.software.common.model.api.security;
public enum ParticipantStatus {
PENDING,
NOTIFIED,
VIEWED,
SIGNED,
DECLINED
}
@@ -0,0 +1,81 @@
package stirling.software.common.model.api.security;
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.
*/
@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;
// Wet signature metadata fields (optional)
private String wetSignatureType; // "canvas" | "image" | "text"
private String wetSignatureData; // Base64 image data or text
private Integer wetSignaturePage; // Zero-indexed page number
private Double wetSignatureX; // X coordinate in PDF points
private Double wetSignatureY; // Y coordinate in PDF points (top-left origin)
private Double wetSignatureWidth; // Width in PDF points
private Double wetSignatureHeight; // Height in PDF points
/**
* Checks if this request includes wet signature metadata.
*
* @return true if wet signature data is present
*/
public boolean hasWetSignature() {
return wetSignatureType != null
&& wetSignatureData != null
&& wetSignaturePage != null
&& wetSignatureX != null
&& wetSignatureY != null
&& wetSignatureWidth != null
&& wetSignatureHeight != null;
}
/**
* Extracts wet signature metadata into a dedicated DTO.
*
* @return WetSignatureMetadata object if wet signature is present, null otherwise
*/
public WetSignatureMetadata extractWetSignatureMetadata() {
if (!hasWetSignature()) {
return null;
}
WetSignatureMetadata metadata = new WetSignatureMetadata();
metadata.setType(wetSignatureType);
metadata.setData(wetSignatureData);
metadata.setPage(wetSignaturePage);
metadata.setX(wetSignatureX);
metadata.setY(wetSignatureY);
metadata.setWidth(wetSignatureWidth);
metadata.setHeight(wetSignatureHeight);
// Validate the metadata
metadata.validate();
return metadata;
}
}
@@ -0,0 +1,25 @@
package stirling.software.common.model.api.security;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/** 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;
}
@@ -0,0 +1,18 @@
package stirling.software.common.model.api.security;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/** 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;
}
@@ -0,0 +1,26 @@
package stirling.software.common.model.api.security;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import lombok.Data;
@Data
public class SigningParticipant {
private Long userId; // Database user ID (null for in-memory sessions)
private String email;
private String name;
private ParticipantStatus status = ParticipantStatus.PENDING;
private List<String> notifications = new ArrayList<>();
private String shareToken = UUID.randomUUID().toString();
private String lastUpdated = Instant.now().toString();
private ParticipantCertificateSubmission certificateSubmission;
public void recordNotification(String message) {
notifications.add(message);
lastUpdated = Instant.now().toString();
}
}
@@ -0,0 +1,26 @@
package stirling.software.common.model.api.security;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import lombok.Data;
@Data
public class SigningSession {
private String sessionId = UUID.randomUUID().toString();
private String documentName;
private byte[] originalPdf;
private byte[] signedPdf;
private String ownerEmail;
private String message;
private String dueDate;
private String createdAt = Instant.now().toString();
private String updatedAt = Instant.now().toString();
private List<SigningParticipant> participants = new ArrayList<>();
public void touch() {
updatedAt = Instant.now().toString();
}
}
@@ -0,0 +1,23 @@
package stirling.software.common.model.api.security;
import java.time.LocalDateTime;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SigningSessionDetailDTO {
private String sessionId;
private String documentName;
private String ownerEmail;
private String message;
private String dueDate;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private boolean finalized;
private List<ParticipantDTO> participants;
}
@@ -0,0 +1,19 @@
package stirling.software.common.model.api.security;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SigningSessionSummaryDTO {
private String sessionId;
private String documentName;
private LocalDateTime createdAt;
private int participantCount;
private int signedCount;
private boolean finalized;
}
@@ -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;
}
@@ -0,0 +1,96 @@
package stirling.software.common.model.api.security;
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 {
/** 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 coordinate (in PDF points) of the signature rectangle, measured from left edge */
@NotNull(message = "X coordinate is required")
@PositiveOrZero(message = "X coordinate must be zero or positive")
private Double x;
/**
* Y coordinate (in PDF points) of the signature rectangle, 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")
private Double y;
/** Width of the signature rectangle in PDF points */
@NotNull(message = "Width is required")
@Positive(message = "Width must be positive")
private Double width;
/** Height of the signature rectangle in PDF points */
@NotNull(message = "Height is required")
@Positive(message = "Height must be positive")
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");
}
}
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;
}
}
@@ -0,0 +1,155 @@
package stirling.software.common.service;
import java.io.IOException;
import java.util.List;
public interface SigningSessionServiceInterface {
/**
* Creates a new signing session
*
* @param request The session creation request
* @param username The username of the session owner (optional, pass null for non-authenticated)
* @return The created session (implementation-specific return type)
*/
Object createSession(Object request, String username) throws IOException;
/**
* Gets a signing session by ID
*
* @param sessionId The session ID
* @return The session object (implementation-specific return type)
*/
Object getSession(String sessionId);
/**
* Lists all sessions for a user
*
* @param username The username
* @return List of session summaries (implementation-specific return type)
*/
List<?> listUserSessions(String username);
/**
* Gets detailed session information with ownership validation
*
* @param sessionId The session ID
* @param username The username for ownership validation
* @return Detailed session object (implementation-specific return type)
*/
Object getSessionDetail(String sessionId, String username);
/**
* Deletes a session
*
* @param sessionId The session ID
* @param username The username for ownership validation
*/
void deleteSession(String sessionId, String username);
/**
* Adds participants to a session
*
* @param sessionId The session ID
* @param request The request containing participant emails and names
* @param username The username for ownership validation
* @return Updated session detail (implementation-specific return type)
*/
Object addParticipants(String sessionId, Object request, String username);
/**
* Removes a participant from a session
*
* @param sessionId The session ID
* @param userId The participant's user ID
* @param username The username for ownership validation
*/
void removeParticipant(String sessionId, Long userId, String username);
/**
* Notifies participants
*
* @param sessionId The session ID
* @param request The notification request
* @return The updated session (implementation-specific return type)
*/
Object notifyParticipants(String sessionId, Object request);
/**
* Attaches a certificate for a participant
*
* @param sessionId The session ID
* @param userId The participant's user ID
* @param request The certificate request
* @return The updated session (implementation-specific return type)
*/
Object attachCertificate(String sessionId, Long userId, Object request) throws IOException;
/**
* Marks a session as finalized
*
* @param sessionId The session ID
* @param signedPdf The final signed PDF bytes
*/
void markSessionFinalized(String sessionId, byte[] signedPdf);
/**
* Gets the PDF for a session with user authentication
*
* @param sessionId The session ID
* @param username The participant's username
* @return The PDF bytes
*/
byte[] getSessionPdf(String sessionId, String username);
/**
* Gets the signed PDF from a finalized session with ownership validation
*
* @param sessionId The session ID
* @param username The username for ownership validation
* @return The signed PDF bytes, or null if not finalized
*/
byte[] getSignedPdf(String sessionId, String username);
/**
* Lists sign requests for a participant
*
* @param username The participant's username
* @return List of sign requests where user is a participant
*/
List<?> listSignRequests(String username);
/**
* Gets sign request detail for a participant
*
* @param sessionId The session ID
* @param username The participant's username
* @return Sign request detail DTO (implementation-specific return type)
*/
Object getSignRequestDetail(String sessionId, String username);
/**
* Declines a sign request
*
* @param sessionId The session ID
* @param username The participant's username
*/
void declineSignRequest(String sessionId, String username);
/**
* Signs a document with optional wet signature metadata
*
* @param sessionId The session ID
* @param username The participant's username
* @param request The sign document request containing certificate and wet signature data
* @throws IOException if certificate processing fails
*/
void signDocument(String sessionId, String username, Object request) throws IOException;
/**
* Checks if this is the database-backed implementation
*
* @return true if database-backed, false if in-memory
*/
boolean isDatabaseBacked();
}
@@ -51,7 +51,8 @@ public class RequestUriUtilsTest {
@Test
void testIsFrontendRoute() {
assertTrue(RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
assertTrue(
RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
assertTrue(
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
"React routes without extensions should be frontend routes");
@@ -95,7 +95,7 @@ public class InitialSetup {
isNewServer =
existingVersion == null
|| existingVersion.isEmpty()
|| existingVersion.equals("0.0.0");
|| "0.0.0".equals(existingVersion);
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");
@@ -72,6 +72,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.MultiSignPDFWithCertRequest;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -113,7 +114,7 @@ public class CertSignController {
this.serverCertificateService = serverCertificateService;
}
private static void sign(
static void sign(
CustomPDFDocumentFactory pdfDocumentFactory,
MultipartFile input,
OutputStream output,
@@ -149,6 +150,42 @@ public class CertSignController {
}
}
static void sign(
CustomPDFDocumentFactory pdfDocumentFactory,
InputStream input,
OutputStream output,
CreateSignature instance,
Boolean showSignature,
Integer pageNumber,
String name,
String location,
String reason,
Boolean showLogo) {
try (PDDocument doc = pdfDocumentFactory.load(input)) {
PDSignature signature = new PDSignature();
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
signature.setSubFilter(PDSignature.SUBFILTER_ADBE_PKCS7_DETACHED);
signature.setName(name);
signature.setLocation(location);
signature.setReason(reason);
signature.setSignDate(Calendar.getInstance());
if (Boolean.TRUE.equals(showSignature)) {
SignatureOptions signatureOptions = new SignatureOptions();
signatureOptions.setVisualSignature(
instance.createVisibleSignature(doc, signature, pageNumber, showLogo));
signatureOptions.setPage(pageNumber);
doc.addSignature(signature, instance, signatureOptions);
} else {
doc.addSignature(signature, instance);
}
doc.saveIncremental(output);
} catch (Exception e) {
ExceptionUtils.logException("PDF signing", e);
}
}
@AutoJobPostMapping(
consumes = {
MediaType.MULTIPART_FORM_DATA_VALUE,
@@ -251,7 +288,143 @@ public class CertSignController {
GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_signed.pdf"));
}
private PrivateKey getPrivateKeyFromPEM(byte[] pemBytes, String password)
@AutoJobPostMapping(
consumes = {
MediaType.MULTIPART_FORM_DATA_VALUE,
MediaType.APPLICATION_FORM_URLENCODED_VALUE
},
value = "/cert-sign/multi")
@StandardPdfResponse
@Operation(
summary = "Sign PDF with multiple Digital Certificates",
description =
"This endpoint accepts a PDF file and multiple digital certificates to"
+ " sequentially sign the document. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> multiSignPDFWithCert(
@ModelAttribute MultiSignPDFWithCertRequest request) throws Exception {
if (request.getCertTypes() == null || request.getCertTypes().isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.optionsNotSpecified",
"{0} options are not specified",
"certificate types");
}
byte[] currentPdf = request.getFileInput().getBytes();
String originalFilename = request.getFileInput().getOriginalFilename();
for (int i = 0; i < request.getCertTypes().size(); i++) {
String certType = request.getCertTypes().get(i);
KeyStore ks = null;
String keystorePassword = getFromList(request.getPasswords(), i, "");
switch (certType) {
case "PEM":
ks = KeyStore.getInstance("JKS");
ks.load(null);
MultipartFile privateKeyFile =
getFromList(request.getPrivateKeyFiles(), i, null);
MultipartFile certFile = getFromList(request.getCertFiles(), i, null);
if (privateKeyFile == null || certFile == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.optionsNotSpecified",
"{0} options are not specified",
"certificate and key files for signer " + (i + 1));
}
PrivateKey privateKey =
getPrivateKeyFromPEM(privateKeyFile.getBytes(), keystorePassword);
Certificate cert = (Certificate) getCertificateFromPEM(certFile.getBytes());
ks.setKeyEntry(
"alias",
privateKey,
keystorePassword.toCharArray(),
new Certificate[] {cert});
break;
case "PKCS12":
case "PFX":
MultipartFile p12File = getFromList(request.getP12Files(), i, null);
if (p12File == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.optionsNotSpecified",
"{0} options are not specified",
"PKCS12/PFX keystore for signer " + (i + 1));
}
ks = KeyStore.getInstance("PKCS12");
ks.load(p12File.getInputStream(), keystorePassword.toCharArray());
break;
case "JKS":
MultipartFile jksfile = getFromList(request.getJksFiles(), i, null);
if (jksfile == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.optionsNotSpecified",
"{0} options are not specified",
"JKS keystore for signer " + (i + 1));
}
ks = KeyStore.getInstance("JKS");
ks.load(jksfile.getInputStream(), keystorePassword.toCharArray());
break;
case "SERVER":
if (serverCertificateService == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.serverCertificateNotAvailable",
"Server certificate service is not available in this edition");
}
if (!serverCertificateService.isEnabled()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.serverCertificateDisabled",
"Server certificate feature is disabled");
}
if (!serverCertificateService.hasServerCertificate()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.serverCertificateNotFound",
"No server certificate configured");
}
ks = serverCertificateService.getServerKeyStore();
keystorePassword = serverCertificateService.getServerCertificatePassword();
break;
default:
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
"certificate type: " + certType);
}
CreateSignature createSignature =
new CreateSignature(ks, keystorePassword.toCharArray());
ByteArrayOutputStream baos = new ByteArrayOutputStream();
String signerName = getFromList(request.getNames(), i, "SPDF");
String location = getFromList(request.getLocations(), i, "SPDF");
String reason = getFromList(request.getReasons(), i, "Signed by SPDF");
Boolean showSignature = getFromList(request.getShowSignatures(), i, Boolean.FALSE);
Boolean showLogo = getFromList(request.getShowLogos(), i, Boolean.TRUE);
Integer pageNumber = getFromList(request.getPageNumbers(), i, null);
pageNumber = pageNumber != null ? pageNumber - 1 : null;
sign(
pdfDocumentFactory,
new ByteArrayInputStream(currentPdf),
baos,
createSignature,
showSignature,
pageNumber,
signerName,
location,
reason,
showLogo);
currentPdf = baos.toByteArray();
}
return WebResponseUtils.bytesToWebResponse(
currentPdf, GeneralUtils.generateFilename(originalFilename, "_multi_signed.pdf"));
}
private static <T> T getFromList(List<T> list, int index, T defaultValue) {
if (list == null || list.size() <= index) {
return defaultValue;
}
return list.get(index);
}
public PrivateKey getPrivateKeyFromPEM(byte[] pemBytes, String password)
throws IOException, OperatorCreationException, PKCSException {
try (PEMParser pemParser =
new PEMParser(new InputStreamReader(new ByteArrayInputStream(pemBytes)))) {
@@ -273,14 +446,14 @@ public class CertSignController {
}
}
private Certificate getCertificateFromPEM(byte[] pemBytes)
public Certificate getCertificateFromPEM(byte[] pemBytes)
throws IOException, CertificateException {
try (ByteArrayInputStream bis = new ByteArrayInputStream(pemBytes)) {
return CertificateFactory.getInstance("X.509").generateCertificate(bis);
}
}
class CreateSignature extends CreateSignatureBase {
static class CreateSignature extends CreateSignatureBase {
File logoFile;
public CreateSignature(KeyStore keystore, char[] pin)
@@ -0,0 +1,678 @@
package stirling.software.SPDF.controller.api.security;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.security.KeyStore;
import java.security.Principal;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.util.List;
import java.util.Locale;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotBlank;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.controller.api.security.CertSignController.CreateSignature;
import stirling.software.SPDF.service.SigningSessionService;
import stirling.software.common.model.api.security.*;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.SigningSessionServiceInterface;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.service.UserServerCertificateService;
@Slf4j
@RestController
@RequestMapping("/api/v1/security")
@Tag(name = "Security", description = "Security APIs")
public class SigningSessionController {
private final SigningSessionService signingSessionService;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ServerCertificateServiceInterface serverCertificateServiceInterface;
private final SigningSessionServiceInterface sessionServiceInterface;
private final UserServerCertificateService userServerCertificateService;
public SigningSessionController(
SigningSessionService signingSessionService,
CustomPDFDocumentFactory pdfDocumentFactory,
@Autowired(required = false)
ServerCertificateServiceInterface serverCertificateServiceInterface,
@Autowired(required = false)
List<SigningSessionServiceInterface> signingSessionServices,
@Autowired(required = false)
UserServerCertificateService userServerCertificateService) {
this.signingSessionService = signingSessionService;
this.pdfDocumentFactory = pdfDocumentFactory;
this.serverCertificateServiceInterface = serverCertificateServiceInterface;
this.userServerCertificateService = userServerCertificateService;
// Use database-backed service if available, otherwise fall back to in-memory
this.sessionServiceInterface =
signingSessionServices != null && !signingSessionServices.isEmpty()
? signingSessionServices.stream()
.filter(SigningSessionServiceInterface::isDatabaseBacked)
.findFirst()
.orElse(signingSessionService)
: signingSessionService;
}
@Operation(summary = "List all signing sessions for current user")
@GetMapping(value = "/cert-sign/sessions")
public ResponseEntity<?> listSessions(Principal principal) {
if (principal == null || !sessionServiceInterface.isDatabaseBacked()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
List<?> sessions = sessionServiceInterface.listUserSessions(principal.getName());
return ResponseEntity.ok(sessions);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Error listing sessions");
}
}
@PostMapping(
consumes = {
MediaType.MULTIPART_FORM_DATA_VALUE,
MediaType.APPLICATION_FORM_URLENCODED_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(
@ModelAttribute CreateSigningSessionRequest request, Principal principal)
throws Exception {
if (sessionServiceInterface.isDatabaseBacked() && principal != null) {
Object session = sessionServiceInterface.createSession(request, principal.getName());
return ResponseEntity.ok(session);
} else {
SigningSession session = signingSessionService.createSession(request);
return ResponseEntity.ok(session);
}
}
@Operation(summary = "Fetch signing session details")
@GetMapping(value = "/cert-sign/sessions/{sessionId}")
public ResponseEntity<?> getSession(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
if (sessionServiceInterface.isDatabaseBacked() && principal != null) {
try {
Object session =
sessionServiceInterface.getSessionDetail(sessionId, principal.getName());
return ResponseEntity.ok(session);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Access denied or session not found");
}
} else {
SigningSession session = signingSessionService.getSession(sessionId);
return ResponseEntity.ok(session);
}
}
@Operation(summary = "Delete a signing session")
@DeleteMapping(value = "/cert-sign/sessions/{sessionId}")
public ResponseEntity<?> deleteSession(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
if (principal == null || !sessionServiceInterface.isDatabaseBacked()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
sessionServiceInterface.deleteSession(sessionId, principal.getName());
return ResponseEntity.noContent().build();
} catch (Exception 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 AddParticipantsRequest request,
Principal principal) {
if (principal == null || !sessionServiceInterface.isDatabaseBacked()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
Object session =
sessionServiceInterface.addParticipants(
sessionId, request, principal.getName());
return ResponseEntity.ok(session);
} catch (Exception 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/{userId}")
public ResponseEntity<?> removeParticipant(
@PathVariable("sessionId") @NotBlank String sessionId,
@PathVariable("userId") Long userId,
Principal principal) {
if (principal == null || !sessionServiceInterface.isDatabaseBacked()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
sessionServiceInterface.removeParticipant(sessionId, userId, principal.getName());
return ResponseEntity.noContent().build();
} catch (Exception 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<byte[]> getSessionPdf(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
if (principal == null || !sessionServiceInterface.isDatabaseBacked()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
byte[] pdfBytes = sessionServiceInterface.getSessionPdf(sessionId, principal.getName());
return WebResponseUtils.bytesToWebResponse(pdfBytes, "document.pdf");
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
@PostMapping(value = "/cert-sign/sessions/{sessionId}/notify")
@Operation(summary = "Notify signing participants about outstanding requests")
public SigningSession notifyParticipants(
@PathVariable("sessionId") @NotBlank String sessionId,
@RequestBody NotifySigningParticipantsRequest request) {
return (SigningSession) sessionServiceInterface.notifyParticipants(sessionId, request);
}
@PostMapping(
value = "/cert-sign/sessions/{sessionId}/participants/{userId}/certificate",
consumes = {
MediaType.MULTIPART_FORM_DATA_VALUE,
MediaType.APPLICATION_FORM_URLENCODED_VALUE
})
@Operation(summary = "Attach certificate details for a specific participant")
public SigningSession attachCertificate(
@PathVariable("sessionId") @NotBlank String sessionId,
@PathVariable("userId") Long userId,
@ModelAttribute ParticipantCertificateRequest request)
throws Exception {
return (SigningSession)
sessionServiceInterface.attachCertificate(sessionId, userId, request);
}
@Operation(summary = "Get signed PDF from finalized session")
@GetMapping(value = "/cert-sign/sessions/{sessionId}/signed-pdf")
@StandardPdfResponse
public ResponseEntity<byte[]> getSignedPdf(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
if (principal == null || !sessionServiceInterface.isDatabaseBacked()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
byte[] signedPdf = sessionServiceInterface.getSignedPdf(sessionId, principal.getName());
if (signedPdf == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Session not finalized".getBytes());
}
SigningSession session = (SigningSession) sessionServiceInterface.getSession(sessionId);
return WebResponseUtils.bytesToWebResponse(
signedPdf,
GeneralUtils.generateFilename(session.getDocumentName(), "_shared_signed.pdf"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
@PostMapping(value = "/cert-sign/sessions/{sessionId}/finalize")
@Operation(
summary = "Finalize signing session",
description =
"Applies collected certificates in order and returns the signed document.")
@StandardPdfResponse
public ResponseEntity<byte[]> finalizeSession(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal)
throws Exception {
// Validate ownership if database service is available
if (sessionServiceInterface.isDatabaseBacked() && principal != null) {
try {
sessionServiceInterface.getSessionDetail(sessionId, principal.getName());
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
SigningSession session = (SigningSession) sessionServiceInterface.getSession(sessionId);
byte[] pdf = session.getOriginalPdf();
// Step 1: Apply wet signatures (visual annotations) FIRST
if (sessionServiceInterface.isDatabaseBacked()) {
try {
pdf = applyWetSignatures(pdf, sessionId);
} catch (Exception e) {
log.error(
"Failed to apply wet signatures for session {}: {}",
sessionId,
e.getMessage());
// Continue with certificate signing even if wet signatures fail
}
}
// Step 2: Apply digital certificates
for (SigningParticipant participant : session.getParticipants()) {
ParticipantCertificateSubmission submission = participant.getCertificateSubmission();
if (submission == null || participant.getStatus() != ParticipantStatus.SIGNED) {
continue;
}
// Skip SERVER certificate type if feature is not available/enabled
if ("SERVER".equalsIgnoreCase(submission.getCertType())) {
if (serverCertificateServiceInterface == null
|| !serverCertificateServiceInterface.isEnabled()
|| !serverCertificateServiceInterface.hasServerCertificate()) {
// Skip this participant - server certificate not available
continue;
}
}
// Handle USER_CERT type - auto-generate if needed
if ("USER_CERT".equalsIgnoreCase(submission.getCertType())) {
if (userServerCertificateService == null
|| !sessionServiceInterface.isDatabaseBacked()) {
log.warn(
"USER_CERT requested but service not available, skipping participant: {}",
participant.getEmail());
continue;
}
}
KeyStore keystore = buildKeystore(submission, participant);
boolean usingServer = "SERVER".equalsIgnoreCase(submission.getCertType());
boolean usingUserCert = "USER_CERT".equalsIgnoreCase(submission.getCertType());
String password;
if (usingServer && serverCertificateServiceInterface != null) {
password = serverCertificateServiceInterface.getServerCertificatePassword();
} else if (usingUserCert && userServerCertificateService != null) {
password = submission.getPassword(); // Password stored in submission for user cert
} else {
password = submission.getPassword();
}
CreateSignature createSignature =
new CreateSignature(
keystore, password != null ? password.toCharArray() : new char[0]);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
CertSignController.sign(
pdfDocumentFactory,
new ByteArrayInputStream(pdf),
baos,
createSignature,
submission.getShowSignature(),
submission.getPageNumber() != null
? Math.max(submission.getPageNumber() - 1, 0)
: null,
StringUtils.defaultIfBlank(participant.getName(), "Shared Signing"),
StringUtils.defaultIfBlank(submission.getLocation(), ""),
StringUtils.defaultIfBlank(submission.getReason(), "Document Signing"),
submission.getShowLogo());
pdf = baos.toByteArray();
}
session.setSignedPdf(pdf);
// Mark session as finalized in database if database service is available
sessionServiceInterface.markSessionFinalized(sessionId, pdf);
// Step 3: Clean up wet signature metadata (GDPR compliance)
if (sessionServiceInterface.isDatabaseBacked()) {
try {
clearWetSignatureMetadata(sessionId);
} catch (Exception e) {
log.error(
"Failed to clear wet signature metadata for session {}: {}",
sessionId,
e.getMessage());
// Don't fail the finalization if cleanup fails
}
}
return WebResponseUtils.bytesToWebResponse(
pdf,
GeneralUtils.generateFilename(session.getDocumentName(), "_shared_signed.pdf"));
}
private KeyStore buildKeystore(
ParticipantCertificateSubmission submission, SigningParticipant participant)
throws Exception {
CertSignController certSignController =
new CertSignController(pdfDocumentFactory, serverCertificateServiceInterface);
String certType = submission.getCertType().toUpperCase(Locale.ROOT);
String password = submission.getPassword();
switch (certType) {
case "USER_CERT":
if (userServerCertificateService == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.userCertificateNotAvailable",
"User certificate service is not available in this edition");
}
// Get user ID from participant
Long userId = getUserIdFromParticipant(participant);
if (userId == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.userNotFound", "Cannot determine user ID for participant");
}
// Auto-generate certificate if user doesn't have one
try {
userServerCertificateService.getOrCreateUserCertificate(userId);
KeyStore userKeyStore = userServerCertificateService.getUserKeyStore(userId);
String userPassword =
userServerCertificateService.getUserKeystorePassword(userId);
// Store password in submission for later use
submission.setPassword(userPassword);
return userKeyStore;
} catch (Exception e) {
log.error("Failed to get/create user certificate for user {}", userId, e);
throw ExceptionUtils.createIllegalArgumentException(
"error.userCertificateFailure",
"Failed to get user certificate: " + e.getMessage());
}
case "PEM":
KeyStore pemStore = KeyStore.getInstance("JKS");
pemStore.load(null);
if (submission.getPrivateKey() == null || submission.getCertificate() == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.optionsNotSpecified",
"{0} options are not specified",
"PEM certificate and key bytes for signer");
}
PrivateKey privateKey =
certSignController.getPrivateKeyFromPEM(
submission.getPrivateKey(), password);
Certificate certificate =
(Certificate)
certSignController.getCertificateFromPEM(
submission.getCertificate());
pemStore.setKeyEntry(
"alias",
privateKey,
password.toCharArray(),
new Certificate[] {certificate});
return pemStore;
case "PKCS12":
case "PFX":
if (submission.getP12Keystore() == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.optionsNotSpecified",
"{0} options are not specified",
"PKCS12 keystore bytes");
}
KeyStore p12Store = KeyStore.getInstance("PKCS12");
p12Store.load(
new ByteArrayInputStream(submission.getP12Keystore()),
password.toCharArray());
return p12Store;
case "JKS":
if (submission.getJksKeystore() == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.optionsNotSpecified",
"{0} options are not specified",
"JKS keystore bytes");
}
KeyStore jksStore = KeyStore.getInstance("JKS");
jksStore.load(
new ByteArrayInputStream(submission.getJksKeystore()),
password.toCharArray());
return jksStore;
case "SERVER":
if (serverCertificateServiceInterface == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.serverCertificateNotAvailable",
"Server certificate service is not available in this edition");
}
if (!serverCertificateServiceInterface.isEnabled()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.serverCertificateDisabled",
"Server certificate feature is disabled");
}
if (!serverCertificateServiceInterface.hasServerCertificate()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.serverCertificateNotFound", "No server certificate configured");
}
return serverCertificateServiceInterface.getServerKeyStore();
default:
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Invalid argument: {0}",
"certificate type: " + submission.getCertType());
}
}
/**
* Applies wet signatures (visual annotations) to the PDF. This must be done BEFORE applying
* digital certificates.
*
* @param pdfBytes Original PDF bytes
* @param sessionId Session ID
* @return PDF bytes with wet signatures overlaid
* @throws Exception if PDF processing fails
*/
private byte[] applyWetSignatures(byte[] pdfBytes, String sessionId) throws Exception {
// Cast to database service to access wet signature methods
if (!(sessionServiceInterface
instanceof
stirling.software.proprietary.security.service.DatabaseSigningSessionService)) {
return pdfBytes; // Skip if not database service
}
stirling.software.proprietary.security.service.DatabaseSigningSessionService dbService =
(stirling.software.proprietary.security.service.DatabaseSigningSessionService)
sessionServiceInterface;
List<WetSignatureMetadata> wetSignatures = dbService.getAllWetSignatures(sessionId);
if (wetSignatures.isEmpty()) {
return pdfBytes; // No wet signatures to apply
}
// Load PDF document
org.apache.pdfbox.pdmodel.PDDocument document =
pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes));
try {
for (WetSignatureMetadata wetSig : wetSignatures) {
applyWetSignatureToPage(document, wetSig);
}
// Save modified PDF
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return baos.toByteArray();
} finally {
document.close();
}
}
/**
* Applies a single wet signature to the appropriate page of the PDF.
*
* @param document PDF document
* @param wetSig Wet signature metadata
* @throws Exception if image processing or PDF manipulation fails
*/
private void applyWetSignatureToPage(
org.apache.pdfbox.pdmodel.PDDocument document, WetSignatureMetadata wetSig)
throws Exception {
if (wetSig.getPage() >= document.getNumberOfPages()) {
log.warn(
"Wet signature page {} exceeds document pages {}, skipping",
wetSig.getPage(),
document.getNumberOfPages());
return;
}
org.apache.pdfbox.pdmodel.PDPage page = document.getPage(wetSig.getPage());
org.apache.pdfbox.pdmodel.PDPageContentStream contentStream =
new org.apache.pdfbox.pdmodel.PDPageContentStream(
document,
page,
org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode.APPEND,
true,
true);
try {
// Extract base64 data (remove data:image/png;base64, prefix if present)
String base64Data = wetSig.extractBase64Data();
byte[] imageBytes = java.util.Base64.getDecoder().decode(base64Data);
// Create PDImageXObject from bytes
org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject image =
org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject.createFromByteArray(
document, imageBytes, "signature");
// Convert Y coordinate from UI (top-left) to PDF (bottom-left) coordinate system
float pdfY =
page.getMediaBox().getHeight()
- wetSig.getY().floatValue()
- wetSig.getHeight().floatValue();
// Draw image at specified position
contentStream.drawImage(
image,
wetSig.getX().floatValue(),
pdfY,
wetSig.getWidth().floatValue(),
wetSig.getHeight().floatValue());
} finally {
contentStream.close();
}
}
/**
* Clears wet signature metadata from all participants. Called after successful finalization for
* GDPR compliance.
*
* @param sessionId Session ID
*/
private void clearWetSignatureMetadata(String sessionId) {
if (sessionServiceInterface
instanceof
stirling.software.proprietary.security.service.DatabaseSigningSessionService) {
stirling.software.proprietary.security.service.DatabaseSigningSessionService dbService =
(stirling.software.proprietary.security.service.DatabaseSigningSessionService)
sessionServiceInterface;
dbService.clearWetSignatureMetadata(sessionId);
}
}
/**
* Helper method to get user ID from participant. Returns null if not available (e.g., in-memory
* sessions).
*
* @param participant The signing participant
* @return User ID or null
*/
private Long getUserIdFromParticipant(SigningParticipant participant) {
return participant.getUserId();
}
@Operation(summary = "List sign requests for authenticated user")
@GetMapping(value = "/cert-sign/sign-requests")
public ResponseEntity<?> listSignRequests(Principal principal) {
if (principal == null || !sessionServiceInterface.isDatabaseBacked()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
return ResponseEntity.ok(sessionServiceInterface.listSignRequests(principal.getName()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Cannot list sign requests: " + e.getMessage());
}
}
@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) {
if (principal == null || !sessionServiceInterface.isDatabaseBacked()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
return ResponseEntity.ok(
sessionServiceInterface.getSignRequestDetail(sessionId, principal.getName()));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Access denied or sign request not found: " + 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) {
if (principal == null || !sessionServiceInterface.isDatabaseBacked()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
sessionServiceInterface.declineSignRequest(sessionId, principal.getName());
return ResponseEntity.noContent().build();
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Cannot decline sign request: " + e.getMessage());
}
}
@Operation(
summary = "Sign a document with optional wet signature",
description =
"Submits certificate and optional wet signature annotation metadata for a signing session")
@PostMapping(
value = "/cert-sign/sessions/{sessionId}/sign",
consumes = {
MediaType.MULTIPART_FORM_DATA_VALUE,
MediaType.APPLICATION_FORM_URLENCODED_VALUE
})
public ResponseEntity<?> signDocument(
@PathVariable("sessionId") @NotBlank String sessionId,
@ModelAttribute SignDocumentRequest request,
Principal principal) {
if (principal == null || !sessionServiceInterface.isDatabaseBacked()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
sessionServiceInterface.signDocument(sessionId, principal.getName(), request);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Cannot sign document: " + e.getMessage());
}
}
}
@@ -6,12 +6,14 @@ import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ReactRoutingController {
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
public String forwardRootPaths() {
return "forward:/index.html";
}
@GetMapping("/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public String forwardNestedPaths() {
return "forward:/index.html";
}
@@ -0,0 +1,68 @@
package stirling.software.SPDF.model.api.security;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class MultiSignPDFWithCertRequest extends PDFFile {
@Schema(
description = "The list of certificate types for each signer",
allowableValues = {"PEM", "PKCS12", "PFX", "JKS", "SERVER"},
requiredMode = Schema.RequiredMode.REQUIRED)
private List<String> certTypes;
@Schema(
description =
"Private key files for PEM certificates (supports .pem, .der, or .key files)."
+ " Should match the order of certTypes when PEM is selected")
private List<MultipartFile> privateKeyFiles;
@Schema(
description =
"Certificate files for PEM certificates (supports .pem, .der, .crt, or .cer files)."
+ " Should match the order of certTypes when PEM is selected")
private List<MultipartFile> certFiles;
@Schema(
description =
"PKCS12/PFX keystore files. Should match the order of certTypes when PKCS12 or PFX is selected")
private List<MultipartFile> p12Files;
@Schema(
description =
"JKS keystore files. Should match the order of certTypes when JKS is selected")
private List<MultipartFile> jksFiles;
@Schema(description = "Passwords for keystores or private keys", format = "password")
private List<String> passwords;
@Schema(description = "Whether to visually show each signature in the PDF")
private List<Boolean> showSignatures;
@Schema(description = "Reasons for signing, aligned with certTypes")
private List<String> reasons;
@Schema(description = "Locations for signing, aligned with certTypes")
private List<String> locations;
@Schema(description = "Signer names, aligned with certTypes")
private List<String> names;
@Schema(
description =
"Page numbers for visible signatures (1-indexed). Required when showSignature is true for a signer")
private List<Integer> pageNumbers;
@Schema(description = "Whether to show a signature logo for each signer")
private List<Boolean> showLogos;
}
@@ -0,0 +1,214 @@
package stirling.software.SPDF.service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.model.api.security.CreateSigningSessionRequest;
import stirling.software.common.model.api.security.NotifySigningParticipantsRequest;
import stirling.software.common.model.api.security.ParticipantCertificateRequest;
import stirling.software.common.model.api.security.ParticipantCertificateSubmission;
import stirling.software.common.model.api.security.ParticipantStatus;
import stirling.software.common.model.api.security.SigningParticipant;
import stirling.software.common.model.api.security.SigningSession;
import stirling.software.common.service.SigningSessionServiceInterface;
import stirling.software.common.util.ExceptionUtils;
@Service
public class SigningSessionService implements SigningSessionServiceInterface {
private final Map<String, SigningSession> sessions = new ConcurrentHashMap<>();
public SigningSession createSession(CreateSigningSessionRequest request) throws IOException {
return createSession(request, null);
}
@Override
public SigningSession createSession(Object requestObj, String username) throws IOException {
// In-memory implementation not supported for user-based signing (requires database)
throw new UnsupportedOperationException(
"User-based signing requires database-backed implementation");
}
public SigningSession getSession(String sessionId) {
SigningSession session = sessions.get(sessionId);
if (session == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.notFound", "Signing session {0} was not found", sessionId);
}
return session;
}
@Override
public SigningSession notifyParticipants(String sessionId, Object requestObj) {
NotifySigningParticipantsRequest request = (NotifySigningParticipantsRequest) requestObj;
SigningSession session = getSession(sessionId);
List<SigningParticipant> targets = getTargets(session, request.getParticipantEmails());
String message =
StringUtils.defaultIfBlank(
request.getMessage(), "A reminder to review and sign the document.");
for (SigningParticipant participant : targets) {
participant.recordNotification(message);
if (participant.getStatus() == ParticipantStatus.PENDING) {
participant.setStatus(ParticipantStatus.NOTIFIED);
}
}
session.touch();
return session;
}
@Override
public SigningSession attachCertificate(String sessionId, Long userId, Object requestObj)
throws IOException {
// In-memory implementation doesn't support user-based participants
throw new UnsupportedOperationException(
"User-based signing not supported in non-database mode");
}
private void broadcastNotification(SigningSession session, String message) {
for (SigningParticipant participant : session.getParticipants()) {
participant.recordNotification(message);
if (participant.getStatus() == ParticipantStatus.PENDING) {
participant.setStatus(ParticipantStatus.NOTIFIED);
}
}
session.touch();
}
private ParticipantCertificateSubmission toSubmission(ParticipantCertificateRequest request)
throws IOException {
return ParticipantCertificateSubmission.builder()
.certType(request.getCertType())
.password(request.getPassword())
.privateKey(toBytes(request.getPrivateKeyFile()))
.certificate(toBytes(request.getCertFile()))
.p12Keystore(toBytes(request.getP12File()))
.jksKeystore(toBytes(request.getJksFile()))
.showSignature(request.getShowSignature())
.pageNumber(request.getPageNumber())
.name(request.getName())
.reason(request.getReason())
.location(request.getLocation())
.showLogo(request.getShowLogo())
.build();
}
private byte[] toBytes(MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
return null;
}
return file.getBytes();
}
private List<SigningParticipant> getTargets(
SigningSession session, List<String> requestedEmails) {
if (requestedEmails == null || requestedEmails.isEmpty()) {
return session.getParticipants();
}
List<SigningParticipant> participants = new ArrayList<>();
for (String email : requestedEmails) {
Optional<SigningParticipant> participant =
session.getParticipants().stream()
.filter(p -> Objects.equals(p.getEmail(), email))
.findFirst();
participant.ifPresent(participants::add);
}
return participants;
}
// Interface methods that are not supported by in-memory implementation
@Override
public List<?> listUserSessions(String username) {
// In-memory implementation doesn't support user filtering
return Collections.emptyList();
}
@Override
public Object getSessionDetail(String sessionId, String username) {
// In-memory implementation doesn't have separate detail view
return getSession(sessionId);
}
@Override
public void deleteSession(String sessionId, String username) {
// In-memory implementation doesn't support deletion
throw new UnsupportedOperationException(
"Session deletion not supported in non-database mode");
}
@Override
public Object addParticipants(String sessionId, Object request, String username) {
// In-memory implementation doesn't support adding participants
throw new UnsupportedOperationException(
"Adding participants not supported in non-database mode");
}
@Override
public void removeParticipant(String sessionId, Long userId, String username) {
// In-memory implementation doesn't support user-based participants
throw new UnsupportedOperationException(
"User-based signing not supported in non-database mode");
}
@Override
public void markSessionFinalized(String sessionId, byte[] signedPdf) {
SigningSession session = getSession(sessionId);
session.setSignedPdf(signedPdf);
}
@Override
public byte[] getSessionPdf(String sessionId, String username) {
// In-memory implementation doesn't support user authentication
throw new UnsupportedOperationException(
"User-based signing not supported in non-database mode");
}
@Override
public byte[] getSignedPdf(String sessionId, String username) {
throw new UnsupportedOperationException(
"getSignedPdf is only available in database-backed mode");
}
@Override
public List<?> listSignRequests(String username) {
// In-memory implementation doesn't support user-based sign requests
return Collections.emptyList();
}
@Override
public Object getSignRequestDetail(String sessionId, String username) {
// In-memory implementation doesn't support user-based signing
throw new UnsupportedOperationException(
"User-based signing not supported in non-database mode");
}
@Override
public void declineSignRequest(String sessionId, String username) {
// In-memory implementation doesn't support user-based signing
throw new UnsupportedOperationException(
"User-based signing not supported in non-database mode");
}
@Override
public void signDocument(String sessionId, String username, Object request) throws IOException {
// In-memory implementation doesn't support user-based signing with wet signatures
throw new UnsupportedOperationException(
"User-based signing not supported in non-database mode");
}
@Override
public boolean isDatabaseBacked() {
return false;
}
}
@@ -37,8 +37,11 @@ spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DE
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=false
spring.h2.console.enabled=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=error
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
# Defer datasource initialization to ensure that the database is fully set up
# before Hibernate attempts to access it. This is particularly useful when
# using database initialization scripts or tools.
@@ -65,3 +68,9 @@ java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
# V2 features
v2=true
# User personal certificates (per-user signing certificates)
system.userCertificates.enabled=true
system.userCertificates.autoGenerate=true
system.userCertificates.validity=365
system.userCertificates.keySize=2048
+1 -1
View File
@@ -57,7 +57,7 @@ dependencies {
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE'
api 'io.micrometer:micrometer-registry-prometheus'
implementation 'com.unboundid.product.scim2:scim2-sdk-client:4.0.0'
implementation 'com.unboundid.product.scim2:scim2-sdk-client:4.1.0'
api "io.jsonwebtoken:jjwt-api:$jwtVersion"
runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion"
@@ -0,0 +1,6 @@
package stirling.software.proprietary.model;
public enum CertificateType {
AUTO_GENERATED,
USER_UPLOADED
}
@@ -0,0 +1,80 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "participant_certificate_submissions")
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class ParticipantCertificateSubmissionEntity 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 = "participant_id", nullable = false, unique = true)
@JsonIgnore
private SigningParticipantEntity participant;
@Column(name = "cert_type", nullable = false)
private String certType;
@Column(name = "password")
private String password;
@Lob
@Column(name = "private_key", columnDefinition = "bytea")
private byte[] privateKey;
@Lob
@Column(name = "certificate", columnDefinition = "bytea")
private byte[] certificate;
@Lob
@Column(name = "p12_keystore", columnDefinition = "bytea")
private byte[] p12Keystore;
@Lob
@Column(name = "jks_keystore", columnDefinition = "bytea")
private byte[] jksKeystore;
@Column(name = "show_signature")
private Boolean showSignature;
@Column(name = "page_number")
private Integer pageNumber;
@Column(name = "name")
private String name;
@Column(name = "reason")
private String reason;
@Column(name = "location")
private String location;
@Column(name = "show_logo")
private Boolean showLogo;
@CreationTimestamp
@Column(name = "submitted_at", updatable = false)
private LocalDateTime submittedAt;
}
@@ -0,0 +1,124 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.UpdateTimestamp;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
import stirling.software.common.model.api.security.ParticipantStatus;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(name = "signing_participants")
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class SigningParticipantEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
@EqualsAndHashCode.Include
@ToString.Include
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "session_id", nullable = false)
@JsonIgnore
private SigningSessionEntity session;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = true)
@JsonIgnore
private User user;
@Column(name = "email")
private String email;
@Column(name = "name")
private String name;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private ParticipantStatus status = ParticipantStatus.PENDING;
@Column(name = "share_token", unique = true, length = 36)
@EqualsAndHashCode.Include
private String shareToken;
// Signature appearance settings (owner-controlled)
@Column(name = "show_signature")
private Boolean showSignature;
@Column(name = "page_number")
private Integer pageNumber;
@Column(name = "reason")
private String reason;
@Column(name = "location")
private String location;
@Column(name = "show_logo")
private Boolean showLogo;
// Wet signature metadata (visual signature placed by participant)
// This data is private to the participant and cleared after finalization
@Column(name = "wet_signature_type", length = 20)
private String wetSignatureType; // "canvas" | "image" | "text"
@Lob
@Column(name = "wet_signature_data", columnDefinition = "TEXT")
private String wetSignatureData; // Base64 image data or text
@Column(name = "wet_signature_page")
private Integer wetSignaturePage;
@Column(name = "wet_signature_x")
private Double wetSignatureX;
@Column(name = "wet_signature_y")
private Double wetSignatureY;
@Column(name = "wet_signature_width")
private Double wetSignatureWidth;
@Column(name = "wet_signature_height")
private Double wetSignatureHeight;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "participant_notifications",
joinColumns = @JoinColumn(name = "participant_id"))
@Column(name = "notification_message", columnDefinition = "text")
private List<String> notifications = new ArrayList<>();
@UpdateTimestamp
@Column(name = "last_updated")
private LocalDateTime lastUpdated;
@OneToOne(
mappedBy = "participant",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
@JsonIgnore
private ParticipantCertificateSubmissionEntity certificateSubmission;
public void recordNotification(String message) {
notifications.add(message);
lastUpdated = LocalDateTime.now();
}
}
@@ -0,0 +1,104 @@
package stirling.software.proprietary.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
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 = "signing_sessions")
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class SigningSessionEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
@EqualsAndHashCode.Include
@ToString.Include
private Long id;
@Column(name = "session_id", unique = true, nullable = false, length = 36)
@EqualsAndHashCode.Include
@ToString.Include
private String sessionId = UUID.randomUUID().toString();
@Column(name = "document_name", nullable = false)
private String documentName;
@Lob
@Basic(fetch = FetchType.EAGER)
@Column(name = "original_pdf", nullable = false, columnDefinition = "bytea")
@JsonIgnore
private byte[] originalPdf;
@Lob
@Basic(fetch = FetchType.EAGER)
@Column(name = "signed_pdf", columnDefinition = "bytea")
@JsonIgnore
private byte[] signedPdf;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@JsonIgnore
private User user;
@Column(name = "owner_email")
private String ownerEmail;
@Column(name = "message", columnDefinition = "text")
private String message;
@Column(name = "due_date")
private String dueDate;
@Column(name = "is_finalized", nullable = false)
private boolean finalized = false;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@OneToMany(
mappedBy = "session",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
@JsonIgnore
private List<SigningParticipantEntity> participants = new ArrayList<>();
public void addParticipant(SigningParticipantEntity participant) {
participants.add(participant);
participant.setSession(this);
}
public void removeParticipant(SigningParticipantEntity participant) {
participants.remove(participant);
participant.setSession(null);
}
public void touch() {
this.updatedAt = LocalDateTime.now();
}
}
@@ -0,0 +1,73 @@
package stirling.software.proprietary.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;
}
@@ -222,6 +222,11 @@ public class SecurityConfiguration {
request -> {
String uri = request.getRequestURI();
// Ignore CSRF for H2 console
if (uri.startsWith("/h2-console")) {
return true;
}
// Ignore CSRF for auth endpoints
if (uri.startsWith("/api/v1/auth/")) {
return true;
@@ -252,6 +257,9 @@ public class SecurityConfiguration {
.csrfTokenRequestHandler(requestHandler));
}
// Allow frames for H2 console
http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable()));
http.sessionManagement(
sessionManagement -> {
if (v2Enabled) {
@@ -308,7 +316,9 @@ public class SecurityConfiguration {
.alwaysRemember(false));
http.authorizeHttpRequests(
authz ->
authz.requestMatchers(
authz.requestMatchers("/h2-console/**")
.permitAll()
.requestMatchers(
req -> {
String uri = req.getRequestURI();
String contextPath = req.getContextPath();
@@ -0,0 +1,222 @@
package stirling.software.proprietary.security.controller.api;
import java.security.Principal;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.UserServerCertificateEntity;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.service.UserServerCertificateService;
@RestController
@RequestMapping("/api/v1/user/certificate")
@Slf4j
@Tag(name = "User Certificate", description = "APIs for user personal certificate management")
@RequiredArgsConstructor
public class UserCertificateController {
private final UserServerCertificateService userCertificateService;
private final UserRepository userRepository;
@GetMapping("/info")
@Operation(
summary = "Get user certificate information",
description = "Returns information about the current user's personal certificate")
public ResponseEntity<CertificateInfoResponse> getUserCertificateInfo(Principal principal) {
if (principal == null) {
return ResponseEntity.status(401).build();
}
try {
User user =
userRepository
.findByUsernameIgnoreCase(principal.getName())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
Optional<UserServerCertificateEntity> certOpt =
userCertificateService.getCertificateInfo(user.getId());
if (certOpt.isEmpty()) {
return ResponseEntity.ok(
new CertificateInfoResponse(
false, null, null, null, null, null, null, null));
}
UserServerCertificateEntity cert = certOpt.get();
return ResponseEntity.ok(
new CertificateInfoResponse(
true,
cert.getCertificateType().toString(),
cert.getSubjectDn(),
cert.getIssuerDn(),
cert.getValidFrom() != null ? cert.getValidFrom().toString() : null,
cert.getValidTo() != null ? cert.getValidTo().toString() : null,
cert.getCreatedAt() != null ? cert.getCreatedAt().toString() : null,
cert.getUpdatedAt() != null ? cert.getUpdatedAt().toString() : null));
} catch (Exception e) {
log.error("Failed to get user certificate info", e);
return ResponseEntity.internalServerError().build();
}
}
@PostMapping("/generate")
@Operation(
summary = "Generate new user certificate",
description = "Generate a new self-signed certificate for the current user")
public ResponseEntity<String> generateUserCertificate(Principal principal) {
if (principal == null) {
return ResponseEntity.status(401).build();
}
try {
User user =
userRepository
.findByUsernameIgnoreCase(principal.getName())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
// Delete existing if any
userCertificateService.deleteUserCertificate(user.getId());
// Generate new
userCertificateService.generateUserCertificate(user);
return ResponseEntity.ok("User certificate generated successfully");
} catch (Exception e) {
log.error("Failed to generate user certificate", e);
return ResponseEntity.internalServerError()
.body("Failed to generate certificate: " + e.getMessage());
}
}
@PostMapping("/upload")
@Operation(
summary = "Upload user certificate",
description =
"Upload a custom PKCS12 certificate file to be used as the user's personal certificate")
public ResponseEntity<String> uploadUserCertificate(
Principal principal,
@Parameter(description = "PKCS12 certificate file", required = true)
@RequestParam("file")
MultipartFile file,
@Parameter(description = "Certificate password", required = true)
@RequestParam("password")
String password) {
if (principal == null) {
return ResponseEntity.status(401).build();
}
if (file.isEmpty()) {
return ResponseEntity.badRequest().body("Certificate file cannot be empty");
}
if (!file.getOriginalFilename().toLowerCase().endsWith(".p12")
&& !file.getOriginalFilename().toLowerCase().endsWith(".pfx")) {
return ResponseEntity.badRequest()
.body("Only PKCS12 (.p12 or .pfx) files are supported");
}
try {
User user =
userRepository
.findByUsernameIgnoreCase(principal.getName())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
userCertificateService.uploadUserCertificate(user, file.getInputStream(), password);
return ResponseEntity.ok("User certificate uploaded successfully");
} catch (IllegalArgumentException e) {
log.warn("Invalid certificate upload: {}", e.getMessage());
return ResponseEntity.badRequest().body("Invalid certificate or password");
} catch (Exception e) {
log.error("Failed to upload user certificate", e);
return ResponseEntity.internalServerError().body("Failed to upload certificate");
}
}
@DeleteMapping
@Operation(
summary = "Delete user certificate",
description = "Delete the current user's personal certificate")
public ResponseEntity<String> deleteUserCertificate(Principal principal) {
if (principal == null) {
return ResponseEntity.status(401).build();
}
try {
User user =
userRepository
.findByUsernameIgnoreCase(principal.getName())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
userCertificateService.deleteUserCertificate(user.getId());
return ResponseEntity.ok("User certificate deleted successfully");
} catch (Exception e) {
log.error("Failed to delete user certificate", e);
return ResponseEntity.internalServerError().body("Failed to delete certificate");
}
}
@GetMapping("/download")
@Operation(
summary = "Download user certificate",
description =
"Download the user's public certificate in DER format for validation purposes")
public ResponseEntity<byte[]> downloadUserCertificate(Principal principal) {
if (principal == null) {
return ResponseEntity.status(401).build();
}
try {
User user =
userRepository
.findByUsernameIgnoreCase(principal.getName())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
if (!userCertificateService.hasUserCertificate(user.getId())) {
return ResponseEntity.notFound().build();
}
// Get the KeyStore and extract the public certificate
java.security.KeyStore keyStore = userCertificateService.getUserKeyStore(user.getId());
java.security.cert.X509Certificate cert =
(java.security.cert.X509Certificate)
keyStore.getCertificate(
keyStore.aliases().nextElement()); // Get first alias
byte[] certBytes = cert.getEncoded();
return ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"user-cert.cer\"")
.contentType(MediaType.valueOf("application/pkix-cert"))
.body(certBytes);
} catch (Exception e) {
log.error("Failed to download user certificate", e);
return ResponseEntity.internalServerError().build();
}
}
// DTO for certificate info response
public record CertificateInfoResponse(
boolean exists,
String type,
String subject,
String issuer,
String validFrom,
String validTo,
String createdAt,
String updatedAt) {}
}
@@ -27,6 +27,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;
@@ -782,4 +783,34 @@ public class UserController {
.body("Failed to complete initial setup");
}
}
/**
* List all enabled users for participant selection in signing sessions
*
* @param principal The authenticated user
* @return List of user summaries
*/
@GetMapping("/users")
public ResponseEntity<List<UserSummaryDTO>> listUsers(Principal principal) {
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
List<UserSummaryDTO> 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());
}
}
@@ -72,6 +72,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
return;
}
// Skip H2 console
if (request.getRequestURI().startsWith("/h2-console")) {
filterChain.doFilter(request, response);
return;
}
if (!apiKeyExists(request, response)) {
String jwtToken = jwtService.extractToken(request);
@@ -113,6 +113,12 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
if (authentication == null || !authentication.isAuthenticated()) {
String contextPath = request.getContextPath();
// Allow H2 console to pass through without authentication
if (requestURI.startsWith("/h2-console")) {
filterChain.doFilter(request, response);
return;
}
// Allow public auth endpoints to pass through without authentication
if (isPublicAuthEndpoint(requestURI, contextPath)) {
filterChain.doFilter(request, response);
@@ -0,0 +1,26 @@
package stirling.software.proprietary.security.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.model.SigningParticipantEntity;
@Repository
public interface SigningParticipantRepository
extends JpaRepository<SigningParticipantEntity, Long> {
Optional<SigningParticipantEntity> findByShareToken(String shareToken);
@Query(
"SELECT p FROM SigningParticipantEntity p WHERE p.session.sessionId = :sessionId AND p.email = :email")
Optional<SigningParticipantEntity> findBySessionIdAndEmail(
@Param("sessionId") String sessionId, @Param("email") String email);
@Query("SELECT p FROM SigningParticipantEntity p WHERE p.session.sessionId = :sessionId")
List<SigningParticipantEntity> findAllBySessionId(@Param("sessionId") String sessionId);
}
@@ -0,0 +1,43 @@
package stirling.software.proprietary.security.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.model.SigningSessionEntity;
import stirling.software.proprietary.security.model.User;
@Repository
public interface SigningSessionRepository extends JpaRepository<SigningSessionEntity, Long> {
Optional<SigningSessionEntity> findBySessionId(String sessionId);
@Query(
"SELECT s FROM SigningSessionEntity s WHERE s.user.id = :userId ORDER BY s.createdAt DESC")
List<SigningSessionEntity> findAllByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId);
@Query(
"SELECT s FROM SigningSessionEntity s LEFT JOIN FETCH s.participants WHERE s.sessionId = :sessionId")
Optional<SigningSessionEntity> findBySessionIdWithParticipants(
@Param("sessionId") String sessionId);
@Query(
"SELECT DISTINCT s FROM SigningSessionEntity s "
+ "LEFT JOIN FETCH s.participants p "
+ "LEFT JOIN FETCH p.certificateSubmission "
+ "WHERE s.sessionId = :sessionId")
Optional<SigningSessionEntity> findBySessionIdWithParticipantsAndCertificates(
@Param("sessionId") String sessionId);
@Query(
"SELECT s FROM SigningSessionEntity s WHERE s.user.id = :userId AND s.finalized = false ORDER BY s.createdAt DESC")
List<SigningSessionEntity> findActiveSessionsByUserId(@Param("userId") Long userId);
boolean existsBySessionId(String sessionId);
long countByUserAndFinalizedFalse(User user);
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.security.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.model.UserServerCertificateEntity;
@Repository
public interface UserServerCertificateRepository
extends JpaRepository<UserServerCertificateEntity, Long> {
@Query("SELECT c FROM UserServerCertificateEntity c WHERE c.user.id = :userId")
Optional<UserServerCertificateEntity> findByUserId(@Param("userId") Long userId);
@Query("SELECT c FROM UserServerCertificateEntity c WHERE c.user.username = :username")
Optional<UserServerCertificateEntity> findByUsername(@Param("username") String username);
boolean existsByUserId(Long userId);
}
@@ -0,0 +1,845 @@
package stirling.software.proprietary.security.service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.security.*;
import stirling.software.common.service.SigningSessionServiceInterface;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.proprietary.model.ParticipantCertificateSubmissionEntity;
import stirling.software.proprietary.model.SigningParticipantEntity;
import stirling.software.proprietary.model.SigningSessionEntity;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.SigningParticipantRepository;
import stirling.software.proprietary.security.repository.SigningSessionRepository;
@Service
@Slf4j
@RequiredArgsConstructor
public class DatabaseSigningSessionService implements SigningSessionServiceInterface {
private final SigningSessionRepository sessionRepository;
private final SigningParticipantRepository participantRepository;
private final UserService userService;
private final UserRepository userRepository;
@Override
@Transactional
public SigningSessionDetailDTO createSession(Object requestObj, String username)
throws IOException {
CreateSigningSessionRequest request = (CreateSigningSessionRequest) requestObj;
if (request.getParticipantUserIds() == null || request.getParticipantUserIds().isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.optionsNotSpecified",
"{0} options are not specified",
"participant user IDs");
}
User owner =
userService
.findByUsernameIgnoreCase(username)
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound", "User {0} not found", username));
byte[] pdfBytes = request.getFileInput().getBytes();
log.info(
"Creating session with PDF: {} bytes from file {}",
pdfBytes != null ? pdfBytes.length : 0,
request.getFileInput().getOriginalFilename());
if (pdfBytes == null || pdfBytes.length == 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Uploaded PDF is null or empty");
}
SigningSessionEntity session = new SigningSessionEntity();
session.setUser(owner);
session.setDocumentName(request.getFileInput().getOriginalFilename());
session.setOwnerEmail(request.getOwnerEmail());
session.setMessage(request.getMessage());
session.setDueDate(request.getDueDate());
session.setOriginalPdf(pdfBytes);
// Add participants by user ID
for (Long userId : request.getParticipantUserIds()) {
User participantUser =
userRepository
.findById(userId)
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound",
"User ID {0} not found",
userId));
SigningParticipantEntity participant = new SigningParticipantEntity();
participant.setUser(participantUser);
participant.setEmail(participantUser.getUsername()); // Keep for audit trail
participant.setName(participantUser.getUsername());
// Apply owner's signature appearance settings
participant.setShowSignature(request.getShowSignature());
participant.setPageNumber(request.getPageNumber());
participant.setReason(request.getReason());
participant.setLocation(request.getLocation());
participant.setShowLogo(request.getShowLogo());
session.addParticipant(participant);
}
if (Boolean.TRUE.equals(request.getNotifyOnCreate())) {
for (SigningParticipantEntity participant : session.getParticipants()) {
participant.recordNotification(
request.getMessage() != null
? request.getMessage()
: "You have been invited to sign a document.");
if (participant.getStatus() == ParticipantStatus.PENDING) {
participant.setStatus(ParticipantStatus.NOTIFIED);
}
}
}
session = sessionRepository.save(session);
return toDetailDTO(session);
}
@Override
@Transactional(readOnly = true)
public List<SigningSessionSummaryDTO> listUserSessions(String username) {
User user =
userService
.findByUsernameIgnoreCase(username)
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound", "User {0} not found", username));
List<SigningSessionEntity> sessions =
sessionRepository.findAllByUserIdOrderByCreatedAtDesc(user.getId());
return sessions.stream().map(this::toSummaryDTO).collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public SigningSessionDetailDTO getSessionDetail(String sessionId, String username) {
SigningSessionEntity session = getSessionEntityByIdWithOwnershipCheck(sessionId, username);
return toDetailDTO(session);
}
@Transactional(readOnly = true)
public SigningSession getSession(String sessionId) {
// Use query with participants and certificates fetch for finalization
SigningSessionEntity entity =
sessionRepository
.findBySessionIdWithParticipantsAndCertificates(sessionId)
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound",
"Signing session {0} was not found",
sessionId));
// Force LOB loading within transaction
byte[] originalPdf = entity.getOriginalPdf();
byte[] signedPdf = entity.getSignedPdf();
log.debug(
"Loading session {} for signing: originalPdf={} bytes, signedPdf={} bytes, participants={}",
sessionId,
originalPdf != null ? originalPdf.length : 0,
signedPdf != null ? signedPdf.length : 0,
entity.getParticipants().size());
if (originalPdf == null || originalPdf.length == 0) {
log.error("Original PDF is null or empty for session {}", sessionId);
throw ExceptionUtils.createIllegalArgumentException(
"error.notFound", "Original PDF not found for session {0}", sessionId);
}
return toSigningSession(entity);
}
public SigningSessionEntity getSessionEntityById(String sessionId) {
return sessionRepository
.findBySessionIdWithParticipants(sessionId)
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound",
"Signing session {0} was not found",
sessionId));
}
private SigningSessionEntity getSessionEntityByIdWithOwnershipCheck(
String sessionId, String username) {
SigningSessionEntity session = getSessionEntityById(sessionId);
validateSessionOwnership(session, username);
return session;
}
@Override
@Transactional
public void deleteSession(String sessionId, String username) {
SigningSessionEntity session = getSessionEntityByIdWithOwnershipCheck(sessionId, username);
if (session.isFinalized()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Cannot delete finalized session", sessionId);
}
sessionRepository.delete(session);
}
@Override
@Transactional
public SigningSessionDetailDTO addParticipants(
String sessionId, Object requestObj, String username) {
AddParticipantsRequest request = (AddParticipantsRequest) requestObj;
if (request.getParticipantUserIds() == null || request.getParticipantUserIds().isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.optionsNotSpecified",
"{0} options are not specified",
"participant user IDs");
}
SigningSessionEntity session = getSessionEntityByIdWithOwnershipCheck(sessionId, username);
// Add participants by user ID
for (Long userId : request.getParticipantUserIds()) {
User participantUser =
userRepository
.findById(userId)
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound",
"User ID {0} not found",
userId));
SigningParticipantEntity participant = new SigningParticipantEntity();
participant.setUser(participantUser);
participant.setEmail(participantUser.getUsername()); // Keep for audit trail
participant.setName(participantUser.getUsername());
// Copy signature settings from existing participants (or use defaults)
SigningParticipantEntity firstParticipant =
session.getParticipants().isEmpty() ? null : session.getParticipants().get(0);
if (firstParticipant != null) {
participant.setShowSignature(firstParticipant.getShowSignature());
participant.setPageNumber(firstParticipant.getPageNumber());
participant.setReason(firstParticipant.getReason());
participant.setLocation(firstParticipant.getLocation());
participant.setShowLogo(firstParticipant.getShowLogo());
}
session.addParticipant(participant);
}
session = sessionRepository.save(session);
return toDetailDTO(session);
}
@Override
@Transactional
public void removeParticipant(String sessionId, Long userId, String username) {
SigningSessionEntity session = getSessionEntityByIdWithOwnershipCheck(sessionId, username);
SigningParticipantEntity participant =
session.getParticipants().stream()
.filter(p -> p.getUser().getId().equals(userId))
.findFirst()
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound",
"Participant with user ID {0} not found",
userId));
if (participant.getStatus() == ParticipantStatus.SIGNED) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument",
"Cannot remove participant who has already signed",
userId);
}
session.removeParticipant(participant);
sessionRepository.save(session);
}
@Override
@Transactional
public SigningSession notifyParticipants(String sessionId, Object requestObj) {
NotifySigningParticipantsRequest request = (NotifySigningParticipantsRequest) requestObj;
SigningSessionEntity session = getSessionEntityById(sessionId);
List<SigningParticipantEntity> targets =
getTargetParticipants(session, request.getParticipantEmails());
String message =
StringUtils.defaultIfBlank(
request.getMessage(), "A reminder to review and sign the document.");
for (SigningParticipantEntity participant : targets) {
participant.recordNotification(message);
if (participant.getStatus() == ParticipantStatus.PENDING) {
participant.setStatus(ParticipantStatus.NOTIFIED);
}
}
session.touch();
session = sessionRepository.save(session);
return toSigningSession(session);
}
@Override
@Transactional
public SigningSession attachCertificate(String sessionId, Long userId, Object requestObj)
throws IOException {
ParticipantCertificateRequest request = (ParticipantCertificateRequest) requestObj;
SigningSessionEntity session = getSessionEntityById(sessionId);
SigningParticipantEntity participant =
session.getParticipants().stream()
.filter(p -> p.getUser().getId().equals(userId))
.findFirst()
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound",
"Participant with user ID {0} does not exist",
userId));
ParticipantCertificateSubmissionEntity submissionEntity =
toSubmissionEntity(request, participant);
participant.setCertificateSubmission(submissionEntity);
participant.setStatus(ParticipantStatus.SIGNED);
session.touch();
session = sessionRepository.save(session);
return toSigningSession(session);
}
@Override
@Transactional
public void markSessionFinalized(String sessionId, byte[] signedPdf) {
if (signedPdf == null || signedPdf.length == 0) {
log.error("Attempting to save null or empty signed PDF for session {}", sessionId);
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidArgument", "Signed PDF cannot be null or empty", sessionId);
}
SigningSessionEntity session =
sessionRepository
.findBySessionId(sessionId)
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound",
"Signing session {0} was not found",
sessionId));
log.info(
"Saving signed PDF for session {}: {} bytes",
sessionId,
signedPdf != null ? signedPdf.length : 0);
session.setSignedPdf(signedPdf);
session.setFinalized(true);
sessionRepository.saveAndFlush(session);
log.info("Signed PDF saved successfully for session {}", sessionId);
}
@Override
@Transactional(readOnly = true)
public byte[] getSessionPdf(String sessionId, String username) {
SigningSessionEntity session = getSessionEntityById(sessionId);
// Validate user is a participant in this session
boolean isParticipant =
session.getParticipants().stream()
.anyMatch(p -> p.getUser().getUsername().equalsIgnoreCase(username));
if (!isParticipant) {
throw ExceptionUtils.createIllegalArgumentException(
"error.unauthorized",
"User {0} is not a participant in session {1}",
username,
sessionId);
}
return session.getOriginalPdf();
}
@Override
@Transactional(readOnly = true)
public byte[] getSignedPdf(String sessionId, String username) {
// Use simple query to fetch session without joins for better LOB loading
SigningSessionEntity session =
sessionRepository
.findBySessionId(sessionId)
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound",
"Signing session {0} was not found",
sessionId));
validateSessionOwnership(session, username);
if (!session.isFinalized()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidState", "Session is not finalized", sessionId);
}
byte[] signedPdf = session.getSignedPdf();
if (signedPdf == null || signedPdf.length == 0) {
log.error("Signed PDF is null or empty for session {}", sessionId);
throw ExceptionUtils.createIllegalArgumentException(
"error.notFound", "Signed PDF not found for session {0}", sessionId);
}
return signedPdf;
}
@Override
@Transactional(readOnly = true)
public List<SignRequestSummaryDTO> listSignRequests(String username) {
User user =
userService
.findByUsernameIgnoreCase(username)
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound", "User {0} not found", username));
// Find all sessions where user is a participant
List<SigningSessionEntity> sessions =
sessionRepository.findAll().stream()
.filter(
session ->
session.getParticipants().stream()
.anyMatch(
p ->
p.getUser()
.getId()
.equals(user.getId())))
.sorted((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt()))
.collect(Collectors.toList());
return sessions.stream()
.map(session -> toSignRequestSummaryDTO(session, user.getId()))
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public SignRequestDetailDTO getSignRequestDetail(String sessionId, String username) {
SigningSessionEntity session = getSessionEntityById(sessionId);
User user =
userService
.findByUsernameIgnoreCase(username)
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound", "User {0} not found", username));
// Find participant matching user
SigningParticipantEntity participant =
session.getParticipants().stream()
.filter(p -> p.getUser().getId().equals(user.getId()))
.findFirst()
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.unauthorized",
"User {0} is not a participant in session {1}",
username,
sessionId));
String ownerUsername =
session.getUser() != null
? session.getUser().getUsername()
: session.getOwnerEmail();
return new SignRequestDetailDTO(
session.getSessionId(),
session.getDocumentName(),
ownerUsername,
session.getMessage(),
session.getDueDate(),
session.getCreatedAt().toString(),
participant.getStatus(),
participant.getShowSignature(),
participant.getPageNumber(),
participant.getReason(),
participant.getLocation(),
participant.getShowLogo());
}
@Override
@Transactional
public void declineSignRequest(String sessionId, String username) {
SigningSessionEntity session = getSessionEntityById(sessionId);
User user =
userService
.findByUsernameIgnoreCase(username)
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound", "User {0} not found", username));
// Find participant matching user
SigningParticipantEntity participant =
session.getParticipants().stream()
.filter(p -> p.getUser().getId().equals(user.getId()))
.findFirst()
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.unauthorized",
"User {0} is not a participant in session {1}",
username,
sessionId));
if (participant.getStatus() == ParticipantStatus.SIGNED) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidState",
"Cannot decline - participant has already signed",
sessionId);
}
participant.setStatus(ParticipantStatus.DECLINED);
session.touch();
sessionRepository.save(session);
}
@Override
@Transactional
public void signDocument(String sessionId, String username, Object request) throws IOException {
if (!(request instanceof SignDocumentRequest)) {
throw new IllegalArgumentException("Invalid request type");
}
SignDocumentRequest signRequest = (SignDocumentRequest) request;
SigningSessionEntity session = getSessionEntityById(sessionId);
User user =
userService
.findByUsernameIgnoreCase(username)
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.notFound", "User {0} not found", username));
// Find participant matching user
SigningParticipantEntity participant =
session.getParticipants().stream()
.filter(p -> p.getUser().getId().equals(user.getId()))
.findFirst()
.orElseThrow(
() ->
ExceptionUtils.createIllegalArgumentException(
"error.unauthorized",
"User {0} is not a participant in session {1}",
username,
sessionId));
if (participant.getStatus() == ParticipantStatus.SIGNED) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidState", "Participant has already signed", sessionId);
}
if (participant.getStatus() == ParticipantStatus.DECLINED) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidState", "Cannot sign - participant has declined", sessionId);
}
// Store wet signature metadata if provided
if (signRequest.hasWetSignature()) {
WetSignatureMetadata wetSig = signRequest.extractWetSignatureMetadata();
participant.setWetSignatureType(wetSig.getType());
participant.setWetSignatureData(wetSig.getData());
participant.setWetSignaturePage(wetSig.getPage());
participant.setWetSignatureX(wetSig.getX());
participant.setWetSignatureY(wetSig.getY());
participant.setWetSignatureWidth(wetSig.getWidth());
participant.setWetSignatureHeight(wetSig.getHeight());
}
// Store certificate data (reuse existing attachCertificate logic)
ParticipantCertificateRequest certRequest = new ParticipantCertificateRequest();
certRequest.setCertType(signRequest.getCertType());
certRequest.setPassword(signRequest.getPassword());
certRequest.setP12File(signRequest.getP12File());
certRequest.setPrivateKeyFile(signRequest.getPrivateKeyFile());
certRequest.setCertFile(signRequest.getCertFile());
// Use participant's signature settings from session
certRequest.setShowSignature(participant.getShowSignature());
certRequest.setPageNumber(participant.getPageNumber());
certRequest.setReason(participant.getReason());
certRequest.setLocation(participant.getLocation());
certRequest.setShowLogo(participant.getShowLogo());
// Store certificate submission (same as attachCertificate method)
ParticipantCertificateSubmissionEntity submissionEntity =
toSubmissionEntity(certRequest, participant);
participant.setCertificateSubmission(submissionEntity);
// Mark as signed
participant.setStatus(ParticipantStatus.SIGNED);
session.touch();
sessionRepository.save(session);
}
private void validateSessionOwnership(SigningSessionEntity session, String username) {
if (!session.getUser().getUsername().equalsIgnoreCase(username)) {
throw ExceptionUtils.createIllegalArgumentException(
"error.unauthorized", "You do not have permission to access this session");
}
}
private List<SigningParticipantEntity> getTargetParticipants(
SigningSessionEntity session, List<String> requestedEmails) {
if (requestedEmails == null || requestedEmails.isEmpty()) {
return session.getParticipants();
}
return session.getParticipants().stream()
.filter(p -> requestedEmails.contains(p.getEmail()))
.collect(Collectors.toList());
}
private ParticipantCertificateSubmissionEntity toSubmissionEntity(
ParticipantCertificateRequest request, SigningParticipantEntity participant)
throws IOException {
ParticipantCertificateSubmissionEntity entity =
new ParticipantCertificateSubmissionEntity();
entity.setParticipant(participant);
entity.setCertType(request.getCertType());
entity.setPassword(request.getPassword());
entity.setPrivateKey(toBytes(request.getPrivateKeyFile()));
entity.setCertificate(toBytes(request.getCertFile()));
entity.setP12Keystore(toBytes(request.getP12File()));
entity.setJksKeystore(toBytes(request.getJksFile()));
// Copy signature appearance settings from participant (configured by owner)
entity.setShowSignature(participant.getShowSignature());
entity.setPageNumber(participant.getPageNumber());
entity.setName(participant.getName());
entity.setReason(participant.getReason());
entity.setLocation(participant.getLocation());
entity.setShowLogo(participant.getShowLogo());
return entity;
}
private byte[] toBytes(MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
return null;
}
return file.getBytes();
}
private SigningSessionSummaryDTO toSummaryDTO(SigningSessionEntity entity) {
int participantCount = entity.getParticipants().size();
int signedCount =
(int)
entity.getParticipants().stream()
.filter(p -> p.getStatus() == ParticipantStatus.SIGNED)
.count();
return new SigningSessionSummaryDTO(
entity.getSessionId(),
entity.getDocumentName(),
entity.getCreatedAt(),
participantCount,
signedCount,
entity.isFinalized());
}
private SignRequestSummaryDTO toSignRequestSummaryDTO(
SigningSessionEntity entity, Long userId) {
// Find participant matching user to get their status
SigningParticipantEntity participant =
entity.getParticipants().stream()
.filter(p -> p.getUser().getId().equals(userId))
.findFirst()
.orElse(null);
ParticipantStatus myStatus =
participant != null ? participant.getStatus() : ParticipantStatus.PENDING;
String ownerUsername =
entity.getUser() != null ? entity.getUser().getUsername() : entity.getOwnerEmail();
return new SignRequestSummaryDTO(
entity.getSessionId(),
entity.getDocumentName(),
ownerUsername,
entity.getCreatedAt().toString(),
entity.getDueDate(),
myStatus);
}
private SigningSessionDetailDTO toDetailDTO(SigningSessionEntity entity) {
List<ParticipantDTO> participants =
entity.getParticipants().stream()
.map(this::toParticipantDTO)
.collect(Collectors.toList());
return new SigningSessionDetailDTO(
entity.getSessionId(),
entity.getDocumentName(),
entity.getOwnerEmail(),
entity.getMessage(),
entity.getDueDate(),
entity.getCreatedAt(),
entity.getUpdatedAt(),
entity.isFinalized(),
participants);
}
private ParticipantDTO toParticipantDTO(SigningParticipantEntity entity) {
User user = entity.getUser();
return new ParticipantDTO(
user.getId(),
user.getUsername(),
entity.getName() != null ? entity.getName() : user.getUsername(),
entity.getStatus(),
entity.getLastUpdated(),
entity.getShowSignature(),
entity.getPageNumber(),
entity.getReason(),
entity.getLocation(),
entity.getShowLogo());
}
private SigningSession toSigningSession(SigningSessionEntity entity) {
SigningSession session = new SigningSession();
session.setSessionId(entity.getSessionId());
session.setDocumentName(entity.getDocumentName());
session.setOriginalPdf(entity.getOriginalPdf());
session.setSignedPdf(entity.getSignedPdf());
session.setOwnerEmail(entity.getOwnerEmail());
session.setMessage(entity.getMessage());
session.setDueDate(entity.getDueDate());
session.setCreatedAt(entity.getCreatedAt().toString());
session.setUpdatedAt(entity.getUpdatedAt().toString());
List<SigningParticipant> participants =
entity.getParticipants().stream()
.map(this::toSigningParticipant)
.collect(Collectors.toList());
session.setParticipants(participants);
return session;
}
private SigningParticipant toSigningParticipant(SigningParticipantEntity entity) {
SigningParticipant participant = new SigningParticipant();
participant.setUserId(entity.getUser() != null ? entity.getUser().getId() : null);
participant.setEmail(entity.getEmail());
participant.setName(entity.getName());
participant.setStatus(entity.getStatus());
participant.setShareToken(entity.getShareToken());
// Force lazy collection to load by creating a new ArrayList
participant.setNotifications(new ArrayList<>(entity.getNotifications()));
participant.setLastUpdated(entity.getLastUpdated().toString());
if (entity.getCertificateSubmission() != null) {
participant.setCertificateSubmission(
toParticipantCertificateSubmission(entity.getCertificateSubmission()));
}
return participant;
}
private ParticipantCertificateSubmission toParticipantCertificateSubmission(
ParticipantCertificateSubmissionEntity entity) {
return ParticipantCertificateSubmission.builder()
.certType(entity.getCertType())
.password(entity.getPassword())
.privateKey(entity.getPrivateKey())
.certificate(entity.getCertificate())
.p12Keystore(entity.getP12Keystore())
.jksKeystore(entity.getJksKeystore())
.showSignature(entity.getShowSignature())
.pageNumber(entity.getPageNumber())
.name(entity.getName())
.reason(entity.getReason())
.location(entity.getLocation())
.showLogo(entity.getShowLogo())
.build();
}
/**
* Gets all wet signature metadata for participants who have signed. Used during finalization to
* overlay wet signatures on the PDF.
*
* @param sessionId The session ID
* @return List of wet signature metadata for all signed participants
*/
public List<WetSignatureMetadata> getAllWetSignatures(String sessionId) {
SigningSessionEntity session = getSessionEntityById(sessionId);
return session.getParticipants().stream()
.filter(p -> p.getStatus() == ParticipantStatus.SIGNED)
.filter(p -> p.getWetSignatureType() != null)
.map(
p -> {
WetSignatureMetadata meta = new WetSignatureMetadata();
meta.setType(p.getWetSignatureType());
meta.setData(p.getWetSignatureData());
meta.setPage(p.getWetSignaturePage());
meta.setX(p.getWetSignatureX());
meta.setY(p.getWetSignatureY());
meta.setWidth(p.getWetSignatureWidth());
meta.setHeight(p.getWetSignatureHeight());
return meta;
})
.collect(Collectors.toList());
}
/**
* Clears wet signature metadata from all participants after finalization. This is required for
* GDPR compliance and to avoid storing large base64 data.
*
* @param sessionId The session ID
*/
@Transactional
public void clearWetSignatureMetadata(String sessionId) {
SigningSessionEntity session = getSessionEntityById(sessionId);
boolean anyCleared = false;
for (SigningParticipantEntity participant : session.getParticipants()) {
if (participant.getWetSignatureType() != null) {
participant.setWetSignatureType(null);
participant.setWetSignatureData(null);
participant.setWetSignaturePage(null);
participant.setWetSignatureX(null);
participant.setWetSignatureY(null);
participant.setWetSignatureWidth(null);
participant.setWetSignatureHeight(null);
anyCleared = true;
}
}
if (anyCleared) {
log.info("Cleared wet signature metadata for session: {}", sessionId);
sessionRepository.save(session);
}
}
@Override
public boolean isDatabaseBacked() {
return true;
}
}
@@ -0,0 +1,251 @@
package stirling.software.proprietary.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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.CertificateType;
import stirling.software.proprietary.model.UserServerCertificateEntity;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.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;
static {
Security.addProvider(new BouncyCastleProvider());
}
/** Get or create user certificate (auto-generate if not exists) */
@Transactional
public UserServerCertificateEntity getOrCreateUserCertificate(Long userId) throws Exception {
Optional<UserServerCertificateEntity> 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(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.readAllBytes();
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(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()),
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 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<UserServerCertificateEntity> 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;
}
}
@@ -0,0 +1,87 @@
-- Migration: Convert shared signing from email-based to user-based participants
-- Date: 2025-12-03
-- Description: Add User FK to signing_participants, signature appearance fields,
-- and create user_server_certificates table
-- ===========================================================================
-- PART 1: Add new columns to signing_participants table
-- ===========================================================================
-- Add user_id FK column (nullable initially for migration)
ALTER TABLE signing_participants ADD COLUMN IF NOT EXISTS user_id BIGINT;
-- Make existing email field nullable (keep for audit trail)
ALTER TABLE signing_participants ALTER COLUMN email DROP NOT NULL;
-- Make share_token nullable (deprecating this field)
ALTER TABLE signing_participants ALTER COLUMN share_token DROP NOT NULL;
-- Add signature appearance columns (owner-controlled)
ALTER TABLE signing_participants ADD COLUMN IF NOT EXISTS show_signature BOOLEAN;
ALTER TABLE signing_participants ADD COLUMN IF NOT EXISTS page_number INTEGER;
ALTER TABLE signing_participants ADD COLUMN IF NOT EXISTS reason VARCHAR(255);
ALTER TABLE signing_participants ADD COLUMN IF NOT EXISTS location VARCHAR(255);
ALTER TABLE signing_participants ADD COLUMN IF NOT EXISTS show_logo BOOLEAN;
-- ===========================================================================
-- PART 2: Migrate existing data
-- ===========================================================================
-- Match existing participants to users based on email = username
-- NOTE: This assumes participants were created with email matching username
UPDATE signing_participants sp
SET user_id = u.user_id
FROM users u
WHERE sp.email = u.username
AND sp.user_id IS NULL;
-- ===========================================================================
-- PART 3: Add constraints
-- ===========================================================================
-- Add Foreign Key constraint to User table
ALTER TABLE signing_participants
ADD CONSTRAINT fk_participant_user
FOREIGN KEY (user_id) REFERENCES users(user_id)
ON DELETE CASCADE;
-- Make user_id non-nullable after migration
ALTER TABLE signing_participants ALTER COLUMN user_id SET NOT NULL;
-- ===========================================================================
-- PART 4: Create user_server_certificates table
-- ===========================================================================
CREATE TABLE IF NOT EXISTS user_server_certificates (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE,
keystore_data BYTEA NOT NULL,
keystore_password VARCHAR(255) NOT NULL,
certificate_type VARCHAR(50) NOT NULL,
subject_dn VARCHAR(500),
issuer_dn VARCHAR(500),
valid_from TIMESTAMP,
valid_to TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_user_cert_user FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
-- Create index on user_id for faster lookups
CREATE INDEX IF NOT EXISTS idx_user_cert_user_id ON user_server_certificates(user_id);
-- ===========================================================================
-- VERIFICATION QUERIES (Run these to verify migration)
-- ===========================================================================
-- Check participants without user mapping
-- SELECT * FROM signing_participants WHERE user_id IS NULL;
-- Check new table structure
-- SELECT column_name, data_type, is_nullable
-- FROM information_schema.columns
-- WHERE table_name = 'signing_participants'
-- ORDER BY ordinal_position;
-- Check user certificates table
-- SELECT * FROM user_server_certificates;
@@ -0,0 +1,74 @@
-- Migration: Add wet signature metadata fields to signing_participants
-- Date: 2025-01-15
-- Description: Add columns to store visual signature annotations placed by participants.
-- This metadata is used to overlay wet signatures on the PDF during finalization
-- and is cleared after the final signed PDF is generated.
-- ===========================================================================
-- Add wet signature metadata columns
-- ===========================================================================
-- Type of wet signature: "canvas" (drawn), "image" (uploaded), "text" (typed)
ALTER TABLE signing_participants ADD COLUMN IF NOT EXISTS wet_signature_type VARCHAR(20);
-- Base64-encoded image data or text content
-- Using TEXT for large base64 image data
ALTER TABLE signing_participants ADD COLUMN IF NOT EXISTS wet_signature_data TEXT;
-- Position and size of the signature on the PDF
ALTER TABLE signing_participants ADD COLUMN IF NOT EXISTS wet_signature_page INTEGER;
ALTER TABLE signing_participants ADD COLUMN IF NOT EXISTS wet_signature_x DOUBLE PRECISION;
ALTER TABLE signing_participants ADD COLUMN IF NOT EXISTS wet_signature_y DOUBLE PRECISION;
ALTER TABLE signing_participants ADD COLUMN IF NOT EXISTS wet_signature_width DOUBLE PRECISION;
ALTER TABLE signing_participants ADD COLUMN IF NOT EXISTS wet_signature_height DOUBLE PRECISION;
-- ===========================================================================
-- Add indexes for common queries
-- ===========================================================================
-- Index for querying participants with wet signatures during finalization
CREATE INDEX IF NOT EXISTS idx_participants_wet_signature
ON signing_participants(session_id, wet_signature_type)
WHERE wet_signature_type IS NOT NULL;
-- ===========================================================================
-- Add comments for documentation
-- ===========================================================================
COMMENT ON COLUMN signing_participants.wet_signature_type IS
'Type of wet signature: canvas, image, or text. NULL if participant has not placed a visual signature.';
COMMENT ON COLUMN signing_participants.wet_signature_data IS
'Base64-encoded image data or text content for the wet signature. Cleared after finalization for GDPR compliance.';
COMMENT ON COLUMN signing_participants.wet_signature_page IS
'Zero-indexed page number where the wet signature is placed.';
COMMENT ON COLUMN signing_participants.wet_signature_x IS
'X coordinate (in PDF points) of the signature rectangle, measured from left edge.';
COMMENT ON COLUMN signing_participants.wet_signature_y IS
'Y coordinate (in PDF points) of the signature rectangle, measured from top edge (UI coordinates, will be converted for PDF).';
COMMENT ON COLUMN signing_participants.wet_signature_width IS
'Width of the signature rectangle in PDF points.';
COMMENT ON COLUMN signing_participants.wet_signature_height IS
'Height of the signature rectangle in PDF points.';
-- ===========================================================================
-- VERIFICATION QUERIES (Run these to verify migration)
-- ===========================================================================
-- Check new column structure
-- SELECT column_name, data_type, is_nullable, column_default
-- FROM information_schema.columns
-- WHERE table_name = 'signing_participants'
-- AND column_name LIKE 'wet_signature%'
-- ORDER BY ordinal_position;
-- Check index creation
-- SELECT indexname, indexdef
-- FROM pg_indexes
-- WHERE tablename = 'signing_participants'
-- AND indexname LIKE '%wet_signature%';
@@ -0,0 +1,21 @@
-- Create user_server_certificates table for storing per-user signing certificates
CREATE TABLE IF NOT EXISTS user_server_certificates (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE,
keystore_data BYTEA NOT NULL,
keystore_password VARCHAR(255) NOT NULL,
certificate_type VARCHAR(50) NOT NULL,
subject_dn VARCHAR(500),
issuer_dn VARCHAR(500),
valid_from TIMESTAMP,
valid_to TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_user_cert_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Create index for faster lookups by user_id
CREATE INDEX IF NOT EXISTS idx_user_certs_user_id ON user_server_certificates(user_id);
-- Create index for checking certificate expiration
CREATE INDEX IF NOT EXISTS idx_user_certs_valid_to ON user_server_certificates(valid_to);
+41 -42
View File
@@ -2,26 +2,22 @@ plugins {
id "java"
id "jacoco"
id "io.spring.dependency-management" version "1.1.7"
id "org.springframework.boot" version "3.5.6"
id "org.springframework.boot" version "3.5.7"
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
id "io.swagger.swaggerhub" version "1.3.2"
id "com.diffplug.spotless" version "7.2.1"
id "com.github.jk1.dependency-license-report" version "2.9"
id "com.diffplug.spotless" version "8.1.0"
//id "nebula.lint" version "19.0.3"
id "org.sonarqube" version "6.3.1.5724"
}
import com.github.jk1.license.render.*
ext {
springBootVersion = "3.5.6"
pdfboxVersion = "3.0.5"
springBootVersion = "3.5.7"
pdfboxVersion = "3.0.6"
imageioVersion = "3.12.0"
lombokVersion = "1.18.42"
bouncycastleVersion = "1.82"
springSecuritySamlVersion = "6.5.5"
springSecuritySamlVersion = "6.5.6"
openSamlVersion = "4.3.2"
commonmarkVersion = "0.26.0"
commonmarkVersion = "0.27.0"
googleJavaFormatVersion = "1.28.0"
junitPlatformVersion = "1.12.2"
}
@@ -55,15 +51,39 @@ repositories {
maven { url = 'https://build.shibboleth.net/maven/releases' }
}
allprojects {
group = 'stirling.software'
version = '2.0.1'
allprojects {
group = 'stirling.software'
version = '2.0.1'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
}
configurations.all {
resolutionStrategy {
force(
'commons-io:commons-io:2.19.0',
'io.micrometer:micrometer-core:1.15.5',
'com.google.zxing:core:3.5.4',
'org.commonmark:commonmark:0.27.0',
'org.commonmark:commonmark-ext-gfm-tables:0.27.0'
)
eachDependency { details ->
if (details.requested.group == 'com.google.code.gson' && details.requested.name == 'gson') {
details.useVersion '2.11.0'
}
if (details.requested.group == 'org.apache.commons' && details.requested.name == 'commons-lang3') {
details.useVersion '3.17.0'
}
if (details.requested.group == 'org.slf4j' && details.requested.name == 'slf4j-api') {
details.useVersion '2.0.13'
}
}
}
}
}
}
tasks.register('writeVersion', WriteProperties) {
destinationFile = layout.projectDirectory.file('app/common/src/main/resources/version.properties')
@@ -115,8 +135,8 @@ subprojects {
implementation 'io.github.pixee:java-security-toolkit:1.2.2'
//tmp for security bumps
implementation 'ch.qos.logback:logback-core:1.5.19'
implementation 'ch.qos.logback:logback-classic:1.5.19'
implementation 'ch.qos.logback:logback-core:1.5.21'
implementation 'ch.qos.logback:logback-classic:1.5.21'
compileOnly "org.projectlombok:lombok:$lombokVersion"
annotationProcessor "org.projectlombok:lombok:$lombokVersion"
@@ -124,7 +144,7 @@ subprojects {
testRuntimeOnly 'org.mockito:mockito-inline:5.2.0'
testRuntimeOnly "org.junit.platform:junit-platform-launcher:$junitPlatformVersion"
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.1.0")
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.1")
testImplementation "com.squareup.okhttp3:mockwebserver"
}
@@ -220,16 +240,6 @@ gradle.taskGraph.whenReady { graph ->
}
}
def allProjects = ((subprojects as Set<Project>) + project) as Set<Project>
licenseReport {
projects = allProjects
renderers = [new JsonReportRenderer()]
allowedLicensesFile = project.layout.projectDirectory.file("app/allowed-licenses.json").asFile
outputDir = project.layout.buildDirectory.dir("reports/dependency-license").get().asFile.path
configurations = [ "productionRuntimeClasspath", "runtimeClasspath" ]
}
// Configure the forked spring boot run task to properly delegate to the stirling-pdf module
tasks.named('forkedSpringBootRun') {
dependsOn ':stirling-pdf:bootRun'
@@ -253,17 +263,6 @@ spotless {
}
}
sonar {
properties {
property "sonar.projectKey", "Stirling-Tools_Stirling-PDF"
property "sonar.organization", "stirling-tools"
property "sonar.exclusions", "**/build-wrapper-dump.json, **/src/main/java/org/apache/**, **/src/main/resources/static/pdfjs/**, **/src/main/resources/static/pdfjs-legacy/**, **/src/main/resources/static/js/thirdParty/**"
property "sonar.coverage.exclusions", "**/src/main/java/org/apache/**, **/src/main/resources/static/pdfjs/**, **/src/main/resources/static/pdfjs-legacy/**, **/src/main/resources/static/js/thirdParty/**"
property "sonar.cpd.exclusions", "**/src/main/java/org/apache/**, **/src/main/resources/static/pdfjs/**, **/src/main/resources/static/pdfjs-legacy/**, **/src/main/resources/static/js/thirdParty/**"
}
}
swaggerhubUpload {
// dependsOn = generateOpenApiDocs // Depends on your task generating Swagger docs
api = "Stirling-PDF" // The name of your API on SwaggerHub
@@ -284,7 +283,7 @@ dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly "org.junit.platform:junit-platform-launcher:$junitPlatformVersion"
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.1.0")
testImplementation platform("com.squareup.okhttp3:okhttp-bom:5.3.1")
testImplementation "com.squareup.okhttp3:mockwebserver"
}
+7
View File
@@ -0,0 +1,7 @@
# Shared Signing User Stories
- **As a document owner,** I can start a shared signing session by uploading a PDF, listing participant emails (with optional display names), adding a message or due date, and choosing whether to notify everyone immediately so that the collaboration is organized from the outset.
- **As a document owner,** I can retrieve a session to review its participants, share tokens, and current statuses so that I know who has been invited and who has responded.
- **As a document owner,** I can send reminder notifications to all participants or a selected subset with a custom message so that pending signers get nudged to complete their part.
- **As a participant,** I can attach my certificate materials (PEM key + certificate, PKCS#12/PFX, JKS, or the server certificate) along with signature placement details (page number, display name, reason, location, and logo/signature visibility) so that my signature is applied with the right credentials and appearance.
- **As a document owner,** I can finalize the session to apply every collected signature sequentially and download the fully signed PDF so that the collaboration produces a single completed document.
+41 -16
View File
@@ -455,6 +455,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -498,6 +499,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -578,6 +580,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.4.1",
"@embedpdf/models": "1.4.1"
@@ -677,6 +680,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -693,6 +697,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -710,6 +715,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -746,6 +752,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -780,6 +787,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -816,6 +824,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -891,6 +900,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -1046,6 +1056,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1089,6 +1100,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2119,6 +2131,7 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@@ -2169,6 +2182,7 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@@ -2236,6 +2250,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.5",
@@ -3168,6 +3183,7 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@@ -3286,7 +3302,6 @@
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
@@ -4063,6 +4078,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4391,6 +4407,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4401,6 +4418,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4470,6 +4488,7 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@@ -5183,7 +5202,6 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.24"
}
@@ -5193,7 +5211,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5204,7 +5221,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
@@ -5217,7 +5233,6 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5244,6 +5259,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5651,7 +5667,6 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -5928,6 +5943,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6975,7 +6991,8 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
"dev": true,
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7370,6 +7387,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7540,6 +7558,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7706,8 +7725,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
@@ -7772,7 +7790,6 @@
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
@@ -8863,6 +8880,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -9339,7 +9357,6 @@
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
@@ -9660,6 +9677,7 @@
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
@@ -10246,8 +10264,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
@@ -11393,6 +11410,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11672,6 +11690,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -12054,6 +12073,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -12063,6 +12083,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13574,7 +13595,6 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -13783,6 +13803,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14084,6 +14105,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14165,6 +14187,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -14369,6 +14392,7 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14520,6 +14544,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14533,6 +14558,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -15144,8 +15170,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
+167 -1
View File
@@ -122,6 +122,7 @@ quickPosition = "Quick Position"
size = "Size"
submit = "Submit"
success = "Success"
errorLabel = "Error"
undoDataMismatch = "Cannot undo: operation data is corrupted"
undoFailed = "Failed to undo operation"
undoQuotaError = "Cannot undo: insufficient storage space"
@@ -718,6 +719,169 @@ tags = "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server
title = "Sign with Certificate"
desc = "Signs a PDF with a Certificate/Key (PEM/P12)"
[home.signingWorkflow]
title = "Shared signing"
desc = "Invite collaborators, issue notifications, and manage certificate collection."
tags = "collaborate,share,team,invite,multi,participants,distributed"
[certSign.collab]
stepTitle = "Share for signing"
submit = "Create shared session"
sessionCreated = "Signing session created successfully"
results = "Session summary"
error = "Unable to start shared signing session. Please verify participant emails and try again."
helper = "Invite multiple participants to sign by entering emails. Each invite gets its own tracking token."
emails = "Participant emails"
emailsPlaceholder = "Enter email and press Enter"
names = "Participant names (optional)"
namesPlaceholder = "Enter name and press Enter"
owner = "Your email for updates (optional)"
messageLabel = "Message to include in invitations"
dueDate = "Due date (optional, ISO date)"
notify = "Send notifications immediately"
footer = "After submitting you will get a session summary with per-signer links and can return later to finalize signatures."
participantStatus = "Participant Status"
signed = "Signed"
pending = "Pending"
loadingSession = "Loading session status..."
[certSign.collab.sessionList]
title = "Signing Sessions"
empty = "No signing sessions yet. Upload a PDF to create one."
createNew = "Create New Session"
participantCount = "{0} participants"
signedCount = "{0} of {1} signed"
active = "Active"
finalized = "Finalized"
[certSign.collab.sessionDetail]
title = "Session Details"
participants = "Participants"
addParticipants = "Add Participants"
selectUsers = "Select users..."
addButton = "Add Participants"
removeParticipant = "Remove"
deleteSession = "Delete Session"
deleteConfirm = "Are you sure? This cannot be undone."
backToList = "Back to Sessions"
autoRefresh = "Auto-refreshing every 30s to show latest participant status"
created = "Created"
owner = "Owner"
dueDate = "Due Date"
messageLabel = "Message"
participantsAdded = "Participants added successfully"
addParticipantsError = "Failed to add participants"
participantRemoved = "Participant removed"
removeParticipantError = "Failed to remove participant"
finalized = "Session finalized and PDF downloaded"
finalizeError = "Failed to finalize session"
deleted = "Session deleted"
deleteError = "Failed to delete session"
loadSignedPdf = "Load signed PDF into active files"
loadPdfError = "Failed to load signed PDF"
[certSign.collab.status]
pending = "Pending"
notified = "Notified"
viewed = "Viewed"
signed = "Signed"
declined = "Declined"
[certSign.collab.message]
placeholder = "Please review and sign this document by the due date."
[certSign.collab.participant]
title = "Submit your certificate"
subtitle = "Upload certificate to sign document"
sessionInfo = "Session: {sessionId}"
documentName = "Document: {documentName}"
yourEmail = "Your email: {email}"
status = "Status: {status}"
instructions = "Please upload your certificate files and provide signing details."
certTypeLabel = "Certificate type"
passwordLabel = "Certificate password"
passwordPlaceholder = "Leave empty if no password"
filesLabel = "Certificate files"
appearanceLabel = "Signature appearance"
visibleLabel = "Make signature visible"
pageNumberLabel = "Page number"
reasonLabel = "Reason"
locationLabel = "Location"
nameLabel = "Signer name"
submit = "Submit certificate"
submitSuccess = "Certificate submitted successfully"
submitError = "Failed to submit certificate. Please check your files and try again."
invalidToken = "Invalid or expired session link"
alreadySigned = "You have already submitted your certificate"
loading = "Loading session details..."
[certSign.collab.finalize]
button = "Finalize and load signed PDF"
tooltip = "Apply all collected certificates and load the final signed document"
processing = "Applying signatures..."
success = "Signed PDF added to active files"
error = "Failed to finalize signatures. Some participants may not have submitted certificates."
[certSign.collab.tabs]
mySessions = "My Sessions"
signRequests = "Sign Requests"
[certSign.collab.userSelector]
placeholder = "Select users..."
loadError = "Failed to load users"
noTeam = "No Team"
[certSign.collab.signatureSettings]
title = "Signature Appearance"
description = "Configure how signatures will appear for all participants"
[certSign.collab.signRequests]
empty = "No pending sign requests."
from = "From"
due = "Due"
[certSign.collab.signRequest]
backToList = "Back to Sign Requests"
from = "From"
dueDate = "Due Date"
signatureSettings = "Signature Settings"
signatureInfo = "These settings are configured by the document owner"
certificateChoice = "Certificate Choice"
usePersonalCert = "Use My Personal Certificate"
usePersonalCertDesc = "Auto-generated for your account"
useServerCert = "Use Organization Certificate"
useServerCertDesc = "Shared organization certificate"
uploadCert = "Upload Custom Certificate"
uploadCertDesc = "Use your own PKCS12 certificate"
p12File = "P12/PFX Certificate File"
selectFile = "Select file..."
password = "Certificate Password"
signButton = "Sign Document"
declineButton = "Decline"
signed = "Document signed successfully"
signError = "Failed to sign document"
declined = "Sign request declined"
declineError = "Failed to decline request"
noCertificate = "Please select a certificate file"
noUser = "User not authenticated"
alreadyProcessed = "You have already processed this sign request."
incomplete = "Not all participants have submitted certificates yet"
loading = "Loading sign request..."
addSignature = "Add Your Signature"
message = "Message"
placeSignatureButton = "Place Signature on PDF"
placementActive = "Click PDF to place"
signaturePlaced = "Signature placed on page"
drawSignature = "Draw your signature below"
uploadSignature = "Upload your signature image"
typeSignature = "Type your name to create a signature"
signatureType = "Signature Type"
placementError = "Failed to place signature"
[certSign.collab.signRequest.placeSignature]
title = "Place Signature"
message = "Click on the PDF to place your signature"
[home.repair]
tags = "fix,restore"
title = "Repair"
@@ -2624,6 +2788,7 @@ stepTitle = "Certificate Files"
[certSign.appearance]
stepTitle = "Signature Appearance"
visibility = "Visibility"
invisible = "Invisible"
visible = "Visible"
@@ -3868,6 +4033,7 @@ singlePageView = "Single Page View"
unknownFile = "Unknown file"
zoomIn = "Zoom In"
zoomOut = "Zoom Out"
resetZoom = "Reset Zoom"
[rightRail]
closeSelected = "Close Selected Files"
@@ -4609,6 +4775,7 @@ subtitle = "Add files to your storage for easy access across tools"
filesSelected = "files selected"
clearSelection = "Clear Selection"
openInFileEditor = "Open in File Editor"
downloadSelected = "Download Selected"
uploadError = "Failed to upload some files."
failedToOpen = "Failed to open file. It may have been removed from storage."
failedToLoad = "Failed to load file to active set."
@@ -4640,7 +4807,6 @@ googleDriveShort = "Drive"
myFiles = "My Files"
noRecentFiles = "No recent files found"
googleDriveNotAvailable = "Google Drive integration not available"
downloadSelected = "Download Selected"
saveSelected = "Save Selected"
openFiles = "Open Files"
openFile = "Open File"
+27
View File
@@ -4,6 +4,7 @@ import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import HomePage from "@app/pages/HomePage";
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
import ParticipantCertificateSubmission from "@app/pages/ParticipantCertificateSubmission";
// Import global styles
import "@app/styles/tailwind.css";
@@ -14,6 +15,32 @@ import "@app/styles/index.css";
import "@app/utils/fileIdSafety";
export default function App() {
console.error("HELLOOOOOO")
// Check for participant signing session URL parameters
const queryParams = new URLSearchParams(window.location.search);
const sessionId = queryParams.get('sessionId');
const token = queryParams.get('token');
console.log('App.tsx routing check:', { sessionId, token, search: window.location.search });
// If both sessionId and token are present, show participant submission page
if (sessionId && token) {
console.log('Showing participant submission page');
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<AppLayout>
<ParticipantCertificateSubmission
sessionId={sessionId}
token={token}
/>
</AppLayout>
</AppProviders>
</Suspense>
);
}
// Otherwise, show normal home page
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
@@ -71,6 +71,16 @@ export default function Workbench() {
};
const renderMainContent = () => {
// 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) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
}
}
if (activeFiles.length === 0) {
return (
<LandingPage
@@ -0,0 +1,342 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Paper,
Text,
Group,
Badge,
Button,
List,
ActionIcon,
Divider,
Alert,
Modal,
} from '@mantine/core';
import { alert } from '@app/components/toast';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import DeleteIcon from '@mui/icons-material/Delete';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import PendingIcon from '@mui/icons-material/Pending';
import CancelIcon from '@mui/icons-material/Cancel';
import AddIcon from '@mui/icons-material/Add';
import InfoIcon from '@mui/icons-material/Info';
import { SessionDetail } from '@app/types/signingSession';
import UserSelector from '@app/components/tools/certSign/UserSelector';
import SignatureSettingsInput, { SignatureSettings } from '@app/components/tools/certSign/SignatureSettingsInput';
interface SessionDetailViewProps {
session: SessionDetail;
onFinalize: () => Promise<void>;
onDelete: () => Promise<void>;
onAddParticipants: (participants: { participantUserIds: number[] }) => Promise<void>;
onRemoveParticipant: (userId: number) => Promise<void>;
onLoadSignedPdf?: () => Promise<void>;
onBack: () => void;
onRefresh: () => void;
}
const SessionDetailView = ({
session,
onFinalize,
onDelete,
onAddParticipants,
onRemoveParticipant,
onLoadSignedPdf,
onBack,
onRefresh,
}: SessionDetailViewProps) => {
const { t } = useTranslation();
const [selectedUserIds, setSelectedUserIds] = useState<number[]>([]);
const [signatureSettings, setSignatureSettings] = useState<SignatureSettings>({
showSignature: false,
pageNumber: 1,
reason: '',
location: '',
showLogo: false,
});
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [finalizing, setFinalizing] = useState(false);
const [deleting, setDeleting] = useState(false);
const [loadingPdf, setLoadingPdf] = useState(false);
// Auto-refresh every 30 seconds
useEffect(() => {
if (!session.finalized) {
const interval = setInterval(() => {
onRefresh();
}, 30000);
return () => clearInterval(interval);
}
}, [session.finalized, onRefresh]);
const handleAddParticipants = async () => {
if (selectedUserIds.length === 0) return;
try {
await onAddParticipants({ participantUserIds: selectedUserIds });
setSelectedUserIds([]);
alert({
alertType: 'success',
title: t('success'),
body: t('certSign.collab.sessionDetail.participantsAdded', 'Participants added successfully'),
});
} catch (_error) {
alert({
alertType: 'error',
title: t('error'),
body: t('certSign.collab.sessionDetail.addParticipantsError', 'Failed to add participants'),
});
}
};
const handleRemoveParticipant = async (userId: number) => {
try {
await onRemoveParticipant(userId);
alert({
alertType: 'success',
title: t('success'),
body: t('certSign.collab.sessionDetail.participantRemoved', 'Participant removed'),
});
} catch (_error) {
alert({
alertType: 'error',
title: t('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('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('error'),
body: t('certSign.collab.sessionDetail.deleteError', 'Failed to delete session'),
});
setDeleting(false);
}
};
const handleLoadSignedPdf = async () => {
if (!onLoadSignedPdf) return;
setLoadingPdf(true);
try {
await onLoadSignedPdf();
} catch (_error) {
alert({
alertType: 'error',
title: t('error'),
body: t('certSign.collab.sessionDetail.loadPdfError', 'Failed to load signed PDF'),
});
} finally {
setLoadingPdf(false);
}
};
return (
<Stack gap="sm">
<Group justify="space-between">
<Button leftSection={<ArrowBackIcon />} variant="subtle" onClick={onBack} size="sm">
{t('certSign.collab.sessionDetail.backToList', 'Back to Sessions')}
</Button>
{!session.finalized && (
<Button leftSection={<DeleteIcon />} color="red" variant="outline" onClick={() => setDeleteModalOpen(true)} size="sm">
{t('certSign.collab.sessionDetail.deleteSession', 'Delete Session')}
</Button>
)}
</Group>
<Paper p="sm" withBorder>
<Stack gap={4}>
<Text size="md" fw={700}>
{session.documentName}
</Text>
<Group gap="sm">
<Badge size="sm" color={session.finalized ? 'green' : 'blue'} variant="light">
{session.finalized
? t('certSign.collab.sessionList.finalized', 'Finalized')
: t('certSign.collab.sessionList.active', 'Active')}
</Badge>
<Text size="xs" c="dimmed">
{new Date(session.createdAt).toLocaleDateString()}
</Text>
</Group>
{session.ownerEmail && (
<Text size="xs" c="dimmed">
{t('certSign.collab.sessionDetail.owner', 'Owner')}: {session.ownerEmail}
</Text>
)}
{session.dueDate && (
<Text size="xs" c="dimmed">
{t('certSign.collab.sessionDetail.dueDate', 'Due Date')}: {session.dueDate}
</Text>
)}
{session.message && (
<Text size="xs" c="dimmed">
{t('certSign.collab.sessionDetail.messageLabel', 'Message')}: {session.message}
</Text>
)}
</Stack>
</Paper>
<Paper p="sm" withBorder>
<Stack gap="sm">
<Text size="sm" fw={600}>
{t('certSign.collab.sessionDetail.participants', 'Participants')}
</Text>
<List spacing={4} size="sm">
{session.participants.map((participant) => {
const isSigned = participant.status === 'SIGNED';
const isDeclined = participant.status === 'DECLINED';
const getIcon = () => {
if (isSigned) return <CheckCircleIcon style={{ color: 'green', fontSize: '1rem' }} />;
if (isDeclined) return <CancelIcon style={{ color: 'red', fontSize: '1rem' }} />;
return <PendingIcon style={{ color: 'orange', fontSize: '1rem' }} />;
};
const getColor = () => {
if (isSigned) return 'green';
if (isDeclined) return 'red';
return 'orange';
};
return (
<List.Item key={participant.userId} icon={getIcon()}>
<Group justify="space-between" wrap="nowrap" gap={4}>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" truncate>
{participant.displayName}
<Text span size="xs" c="dimmed" ml={4}>
(@{participant.username})
</Text>
</Text>
<Badge size="xs" color={getColor()} variant="light">
{t(`certSign.collab.status.${participant.status.toLowerCase()}`, participant.status)}
</Badge>
</Stack>
{!session.finalized && !isSigned && !isDeclined && (
<ActionIcon
size="xs"
variant="subtle"
color="red"
onClick={() => handleRemoveParticipant(participant.userId)}
title={t('certSign.collab.sessionDetail.removeParticipant', 'Remove')}
>
<DeleteIcon style={{ fontSize: '0.875rem' }} />
</ActionIcon>
)}
</Group>
</List.Item>
);
})}
</List>
{!session.finalized && (
<>
<Divider />
<Text size="xs" fw={600}>
{t('certSign.collab.sessionDetail.addParticipants', 'Add Participants')}
</Text>
<UserSelector
value={selectedUserIds}
onChange={setSelectedUserIds}
placeholder={t('certSign.collab.sessionDetail.selectUsers', 'Select users...')}
size="xs"
/>
<SignatureSettingsInput value={signatureSettings} onChange={setSignatureSettings} />
<Button
leftSection={<AddIcon />}
onClick={handleAddParticipants}
disabled={selectedUserIds.length === 0}
size="xs"
>
{t('certSign.collab.sessionDetail.addButton', 'Add Participants')}
</Button>
</>
)}
</Stack>
</Paper>
{!session.finalized ? (
<>
<Alert icon={<InfoIcon />} color="blue" variant="light" p="xs">
<Text size="xs">
{t(
'certSign.collab.sessionDetail.autoRefresh',
'Auto-refreshing every 30s to show latest participant status'
)}
</Text>
</Alert>
<Button
leftSection={<CheckCircleIcon />}
size="sm"
color="green"
fullWidth
onClick={handleFinalize}
loading={finalizing}
>
{t('certSign.collab.finalize.button', 'Finalize and load signed PDF')}
</Button>
</>
) : (
onLoadSignedPdf && (
<Button
leftSection={<CheckCircleIcon />}
size="sm"
color="blue"
fullWidth
onClick={handleLoadSignedPdf}
loading={loadingPdf}
>
{t('certSign.collab.sessionDetail.loadSignedPdf', 'Load signed PDF into active files')}
</Button>
)
)}
<Modal
opened={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
title={t('certSign.collab.sessionDetail.deleteSession', 'Delete Session')}
>
<Stack gap="md">
<Text>{t('certSign.collab.sessionDetail.deleteConfirm', 'Are you sure? This cannot be undone.')}</Text>
<Group justify="flex-end">
<Button variant="subtle" onClick={() => setDeleteModalOpen(false)}>
{t('cancel', 'Cancel')}
</Button>
<Button color="red" onClick={handleDelete} loading={deleting}>
{t('delete', 'Delete')}
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};
export default SessionDetailView;
@@ -0,0 +1,97 @@
import { Stack, Card, Text, Group, Badge, Button, Loader, Center } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DescriptionIcon from '@mui/icons-material/Description';
import PeopleIcon from '@mui/icons-material/People';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import AddIcon from '@mui/icons-material/Add';
import { SessionSummary } from '@app/types/signingSession';
interface SessionListViewProps {
sessions: SessionSummary[];
onSessionSelect: (sessionId: string) => void;
onCreateNew: () => void;
loading: boolean;
}
const SessionListView = ({ sessions, onSessionSelect, onCreateNew, loading }: SessionListViewProps) => {
const { t } = useTranslation();
if (loading) {
return (
<Center p="xl">
<Loader size="lg" />
</Center>
);
}
if (sessions.length === 0) {
return (
<Stack align="center" justify="center" gap="md" p="xl">
<DescriptionIcon style={{ fontSize: '3rem', opacity: 0.3 }} />
<Text size="lg" c="dimmed">
{t('certSign.collab.sessionList.empty', 'No signing sessions yet. Upload a PDF to create one.')}
</Text>
<Button leftSection={<AddIcon />} onClick={onCreateNew} size="lg">
{t('certSign.collab.sessionList.createNew', 'Create New Session')}
</Button>
</Stack>
);
}
return (
<Stack gap="md">
<Button leftSection={<AddIcon />} onClick={onCreateNew} size="sm">
{t('certSign.collab.sessionList.createNew', 'Create New Session')}
</Button>
<Stack gap="sm">
{sessions.map((session) => (
<Card
key={session.sessionId}
shadow="sm"
padding="md"
radius="md"
withBorder
style={{ cursor: 'pointer' }}
onClick={() => onSessionSelect(session.sessionId)}
>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<DescriptionIcon style={{ fontSize: '1.5rem', flexShrink: 0 }} />
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{session.documentName}
</Text>
<Group gap="sm">
<Group gap={4}>
<PeopleIcon style={{ fontSize: '0.875rem' }} />
<Text size="xs" c="dimmed">
{session.participantCount}
</Text>
</Group>
<Group gap={4}>
<CheckCircleIcon style={{ fontSize: '0.875rem' }} />
<Text size="xs" c="dimmed">
{session.signedCount}/{session.participantCount}
</Text>
</Group>
<Text size="xs" c="dimmed">
{new Date(session.createdAt).toLocaleDateString()}
</Text>
</Group>
</Stack>
</Group>
<Badge size="sm" color={session.finalized ? 'green' : 'blue'} variant="light" style={{ flexShrink: 0 }}>
{session.finalized
? t('certSign.collab.sessionList.finalized', 'Finalized')
: t('certSign.collab.sessionList.active', 'Active')}
</Badge>
</Group>
</Card>
))}
</Stack>
</Stack>
);
};
export default SessionListView;
@@ -0,0 +1,289 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Stack,
Paper,
Text,
Group,
Button,
Divider,
Alert,
Radio,
FileInput,
PasswordInput,
Loader,
} from '@mantine/core';
import { alert } from '@app/components/toast';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import InfoIcon from '@mui/icons-material/Info';
import { SignRequestDetail } from '@app/types/signingSession';
interface SignRequestDetailViewProps {
signRequest: SignRequestDetail;
onSign: (certificateData: FormData) => Promise<void>;
onDecline: () => Promise<void>;
onBack: () => void;
canSign: boolean; // based on status
onLoadPdf: (sessionId: string, documentName: string) => Promise<File>;
}
const SignRequestDetailView = ({ signRequest, onSign, onDecline, onBack, canSign, onLoadPdf }: SignRequestDetailViewProps) => {
const { t } = useTranslation();
const [certType, setCertType] = useState<'SERVER' | 'UPLOAD'>('SERVER');
const [uploading, setSigning] = useState(false);
const [declining, setDeclining] = useState(false);
const [loadingPdf, setLoadingPdf] = useState(true);
// Upload certificate fields
const [p12File, setP12File] = useState<File | null>(null);
const [password, setPassword] = useState('');
// Load PDF on mount
useEffect(() => {
const loadPdf = async () => {
setLoadingPdf(true);
try {
await onLoadPdf(signRequest.sessionId, signRequest.documentName);
} catch (error) {
console.error('Failed to load PDF:', error);
alert({
alertType: 'error',
title: t('error'),
body: t('certSign.collab.signRequest.pdfLoadError', 'Failed to load document'),
});
} finally {
setLoadingPdf(false);
}
};
loadPdf();
}, [signRequest.sessionId, signRequest.documentName, onLoadPdf, t]);
const handleSign = async () => {
setSigning(true);
try {
const formData = new FormData();
formData.append('certType', certType);
if (certType === 'UPLOAD') {
if (!p12File) {
alert({
alertType: 'error',
title: t('error'),
body: t('certSign.collab.signRequest.noCertificate', 'Please select a certificate file'),
});
setSigning(false);
return;
}
formData.append('p12File', p12File);
formData.append('password', password);
}
await onSign(formData);
alert({
alertType: 'success',
title: t('success'),
body: t('certSign.collab.signRequest.signed', 'Document signed successfully'),
});
} catch (_error) {
alert({
alertType: 'error',
title: t('error'),
body: t('certSign.collab.signRequest.signError', 'Failed to sign document'),
});
} finally {
setSigning(false);
}
};
const handleDecline = async () => {
setDeclining(true);
try {
await onDecline();
alert({
alertType: 'success',
title: t('success'),
body: t('certSign.collab.signRequest.declined', 'Sign request declined'),
});
} catch (_error) {
alert({
alertType: 'error',
title: t('error'),
body: t('certSign.collab.signRequest.declineError', 'Failed to decline request'),
});
setDeclining(false);
}
};
if (loadingPdf) {
return (
<Stack gap="sm" align="center" justify="center" style={{ minHeight: '200px' }}>
<Loader size="lg" />
<Text size="sm" c="dimmed">
{t('certSign.collab.signRequest.loadingPdf', 'Loading document...')}
</Text>
</Stack>
);
}
return (
<Stack gap="sm">
<Group justify="space-between">
<Button leftSection={<ArrowBackIcon />} variant="subtle" onClick={onBack} size="sm">
{t('certSign.collab.signRequest.backToList', 'Back to Sign Requests')}
</Button>
</Group>
<Paper p="sm" withBorder>
<Stack gap={4}>
<Text size="md" fw={700}>
{signRequest.documentName}
</Text>
<Group gap="sm">
<Text size="xs" c="dimmed">
{t('certSign.collab.signRequest.from', 'From')}: {signRequest.ownerUsername}
</Text>
<Text size="xs" c="dimmed">
{new Date(signRequest.createdAt).toLocaleDateString()}
</Text>
</Group>
{signRequest.dueDate && (
<Text size="xs" c="dimmed">
{t('certSign.collab.signRequest.dueDate', 'Due Date')}: {signRequest.dueDate}
</Text>
)}
{signRequest.message && (
<Alert icon={<InfoIcon />} color="blue" variant="light" p="xs" mt="xs">
<Text size="xs">{signRequest.message}</Text>
</Alert>
)}
</Stack>
</Paper>
{/* Signature Settings (Read-Only) */}
<Paper p="sm" withBorder>
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('certSign.collab.signRequest.signatureSettings', 'Signature Settings')}
</Text>
<Text size="xs" c="dimmed">
{t('certSign.collab.signRequest.signatureInfo', 'These settings are configured by the document owner')}
</Text>
<Stack gap={4}>
<Text size="xs">
{t('certSign.appearance.visibility', 'Visibility')}:{' '}
<strong>
{signRequest.showSignature
? t('certSign.appearance.visible', 'Visible')
: t('certSign.appearance.invisible', 'Invisible')}
</strong>
</Text>
{signRequest.showSignature && (
<>
{signRequest.pageNumber && (
<Text size="xs">
{t('certSign.pageNumber', 'Page Number')}: <strong>{signRequest.pageNumber}</strong>
</Text>
)}
{signRequest.reason && (
<Text size="xs">
{t('certSign.reason', 'Reason')}: <strong>{signRequest.reason}</strong>
</Text>
)}
{signRequest.location && (
<Text size="xs">
{t('certSign.location', 'Location')}: <strong>{signRequest.location}</strong>
</Text>
)}
<Text size="xs">
{t('certSign.logoTitle', 'Logo')}:{' '}
<strong>
{signRequest.showLogo ? t('certSign.showLogo', 'Show Logo') : t('certSign.noLogo', 'No Logo')}
</strong>
</Text>
</>
)}
</Stack>
</Stack>
</Paper>
{canSign && (
<>
<Divider />
<Paper p="sm" withBorder>
<Stack gap="sm">
<Text size="sm" fw={600}>
{t('certSign.collab.signRequest.certificateChoice', 'Certificate Choice')}
</Text>
<Radio.Group value={certType} onChange={(value) => setCertType(value as 'SERVER' | 'UPLOAD')}>
<Stack gap="xs">
<Radio
value="SERVER"
label={t('certSign.collab.signRequest.useServerCert', 'Use My Server Certificate')}
/>
<Radio
value="UPLOAD"
label={t('certSign.collab.signRequest.uploadCert', 'Upload Custom Certificate')}
/>
</Stack>
</Radio.Group>
{certType === 'UPLOAD' && (
<Stack gap="xs" mt="xs">
<FileInput
label={t('certSign.collab.signRequest.p12File', 'P12/PFX Certificate File')}
placeholder={t('certSign.collab.signRequest.selectFile', 'Select file...')}
accept=".p12,.pfx"
value={p12File}
onChange={setP12File}
size="xs"
/>
<PasswordInput
label={t('certSign.collab.signRequest.password', 'Certificate Password')}
value={password}
onChange={(event) => setPassword(event.currentTarget.value)}
size="xs"
/>
</Stack>
)}
</Stack>
</Paper>
<Group gap="sm" mt="sm">
<Button
leftSection={<CheckCircleIcon />}
color="green"
onClick={handleSign}
loading={uploading}
style={{ flex: 1 }}
>
{t('certSign.collab.signRequest.signButton', 'Sign Document')}
</Button>
<Button
leftSection={<CancelIcon />}
color="red"
variant="outline"
onClick={handleDecline}
loading={declining}
style={{ flex: 1 }}
>
{t('certSign.collab.signRequest.declineButton', 'Decline')}
</Button>
</Group>
</>
)}
{!canSign && (
<Alert icon={<InfoIcon />} color="blue" variant="light" p="sm">
<Text size="xs">
{t('certSign.collab.signRequest.alreadyProcessed', 'You have already processed this sign request.')}
</Text>
</Alert>
)}
</Stack>
);
};
export default SignRequestDetailView;
@@ -0,0 +1,117 @@
import { Stack, Card, Text, Group, Badge, Loader, Center } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import DescriptionIcon from '@mui/icons-material/Description';
import PersonIcon from '@mui/icons-material/Person';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import PendingIcon from '@mui/icons-material/Pending';
import CancelIcon from '@mui/icons-material/Cancel';
import { SignRequestSummary } from '@app/types/signingSession';
interface SignRequestListViewProps {
signRequests: SignRequestSummary[];
onRequestSelect: (sessionId: string) => void;
loading: boolean;
}
const SignRequestListView = ({ signRequests, onRequestSelect, loading }: SignRequestListViewProps) => {
const { t } = useTranslation();
const getStatusColor = (status: string) => {
switch (status) {
case 'SIGNED':
return 'green';
case 'DECLINED':
return 'red';
case 'VIEWED':
return 'blue';
case 'NOTIFIED':
return 'orange';
default:
return 'gray';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'SIGNED':
return <CheckCircleIcon style={{ fontSize: '1rem' }} />;
case 'DECLINED':
return <CancelIcon style={{ fontSize: '1rem' }} />;
default:
return <PendingIcon style={{ fontSize: '1rem' }} />;
}
};
if (loading) {
return (
<Center p="xl">
<Loader size="lg" />
</Center>
);
}
if (signRequests.length === 0) {
return (
<Stack align="center" justify="center" gap="md" p="xl">
<DescriptionIcon style={{ fontSize: '3rem', opacity: 0.3 }} />
<Text size="lg" c="dimmed">
{t('certSign.collab.signRequests.empty', 'No pending sign requests.')}
</Text>
</Stack>
);
}
return (
<Stack gap="sm">
{signRequests.map((request) => (
<Card
key={request.sessionId}
shadow="sm"
padding="md"
radius="md"
withBorder
style={{ cursor: 'pointer' }}
onClick={() => onRequestSelect(request.sessionId)}
>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Group gap="sm" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<DescriptionIcon style={{ fontSize: '1.5rem', flexShrink: 0 }} />
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} size="sm" truncate>
{request.documentName}
</Text>
<Group gap="sm">
<Group gap={4}>
<PersonIcon style={{ fontSize: '0.875rem' }} />
<Text size="xs" c="dimmed">
{t('certSign.collab.signRequests.from', 'From')}: {request.ownerUsername}
</Text>
</Group>
<Text size="xs" c="dimmed">
{new Date(request.createdAt).toLocaleDateString()}
</Text>
{request.dueDate && (
<Text size="xs" c="dimmed">
{t('certSign.collab.signRequests.due', 'Due')}: {request.dueDate}
</Text>
)}
</Group>
</Stack>
</Group>
<Badge
size="sm"
color={getStatusColor(request.myStatus)}
variant="light"
style={{ flexShrink: 0 }}
leftSection={getStatusIcon(request.myStatus)}
>
{t(`certSign.collab.status.${request.myStatus.toLowerCase()}`, request.myStatus)}
</Badge>
</Group>
</Card>
))}
</Stack>
);
};
export default SignRequestListView;
@@ -0,0 +1,331 @@
import { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Paper, Group, Button, Text, Divider } from '@mantine/core';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import CancelIcon from '@mui/icons-material/Cancel';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import ZoomOutIcon from '@mui/icons-material/ZoomOut';
import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap';
import { SignRequestDetail } from '@app/types/signingSession';
import { LocalEmbedPDFWithAnnotations, AnnotationAPI } from '@app/components/viewer/LocalEmbedPDFWithAnnotations';
import WetSignatureInput from '@app/components/tools/certSign/WetSignatureInput';
import SignatureSettingsDisplay from '@app/components/tools/certSign/SignatureSettingsDisplay';
import { alert } from '@app/components/toast';
export interface SignRequestWorkbenchData {
signRequest: SignRequestDetail;
pdfFile: File;
onSign: (certificateData: FormData) => Promise<void>;
onDecline: () => Promise<void>;
onBack: () => void;
canSign: boolean;
}
interface SignRequestWorkbenchViewProps {
data: SignRequestWorkbenchData;
}
const SignRequestWorkbenchView = ({ data }: SignRequestWorkbenchViewProps) => {
const { t } = useTranslation();
const { signRequest, pdfFile, onSign, onDecline, onBack, canSign } = data;
// Ref for annotation API
const annotationApiRef = useRef<AnnotationAPI | null>(null);
// State for certificate selection
const [certType, setCertType] = useState<'SERVER' | 'USER_CERT' | 'UPLOAD'>('USER_CERT');
const [p12File, setP12File] = useState<File | null>(null);
const [password, setPassword] = useState('');
const [signing, setSigning] = useState(false);
const [declining, setDeclining] = useState(false);
// State for wet signature
const [signatureType, setSignatureType] = useState<'canvas' | 'image' | 'text'>('canvas');
const [signatureData, setSignatureData] = useState<string | undefined>();
const [annotations, setAnnotations] = useState<any[]>([]);
const [placementMode, setPlacementMode] = useState(false);
// Check if signature is ready to be placed
const hasSignatureData = signatureData !== undefined && signatureData.trim() !== '';
// Enable placement mode when user has signature data
const handlePlaceSignature = () => {
if (!hasSignatureData) return;
setPlacementMode(true);
alert({
alertType: 'neutral',
title: t('certSign.collab.signRequest.placeSignature.title', 'Place Signature'),
body: t('certSign.collab.signRequest.placeSignature.message', 'Click on the PDF to place your signature'),
});
};
// Handle signature placement when user clicks on PDF
const handlePlaceSignatureAtPosition = (
pageIndex: number,
x: number,
y: number,
width: number,
height: number
) => {
if (!signatureData) return;
// Update annotations state with position
setAnnotations([{ pageIndex, rect: { x, y, width, height } }]);
// Disable placement mode
setPlacementMode(false);
alert({
alertType: 'success',
title: t('success'),
body: t('certSign.collab.signRequest.signaturePlaced', 'Signature placed on page') + ` ${pageIndex + 1}`,
});
};
const handleSign = async () => {
setSigning(true);
try {
const formData = new FormData();
formData.append('certType', certType);
if (certType === 'UPLOAD') {
if (!p12File) {
alert({
alertType: 'error',
title: t('error'),
body: t('certSign.collab.signRequest.noCertificate', 'Please select a certificate file'),
});
setSigning(false);
return;
}
formData.append('p12File', p12File);
formData.append('password', password);
}
// Add wet signature metadata if user placed a signature
if (annotations.length > 0 && hasSignatureData) {
const annotation = annotations[0]; // Get the first (and should be only) annotation
// Send as individual form fields (backend expects flat structure)
formData.append('wetSignatureType', signatureType);
formData.append('wetSignatureData', signatureData);
formData.append('wetSignaturePage', String(annotation.pageIndex || 0));
formData.append('wetSignatureX', String(annotation.rect?.x || 0));
formData.append('wetSignatureY', String(annotation.rect?.y || 0));
formData.append('wetSignatureWidth', String(annotation.rect?.width || 100));
formData.append('wetSignatureHeight', String(annotation.rect?.height || 50));
}
await onSign(formData);
} 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);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden' }}>
{/* Top Control Bar */}
<Paper p="sm" shadow="sm" style={{ flexShrink: 0, zIndex: 10 }}>
<Group justify="space-between">
<Group gap="md">
<Button
leftSection={<ArrowBackIcon />}
variant="subtle"
onClick={onBack}
size="sm"
>
{t('certSign.collab.signRequest.backToList', 'Back to Sign Requests')}
</Button>
<Divider orientation="vertical" />
<Stack gap={2}>
<Text size="sm" fw={600}>
{signRequest.documentName}
</Text>
<Text size="xs" c="dimmed">
{t('certSign.collab.signRequest.from', 'From')}: {signRequest.ownerUsername} {new Date(signRequest.createdAt).toLocaleDateString()}
</Text>
</Stack>
</Group>
<Group gap="xs">
<Button.Group>
<Button
variant="default"
size="sm"
onClick={() => annotationApiRef.current?.zoomOut()}
title={t('viewer.zoomOut', 'Zoom out')}
>
<ZoomOutIcon fontSize="small" />
</Button>
<Button
variant="default"
size="sm"
onClick={() => annotationApiRef.current?.resetZoom()}
title={t('viewer.resetZoom', 'Reset zoom')}
>
<ZoomOutMapIcon fontSize="small" />
</Button>
<Button
variant="default"
size="sm"
onClick={() => annotationApiRef.current?.zoomIn()}
title={t('viewer.zoomIn', 'Zoom in')}
>
<ZoomInIcon fontSize="small" />
</Button>
</Button.Group>
</Group>
{canSign && (
<Group gap="sm">
<Button
leftSection={<CheckCircleIcon />}
color="green"
onClick={handleSign}
loading={signing}
>
{t('certSign.collab.signRequest.signButton', 'Sign Document')}
</Button>
<Button
leftSection={<CancelIcon />}
color="red"
variant="outline"
onClick={handleDecline}
loading={declining}
>
{t('certSign.collab.signRequest.declineButton', 'Decline')}
</Button>
</Group>
)}
</Group>
</Paper>
{/* Main Content Area */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Left Panel - Signature Input */}
{canSign && (
<Paper
p="md"
shadow="sm"
style={{
width: '360px',
flexShrink: 0,
overflowY: 'auto',
borderRight: '1px solid var(--mantine-color-gray-3)'
}}
>
<Stack gap="md">
<Text size="md" fw={600}>
{t('certSign.collab.signRequest.addSignature', 'Add Your Signature')}
</Text>
<WetSignatureInput
onSignatureDataChange={setSignatureData}
onSignatureTypeChange={setSignatureType}
onCertTypeChange={setCertType}
onP12FileChange={setP12File}
onPasswordChange={setPassword}
certType={certType}
p12File={p12File}
password={password}
disabled={signing || declining}
/>
<Button
leftSection={<AddCircleIcon />}
onClick={handlePlaceSignature}
disabled={!hasSignatureData || placementMode || signing || declining}
fullWidth
variant="light"
>
{placementMode
? t('certSign.collab.signRequest.placementActive', 'Click PDF to place')
: t('certSign.collab.signRequest.placeSignatureButton', 'Place Signature on PDF')}
</Button>
{annotations.length > 0 && (
<Text size="xs" c="green">
{t('certSign.collab.signRequest.signaturePlaced', 'Signature placed on page')} {annotations[0]?.pageIndex + 1 || 1}
</Text>
)}
</Stack>
</Paper>
)}
{/* Center - PDF Viewer */}
<div style={{ flex: 1, overflow: 'hidden', position: 'relative' }}>
<LocalEmbedPDFWithAnnotations
ref={annotationApiRef}
file={pdfFile}
onAnnotationChange={setAnnotations}
placementMode={placementMode}
signatureData={signatureData}
onPlaceSignature={handlePlaceSignatureAtPosition}
/>
</div>
{/* Right Panel - Signature Settings Display */}
<Paper
p="md"
shadow="sm"
style={{
width: '320px',
flexShrink: 0,
overflowY: 'auto',
borderLeft: '1px solid var(--mantine-color-gray-3)'
}}
>
<Stack gap="md">
<Text size="md" fw={600}>
{t('certSign.collab.signRequest.signatureSettings', 'Signature Settings')}
</Text>
<SignatureSettingsDisplay
showSignature={signRequest.showSignature ?? false}
pageNumber={signRequest.pageNumber}
reason={signRequest.reason}
location={signRequest.location}
showLogo={signRequest.showLogo ?? false}
/>
{signRequest.message && (
<Paper p="sm" withBorder>
<Text size="xs" fw={600} mb="xs">
{t('certSign.collab.signRequest.message', 'Message')}
</Text>
<Text size="xs">{signRequest.message}</Text>
</Paper>
)}
{signRequest.dueDate && (
<Paper p="sm" withBorder>
<Text size="xs" fw={600} mb="xs">
{t('certSign.collab.signRequest.dueDate', 'Due Date')}
</Text>
<Text size="xs">{signRequest.dueDate}</Text>
</Paper>
)}
</Stack>
</Paper>
</div>
</div>
);
};
export default SignRequestWorkbenchView;
@@ -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 (
<Stack gap="sm">
<Paper p="sm" withBorder>
<Stack gap="xs">
<Group gap="xs" justify="space-between">
<Text size="xs" c="dimmed">
{t('certSign.appearance.visibility', 'Visibility')}
</Text>
<Group gap="xs">
{showSignature ? (
<>
<VisibilityIcon style={{ fontSize: '16px', color: 'var(--mantine-color-green-6)' }} />
<Badge size="sm" color="green" variant="light">
{t('certSign.appearance.visible', 'Visible')}
</Badge>
</>
) : (
<>
<VisibilityOffIcon style={{ fontSize: '16px', color: 'var(--mantine-color-gray-6)' }} />
<Badge size="sm" color="gray" variant="light">
{t('certSign.appearance.invisible', 'Invisible')}
</Badge>
</>
)}
</Group>
</Group>
{showSignature && (
<>
{pageNumber && (
<Group gap="xs" justify="space-between">
<Text size="xs" c="dimmed">
{t('certSign.pageNumber', 'Page Number')}
</Text>
<Text size="xs" fw={600}>
{pageNumber}
</Text>
</Group>
)}
{reason && (
<Stack gap={4}>
<Text size="xs" c="dimmed">
{t('certSign.reason', 'Reason')}
</Text>
<Paper p="xs" withBorder bg="gray.0">
<Text size="xs">{reason}</Text>
</Paper>
</Stack>
)}
{location && (
<Stack gap={4}>
<Text size="xs" c="dimmed">
{t('certSign.location', 'Location')}
</Text>
<Paper p="xs" withBorder bg="gray.0">
<Text size="xs">{location}</Text>
</Paper>
</Stack>
)}
<Group gap="xs" justify="space-between">
<Text size="xs" c="dimmed">
{t('certSign.logoTitle', 'Logo')}
</Text>
<Group gap="xs">
{showLogo ? (
<>
<CheckIcon style={{ fontSize: '16px', color: 'var(--mantine-color-green-6)' }} />
<Badge size="sm" color="green" variant="light">
{t('certSign.showLogo', 'Show Logo')}
</Badge>
</>
) : (
<>
<CloseIcon style={{ fontSize: '16px', color: 'var(--mantine-color-gray-6)' }} />
<Badge size="sm" color="gray" variant="light">
{t('certSign.noLogo', 'No Logo')}
</Badge>
</>
)}
</Group>
</Group>
</>
)}
</Stack>
</Paper>
<Paper p="xs" withBorder bg="blue.0">
<Text size="xs" c="blue.9">
{t(
'certSign.collab.signRequest.signatureInfo',
'These settings are configured by the document owner'
)}
</Text>
</Paper>
</Stack>
);
};
export default SignatureSettingsDisplay;
@@ -0,0 +1,98 @@
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;
}
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 (
<Stack gap="sm">
<Text size="sm" fw={600}>
{t('certSign.collab.signatureSettings.title', 'Signature Appearance')}
</Text>
<Text size="xs" c="dimmed">
{t('certSign.collab.signatureSettings.description', 'Configure how signatures will appear for all participants')}
</Text>
{/* Signature Visibility */}
<div style={{ display: 'flex', gap: '4px' }}>
<Button
variant={!value.showSignature ? 'filled' : 'outline'}
color={!value.showSignature ? 'blue' : 'var(--text-muted)'}
onClick={() => handleChange('showSignature', false)}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
{t('certSign.appearance.invisible', 'Invisible')}
</div>
</Button>
<Button
variant={value.showSignature ? 'filled' : 'outline'}
color={value.showSignature ? 'blue' : 'var(--text-muted)'}
onClick={() => handleChange('showSignature', true)}
disabled={disabled}
style={{ flex: 1, height: 'auto', minHeight: '40px', fontSize: '11px' }}
>
<div style={{ textAlign: 'center', lineHeight: '1.1', fontSize: '11px' }}>
{t('certSign.appearance.visible', 'Visible')}
</div>
</Button>
</div>
{/* Visible Signature Options */}
{value.showSignature && (
<Stack gap="sm">
<TextInput
label={t('certSign.reason', 'Reason')}
value={value.reason || ''}
onChange={(event) => handleChange('reason', event.currentTarget.value)}
disabled={disabled}
size="xs"
/>
<TextInput
label={t('certSign.location', 'Location')}
value={value.location || ''}
onChange={(event) => handleChange('location', event.currentTarget.value)}
disabled={disabled}
size="xs"
/>
<NumberInput
label={t('certSign.pageNumber', 'Page Number')}
value={value.pageNumber || 1}
onChange={(val) => handleChange('pageNumber', val || 1)}
min={1}
disabled={disabled}
size="xs"
/>
<Switch
label={t('certSign.showLogo', 'Show Stirling PDF Logo')}
checked={value.showLogo || false}
onChange={(event) => handleChange('showLogo', event.currentTarget.checked)}
disabled={disabled}
size="sm"
/>
</Stack>
)}
</Stack>
);
};
export default SignatureSettingsInput;
@@ -0,0 +1,86 @@
import { Stack, Text, TextInput, Switch, Textarea, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import InfoIcon from '@mui/icons-material/Info';
import { SigningWorkflowParameters } from '@app/hooks/tools/certSign/useSigningWorkflowParameters';
import UserSelector from '@app/components/tools/certSign/UserSelector';
import SignatureSettingsInput from '@app/components/tools/certSign/SignatureSettingsInput';
interface SigningCollaborationSettingsProps {
parameters: SigningWorkflowParameters;
onParameterChange: (key: keyof SigningWorkflowParameters, value: any) => void;
disabled?: boolean;
}
const SigningCollaborationSettings = ({ parameters, onParameterChange, disabled = false }: SigningCollaborationSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="sm">
<Alert icon={<InfoIcon fontSize="small" />} radius="md" color="blue" variant="light" p="xs">
<Text size="xs">
{t('certSign.collab.helper', 'Select users from your organization to participate in signing. You can configure signature appearance and set a due date.')}
</Text>
</Alert>
<UserSelector
value={parameters.participantUserIds}
onChange={(userIds) => onParameterChange('participantUserIds', userIds)}
placeholder={t('certSign.collab.userSelector.placeholder', 'Select users...')}
size="xs"
disabled={disabled}
/>
<SignatureSettingsInput
value={{
showSignature: parameters.showSignature,
pageNumber: parameters.pageNumber,
reason: parameters.reason,
location: parameters.location,
showLogo: parameters.showLogo,
}}
onChange={(settings) => {
onParameterChange('showSignature', settings.showSignature);
onParameterChange('pageNumber', settings.pageNumber);
onParameterChange('reason', settings.reason);
onParameterChange('location', settings.location);
onParameterChange('showLogo', settings.showLogo);
}}
disabled={disabled}
/>
<Textarea
label={t('certSign.collab.messageLabel', 'Message to include in invitations')}
placeholder={t('certSign.collab.message.placeholder', 'Please review and sign this document by the due date.')}
value={parameters.message}
onChange={(event) => onParameterChange('message', event.currentTarget.value)}
disabled={disabled}
autosize
minRows={2}
size="xs"
/>
<TextInput
label={t('certSign.collab.dueDate', 'Due date (optional, ISO date)')}
placeholder="2025-01-31"
value={parameters.dueDate}
onChange={(event) => onParameterChange('dueDate', event.currentTarget.value)}
disabled={disabled}
size="xs"
/>
<Switch
label={t('certSign.collab.notify', 'Send notifications immediately')}
checked={parameters.notifyOnCreate}
onChange={(event) => onParameterChange('notifyOnCreate', event.currentTarget.checked)}
disabled={disabled}
size="xs"
/>
<Text size="xs" c="dimmed">
{t('certSign.collab.footer', 'After submitting, users will see sign requests in their inbox. You can track status and finalize when all signatures are collected.')}
</Text>
</Stack>
);
};
export default SigningCollaborationSettings;
@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MultiSelect, Loader } from '@mantine/core';
import { alert } from '@app/components/toast';
import { UserSummary } from '@app/types/signingSession';
import apiClient from '@app/services/apiClient';
import { useAuth } from '@app/auth/UseSession';
interface UserSelectorProps {
value: number[];
onChange: (userIds: number[]) => void;
placeholder?: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
disabled?: boolean;
}
type SelectItem = { value: string; label: string };
type GroupedData = { group: string; items: SelectItem[] };
const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = false }: UserSelectorProps) => {
const { t } = useTranslation();
const { user } = useAuth();
const [_users, setUsers] = useState<UserSummary[]>([]);
const [selectData, setSelectData] = useState<GroupedData[]>([]);
const [loading, setLoading] = useState(true);
const [stringValue, setStringValue] = useState<string[]>([]);
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await apiClient.get('/api/v1/user/users');
console.log('Users API response:', response.data);
const fetchedUsers = response.data || [];
setUsers(fetchedUsers);
// Process selectData inside useEffect - group by team
const usersByTeam: Record<string, SelectItem[]> = {};
const currentUserId = user?.id ? parseInt(user.id, 10) : null;
fetchedUsers
.filter((u: UserSummary) => u && u.userId && u.username)
.filter((u: UserSummary) => u.userId !== currentUserId) // Exclude current user
.filter((u: UserSummary) => u.teamName?.toLowerCase() !== 'internal') // Exclude internal users
.forEach((user: UserSummary) => {
const teamName = user.teamName || t('certSign.collab.userSelector.noTeam', 'No Team');
if (!usersByTeam[teamName]) {
usersByTeam[teamName] = [];
}
usersByTeam[teamName].push({
value: String(user.userId),
label: `${user.displayName || user.username || 'Unknown'} (@${user.username || 'unknown'})`,
});
});
// Convert to Mantine's grouped format
const processed: GroupedData[] = Object.entries(usersByTeam).map(([teamName, items]) => ({
group: teamName,
items: items.sort((a, b) => a.label.localeCompare(b.label)),
}));
console.log('Processed selectData:', processed);
setSelectData(processed);
} catch (error) {
console.error('Failed to load users:', error);
alert({
alertType: 'error',
title: t('error'),
body: t('certSign.collab.userSelector.loadError', 'Failed to load users'),
});
} finally {
setLoading(false);
}
};
fetchUsers();
}, [t]);
// Process stringValue when value prop changes
useEffect(() => {
const safeValue = Array.isArray(value) ? value : [];
const result = safeValue.map((id) => (id != null ? id.toString() : '')).filter(Boolean);
console.log('stringValue for MultiSelect:', result);
setStringValue(result);
}, [value]);
if (loading) {
return <Loader size="sm" />;
}
// Don't render if we don't have data ready
if (!selectData || selectData.length === 0) {
return <Loader size="sm" />;
}
return (
<MultiSelect
data={selectData}
value={stringValue}
onChange={(selectedIds) => {
const parsedIds = selectedIds
.map((id) => parseInt(id, 10))
.filter((id) => !isNaN(id));
onChange(parsedIds);
}}
placeholder={placeholder || t('certSign.collab.userSelector.placeholder', 'Select users...')}
searchable
clearable
size={size}
disabled={disabled}
maxDropdownHeight={300}
/>
);
};
export default UserSelector;
@@ -0,0 +1,282 @@
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<SignatureType>('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<string | undefined>();
// Image upload state
const [imageSignatureData, setImageSignatureData] = useState<string | undefined>();
// 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<string>((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 (
<Stack gap="xs">
<Text size="xs" c="dimmed">
{t('certSign.collab.signRequest.drawSignature', 'Draw your signature below')}
</Text>
<DrawingCanvas
selectedColor={selectedColor}
penSize={penSize}
penSizeInput={penSizeInput}
onColorSwatchClick={() => setIsColorPickerOpen(true)}
onPenSizeChange={setPenSize}
onPenSizeInputChange={setPenSizeInput}
onSignatureDataChange={handleCanvasSignatureChange}
onDrawingComplete={() => {}}
disabled={disabled}
initialSignatureData={canvasSignatureData}
/>
</Stack>
);
}
if (signatureType === 'image') {
return (
<Stack gap="xs">
<Text size="xs" c="dimmed">
{t('certSign.collab.signRequest.uploadSignature', 'Upload your signature image')}
</Text>
<ImageUploader onImageChange={handleImageChange} disabled={disabled} />
</Stack>
);
}
return (
<Stack gap="xs">
<Text size="xs" c="dimmed">
{t('certSign.collab.signRequest.typeSignature', 'Type your name to create a signature')}
</Text>
<TextInputWithFont
text={signerName}
onTextChange={setSignerName}
fontSize={fontSize}
onFontSizeChange={setFontSize}
fontFamily={fontFamily}
onFontFamilyChange={setFontFamily}
textColor={textColor}
onTextColorChange={setTextColor}
disabled={disabled}
onAnyChange={() => {}}
/>
</Stack>
);
};
return (
<Stack gap="md">
{/* Signature Type Selector */}
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('certSign.collab.signRequest.signatureType', 'Signature Type')}
</Text>
<SegmentedControl
value={signatureType}
fullWidth
onChange={(value) => 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}
/>
</Stack>
{/* Signature Builder */}
{renderSignatureBuilder()}
<Divider />
{/* Certificate Selection */}
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('certSign.collab.signRequest.certificateChoice', 'Certificate Choice')}
</Text>
<Radio.Group
value={certType}
onChange={(value) => onCertTypeChange(value as CertificateType)}
>
<Stack gap="xs">
<Radio
value="USER_CERT"
label={t('certSign.collab.signRequest.usePersonalCert', 'Use My Personal Certificate')}
description={t('certSign.collab.signRequest.usePersonalCertDesc', 'Auto-generated for your account')}
disabled={disabled}
/>
<Radio
value="SERVER"
label={t('certSign.collab.signRequest.useServerCert', 'Use Organization Certificate')}
description={t('certSign.collab.signRequest.useServerCertDesc', 'Shared organization certificate')}
disabled={disabled}
/>
<Radio
value="UPLOAD"
label={t('certSign.collab.signRequest.uploadCert', 'Upload Custom Certificate')}
description={t('certSign.collab.signRequest.uploadCertDesc', 'Use your own PKCS12 certificate')}
disabled={disabled}
/>
</Stack>
</Radio.Group>
{certType === 'UPLOAD' && (
<Stack gap="xs" mt="xs">
<FileInput
label={t('certSign.collab.signRequest.p12File', 'P12/PFX Certificate File')}
placeholder={t('certSign.collab.signRequest.selectFile', 'Select file...')}
accept=".p12,.pfx"
value={p12File}
onChange={onP12FileChange}
size="xs"
disabled={disabled}
/>
<PasswordInput
label={t('certSign.collab.signRequest.password', 'Certificate Password')}
value={password}
onChange={(event) => onPasswordChange(event.currentTarget.value)}
size="xs"
disabled={disabled}
/>
</Stack>
)}
</Stack>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
title={t('sign.canvas.colorPickerTitle', 'Choose stroke colour')}
/>
</Stack>
);
};
export default WetSignatureInput;
@@ -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 { EmbedPDF } from '@embedpdf/core/react';
import { usePdfiumEngine } from '@embedpdf/engines/react';
@@ -40,14 +40,82 @@ interface LocalEmbedPDFWithAnnotationsProps {
file?: File | Blob;
url?: string | null;
onAnnotationChange?: (annotations: any[]) => void;
placementMode?: boolean;
signatureData?: string;
onPlaceSignature?: (pageIndex: number, x: number, y: number, width: number, height: number) => void;
}
export function LocalEmbedPDFWithAnnotations({
export interface AnnotationAPI {
setActiveTool: (toolId: string | null) => void;
setToolDefaults: (toolId: string, defaults: any) => void;
getActiveTool: () => any;
getPageAnnotations: (pageIndex: number) => Promise<any[]>;
getAllAnnotations: () => Promise<any[]>;
zoomIn: () => void;
zoomOut: () => void;
resetZoom: () => void;
}
export const LocalEmbedPDFWithAnnotations = forwardRef<AnnotationAPI | null, LocalEmbedPDFWithAnnotationsProps>(({
file,
url,
onAnnotationChange
}: LocalEmbedPDFWithAnnotationsProps) {
onAnnotationChange,
placementMode = false,
signatureData,
onPlaceSignature
}, ref) => {
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const annotationApiRef = useRef<any>(null);
const zoomApiRef = useRef<any>(null);
// State for signature preview overlay
const [signaturePreview, setSignaturePreview] = useState<{
pageIndex: number;
x: number;
y: number;
width: number;
height: 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 [];
},
zoomIn: () => {
zoomApiRef.current?.zoomIn();
},
zoomOut: () => {
zoomApiRef.current?.zoomOut();
},
resetZoom: () => {
zoomApiRef.current?.resetZoom();
},
}), []);
// Convert File to URL if needed
useEffect(() => {
@@ -189,21 +257,33 @@ export function LocalEmbedPDFWithAnnotations({
engine={engine}
plugins={plugins}
onInitialized={async (registry) => {
// Store zoom API reference
const zoomPlugin = registry.getPlugin('zoom');
if (zoomPlugin && zoomPlugin.provides) {
zoomApiRef.current = zoomPlugin.provides();
}
const annotationPlugin = registry.getPlugin('annotation');
if (!annotationPlugin || !annotationPlugin.provides) return;
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,
},
});
@@ -281,12 +361,35 @@ export function LocalEmbedPDFWithAnnotations({
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none'
msUserSelect: 'none',
cursor: placementMode ? 'crosshair' : 'default'
}}
draggable={false}
onDragStart={(e) => e.preventDefault()}
onDrop={(e) => e.preventDefault()}
onDragOver={(e) => e.preventDefault()}
onClick={(e) => {
if (placementMode && onPlaceSignature) {
const rect = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - rect.left) / scale;
const y = (e.clientY - rect.top) / scale;
// Default signature size: 150x75 pts
const sigWidth = 150;
const sigHeight = 75;
// Show preview
setSignaturePreview({
pageIndex,
x,
y,
width: sigWidth,
height: sigHeight,
});
// Notify parent
onPlaceSignature(pageIndex, x, y, sigWidth, sigHeight);
}
}}
>
{/* High-resolution tile layer */}
<TilingLayer pageIndex={pageIndex} scale={scale} />
@@ -306,6 +409,155 @@ export function LocalEmbedPDFWithAnnotations({
rotation={rotation || 0}
selectionOutlineColor="#007ACC"
/>
{/* Signature preview overlay */}
{signaturePreview &&
signaturePreview.pageIndex === pageIndex &&
signatureData && (
<div
style={{
position: 'absolute',
left: signaturePreview.x * scale,
top: signaturePreview.y * scale,
width: signaturePreview.width * scale,
height: signaturePreview.height * scale,
border: '2px solid #007ACC',
boxShadow: '0 0 10px rgba(0, 122, 204, 0.5)',
cursor: 'move',
zIndex: 1000,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
onMouseDown={(e) => {
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const startLeft = signaturePreview.x;
const startTop = signaturePreview.y;
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = (moveEvent.clientX - startX) / scale;
const deltaY = (moveEvent.clientY - startY) / scale;
setSignaturePreview({
...signaturePreview,
x: startLeft + deltaX,
y: startTop + deltaY,
});
// Update parent with new position
if (onPlaceSignature) {
onPlaceSignature(
pageIndex,
startLeft + deltaX,
startTop + deltaY,
signaturePreview.width,
signaturePreview.height
);
}
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}}
>
<img
src={signatureData}
alt="Signature preview"
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
pointerEvents: 'none',
}}
/>
{/* 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) => (
<div
key={handle.position}
style={{
position: 'absolute',
width: 8,
height: 8,
backgroundColor: '#007ACC',
border: '1px solid white',
cursor: handle.cursor,
zIndex: 1001,
...(handle.top !== undefined && { top: handle.top }),
...(handle.bottom !== undefined && { bottom: handle.bottom }),
...(handle.left !== undefined && { left: handle.left }),
...(handle.right !== undefined && { right: handle.right }),
}}
onMouseDown={(e) => {
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const startWidth = signaturePreview.width;
const startHeight = signaturePreview.height;
const startLeft = signaturePreview.x;
const startTop = signaturePreview.y;
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = (moveEvent.clientX - startX) / scale;
const deltaY = (moveEvent.clientY - startY) / scale;
let newWidth = startWidth;
let newHeight = startHeight;
let newX = startLeft;
let newY = startTop;
// Calculate new dimensions based on handle position
if (handle.position.includes('e')) {
newWidth = Math.max(50, startWidth + deltaX);
}
if (handle.position.includes('w')) {
newWidth = Math.max(50, startWidth - deltaX);
newX = startLeft + (startWidth - newWidth);
}
if (handle.position.includes('s')) {
newHeight = Math.max(25, startHeight + deltaY);
}
if (handle.position.includes('n')) {
newHeight = Math.max(25, startHeight - deltaY);
newY = startTop + (startHeight - newHeight);
}
setSignaturePreview({
pageIndex,
x: newX,
y: newY,
width: newWidth,
height: newHeight,
});
// Update parent with new dimensions
if (onPlaceSignature) {
onPlaceSignature(pageIndex, newX, newY, newWidth, newHeight);
}
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}}
/>
))}
</div>
)}
</div>
</PagePointerProvider>
</Rotate>
@@ -316,4 +568,4 @@ export function LocalEmbedPDFWithAnnotations({
</EmbedPDF>
</div>
);
}
});
@@ -40,6 +40,7 @@ import UnlockPdfForms from "@app/tools/UnlockPdfForms";
import RemoveCertificateSign from "@app/tools/RemoveCertificateSign";
import RemoveImage from "@app/tools/RemoveImage";
import CertSign from "@app/tools/CertSign";
import SigningWorkflow from "@app/tools/SigningWorkflow";
import BookletImposition from "@app/tools/BookletImposition";
import Flatten from "@app/tools/Flatten";
import Rotate from "@app/tools/Rotate";
@@ -64,6 +65,7 @@ import { convertOperationConfig } from "@app/hooks/tools/convert/useConvertOpera
import { removeCertificateSignOperationConfig } from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
import { signingWorkflowOperationConfig } from "@app/hooks/tools/certSign/useSigningWorkflowOperation";
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
import { mergeOperationConfig } from '@app/hooks/tools/merge/useMergeOperation';
import { editTableOfContentsOperationConfig } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation';
@@ -190,6 +192,20 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
operationConfig: certSignOperationConfig,
automationSettings: CertSignAutomationSettings,
},
signingWorkflow: {
icon: <LocalIcon icon="diversity-2-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.signingWorkflow.title", "Shared signing"),
component: SigningWorkflow,
description: t("home.signingWorkflow.desc", "Invite collaborators, issue notifications, and manage certificate collection."),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.SIGNING,
synonyms: getSynonyms(t, "signingWorkflow"),
maxFiles: -1,
supportedFormats: ['pdf', 'json'],
endpoints: ["cert-sign/sessions"],
operationConfig: signingWorkflowOperationConfig,
automationSettings: null,
},
sign: {
icon: <LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.sign.title", "Sign"),
@@ -0,0 +1,132 @@
import { useState, useCallback } from 'react';
import apiClient from '@app/services/apiClient';
import { SignRequestSummary, SignRequestDetail } from '@app/types/signingSession';
export const useSignRequestManagement = () => {
const [signRequests, setSignRequests] = useState<SignRequestSummary[]>([]);
const [activeRequest, setActiveRequest] = useState<SignRequestDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchSignRequests = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get('/api/v1/security/cert-sign/sign-requests');
setSignRequests(response.data);
} catch (err: any) {
console.error('Failed to fetch sign requests:', err);
setError(err.response?.data?.message || 'Failed to fetch sign requests');
setSignRequests([]);
} finally {
setLoading(false);
}
}, []);
const fetchSignRequestDetail = useCallback(async (sessionId: string) => {
setLoading(true);
setError(null);
try {
// Fetch sign request detail from participant endpoint
const response = await apiClient.get(`/api/v1/security/cert-sign/sign-requests/${sessionId}`);
setActiveRequest(response.data);
} catch (err: any) {
console.error('Failed to fetch sign request detail:', err);
setError(err.response?.data?.message || 'Failed to fetch sign request details');
setActiveRequest(null);
} finally {
setLoading(false);
}
}, []);
const signRequest = useCallback(async (sessionId: string, userId: number, certificateData: FormData) => {
setLoading(true);
setError(null);
try {
// Use the new /sign endpoint that supports both certificates and wet signatures
const response = await apiClient.post(
`/api/v1/security/cert-sign/sessions/${sessionId}/sign`,
certificateData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
// Refresh sign requests list after signing
await fetchSignRequests();
return response.data;
} catch (err: any) {
console.error('Failed to sign request:', err);
setError(err.response?.data || 'Failed to sign document');
throw err;
} finally {
setLoading(false);
}
}, [fetchSignRequests]);
const declineRequest = useCallback(async (sessionId: string) => {
setLoading(true);
setError(null);
try {
await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/decline`);
// Refresh sign requests list after declining
await fetchSignRequests();
setActiveRequest(null);
} catch (err: any) {
console.error('Failed to decline request:', err);
setError(err.response?.data || 'Failed to decline request');
throw err;
} finally {
setLoading(false);
}
}, [fetchSignRequests]);
const fetchSessionPdf = useCallback(async (sessionId: string, documentName?: string) => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/api/v1/security/cert-sign/sessions/${sessionId}/pdf`,
{ responseType: 'blob' }
);
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers['content-disposition'];
let filename = `document-${sessionId}.pdf`;
if (contentDisposition) {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (match && match[1]) {
filename = match[1].replace(/['"]/g, '');
}
} else if (documentName) {
filename = documentName;
}
// Create File object
const pdfBlob = new Blob([response.data], { type: 'application/pdf' });
const file = new File([pdfBlob], filename, { type: 'application/pdf' });
return file;
} catch (err: any) {
console.error('Failed to fetch session PDF:', err);
setError(err.response?.data || 'Failed to fetch PDF');
throw err;
} finally {
setLoading(false);
}
}, []);
return {
signRequests,
activeRequest,
loading,
error,
fetchSignRequests,
fetchSignRequestDetail,
signRequest,
declineRequest,
fetchSessionPdf,
setActiveRequest,
};
};
@@ -0,0 +1,183 @@
import { useState, useCallback } from 'react';
import apiClient from '@app/services/apiClient';
import { SessionSummary, SessionDetail } from '@app/types/signingSession';
export const useSigningSessionManagement = () => {
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [activeSession, setActiveSession] = useState<SessionDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchSessions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get('/api/v1/security/cert-sign/sessions');
setSessions(response.data);
} catch (err: any) {
console.error('Failed to fetch sessions:', err);
setError(err.response?.data?.message || 'Failed to fetch sessions');
setSessions([]);
} finally {
setLoading(false);
}
}, []);
const fetchSessionDetail = useCallback(async (sessionId: string) => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get(`/api/v1/security/cert-sign/sessions/${sessionId}`);
setActiveSession(response.data);
} catch (err: any) {
console.error('Failed to fetch session detail:', err);
setError(err.response?.data?.message || 'Failed to fetch session details');
setActiveSession(null);
} finally {
setLoading(false);
}
}, []);
const deleteSession = useCallback(async (sessionId: string) => {
setLoading(true);
setError(null);
try {
await apiClient.delete(`/api/v1/security/cert-sign/sessions/${sessionId}`);
// Refresh sessions list after deletion
await fetchSessions();
setActiveSession(null);
} catch (err: any) {
console.error('Failed to delete session:', err);
setError(err.response?.data || 'Failed to delete session');
throw err;
} finally {
setLoading(false);
}
}, [fetchSessions]);
const addParticipants = useCallback(
async (sessionId: string, participants: { participantUserIds: number[] }) => {
setLoading(true);
setError(null);
try {
const response = await apiClient.post(
`/api/v1/security/cert-sign/sessions/${sessionId}/participants`,
participants
);
setActiveSession(response.data);
return response.data;
} catch (err: any) {
console.error('Failed to add participants:', err);
setError(err.response?.data || 'Failed to add participants');
throw err;
} finally {
setLoading(false);
}
},
[]
);
const removeParticipant = useCallback(async (sessionId: string, userId: number) => {
setLoading(true);
setError(null);
try {
await apiClient.delete(
`/api/v1/security/cert-sign/sessions/${sessionId}/participants/${userId}`
);
// Refresh session detail after removal
await fetchSessionDetail(sessionId);
} catch (err: any) {
console.error('Failed to remove participant:', err);
setError(err.response?.data || 'Failed to remove participant');
throw err;
} finally {
setLoading(false);
}
}, [fetchSessionDetail]);
const finalizeSession = useCallback(async (sessionId: string, documentName?: string) => {
setLoading(true);
setError(null);
try {
const response = await apiClient.post(
`/api/v1/security/cert-sign/sessions/${sessionId}/finalize`,
{},
{ responseType: 'blob' }
);
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers['content-disposition'];
let filename = `signed-${sessionId}.pdf`;
if (contentDisposition) {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (match && match[1]) {
filename = match[1].replace(/['"]/g, '');
}
} else if (documentName) {
filename = documentName.replace(/\.pdf$/i, '') + '_signed.pdf';
}
// Create File object
const pdfBlob = new Blob([response.data], { type: 'application/pdf' });
const file = new File([pdfBlob], filename, { type: 'application/pdf' });
return file;
} catch (err: any) {
console.error('Failed to finalize session:', err);
setError(err.response?.data || 'Failed to finalize session');
throw err;
} finally {
setLoading(false);
}
}, []);
const loadSignedPdf = useCallback(async (sessionId: string, documentName?: string) => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/api/v1/security/cert-sign/sessions/${sessionId}/signed-pdf`,
{ responseType: 'blob' }
);
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers['content-disposition'];
let filename = `signed-${sessionId}.pdf`;
if (contentDisposition) {
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (match && match[1]) {
filename = match[1].replace(/['"]/g, '');
}
} else if (documentName) {
filename = documentName.replace(/\.pdf$/i, '') + '_signed.pdf';
}
// Create File object
const pdfBlob = new Blob([response.data], { type: 'application/pdf' });
const file = new File([pdfBlob], filename, { type: 'application/pdf' });
return file;
} catch (err: any) {
console.error('Failed to load signed PDF:', err);
setError(err.response?.data || 'Failed to load signed PDF');
throw err;
} finally {
setLoading(false);
}
}, []);
return {
sessions,
activeSession,
loading,
error,
fetchSessions,
fetchSessionDetail,
deleteSession,
addParticipants,
removeParticipant,
finalizeSession,
loadSignedPdf,
setActiveSession,
};
};
@@ -0,0 +1,82 @@
import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { SigningWorkflowParameters, defaultSigningWorkflowParameters } from '@app/hooks/tools/certSign/useSigningWorkflowParameters';
const buildSessionFormData = (parameters: SigningWorkflowParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
parameters.participantUserIds.forEach((userId) => {
formData.append('participantUserIds', userId.toString());
});
if (parameters.message) {
formData.append('message', parameters.message);
}
if (parameters.dueDate) {
formData.append('dueDate', parameters.dueDate);
}
// Signature appearance settings (applied to all participants)
if (parameters.showSignature !== undefined) {
formData.append('showSignature', parameters.showSignature.toString());
}
if (parameters.pageNumber) {
formData.append('pageNumber', parameters.pageNumber.toString());
}
if (parameters.reason) {
formData.append('reason', parameters.reason);
}
if (parameters.location) {
formData.append('location', parameters.location);
}
if (parameters.showLogo !== undefined) {
formData.append('showLogo', parameters.showLogo.toString());
}
return formData;
};
export const signingWorkflowOperationConfig = {
toolType: ToolType.custom,
operationType: 'signingWorkflow',
defaultParameters: defaultSigningWorkflowParameters,
customProcessor: async (parameters: SigningWorkflowParameters, files: File[]) => {
if (files.length === 0) {
throw new Error('A PDF file is required to start a signing workflow');
}
const formData = buildSessionFormData(parameters, files[0]);
const { data } = await apiClient.post('/api/v1/security/cert-sign/sessions', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
// Log session info for easy testing
console.log('\n🔐 Signing Session Created!');
console.log('📄 Session ID:', data.sessionId);
console.log('👥 Participants:', data.participants?.length || 0);
console.log('\n');
if (parameters.notifyOnCreate) {
await apiClient.post(`/api/v1/security/cert-sign/sessions/${data.sessionId}/notify`, {
message: parameters.message,
});
}
// Return empty array - session is created on server, no files produced
return [];
},
} as const;
export const useSigningWorkflowOperation = () => {
const { t } = useTranslation();
return useToolOperation<SigningWorkflowParameters>({
...signingWorkflowOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('certSign.collab.error', 'Unable to start shared signing session. Please verify participant selection and try again.'),
),
});
};
@@ -0,0 +1,39 @@
import { BaseParameters } from '@app/types/parameters';
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
export interface SigningWorkflowParameters extends BaseParameters {
participantUserIds: number[];
message: string;
dueDate: string;
notifyOnCreate: boolean;
// Signature appearance settings (applied to all participants)
showSignature?: boolean;
pageNumber?: number;
reason?: string;
location?: string;
showLogo?: boolean;
}
export const defaultSigningWorkflowParameters: SigningWorkflowParameters = {
participantUserIds: [],
message: '',
dueDate: '',
notifyOnCreate: true,
showSignature: false,
pageNumber: 1,
reason: '',
location: '',
showLogo: false,
};
export type SigningWorkflowParametersHook = BaseParametersHook<SigningWorkflowParameters>;
export const useSigningWorkflowParameters = (): SigningWorkflowParametersHook => {
return useBaseParameters({
defaultParameters: defaultSigningWorkflowParameters,
endpointName: 'signing-workflow',
validateFn: (params) => {
return params.participantUserIds.length > 0;
},
});
};
@@ -0,0 +1,470 @@
import { useState, useEffect } from 'react';
import { Stack, Text, TextInput, Button, Alert, Paper, Title, Group, NumberInput, Switch, Box, ScrollArea } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import InfoIcon from '@mui/icons-material/Info';
import ErrorIcon from '@mui/icons-material/Error';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import apiClient from '@app/services/apiClient';
import FileUploadButton from '@app/components/shared/FileUploadButton';
import { LocalEmbedPDF } from '@app/components/viewer/LocalEmbedPDF';
import { ViewerProvider } from '@app/contexts/ViewerContext';
interface SigningParticipant {
email: string;
name?: string;
shareToken: string;
status: 'PENDING' | 'NOTIFIED' | 'VIEWED' | 'SIGNED';
}
interface SigningSession {
sessionId: string;
documentName: string;
participants: SigningParticipant[];
ownerEmail?: string;
message?: string;
dueDate?: string;
}
interface ParticipantCertificateSubmissionProps {
sessionId: string;
token: string;
}
type CertType = 'PEM' | 'PKCS12' | 'PFX' | 'JKS' | 'SERVER';
export function ParticipantCertificateSubmission({ sessionId, token }: ParticipantCertificateSubmissionProps) {
const { t } = useTranslation();
// Loading and error states
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [session, setSession] = useState<SigningSession | null>(null);
const [participant, setParticipant] = useState<SigningParticipant | null>(null);
// Form state
const [certType, setCertType] = useState<CertType>('PEM');
const [privateKeyFile, setPrivateKeyFile] = useState<File | undefined>(undefined);
const [certFile, setCertFile] = useState<File | undefined>(undefined);
const [p12File, setP12File] = useState<File | undefined>(undefined);
const [jksFile, setJksFile] = useState<File | undefined>(undefined);
const [password, setPassword] = useState('');
// Signature appearance
const [showSignature, setShowSignature] = useState(true);
const [pageNumber, setPageNumber] = useState(1);
const [reason, setReason] = useState('');
const [location, setLocation] = useState('');
const [name, setName] = useState('');
const [showLogo, setShowLogo] = useState(false);
// Submission state
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
// PDF viewer state
const [pdfFile, setPdfFile] = useState<File | null>(null);
const [pdfLoading, setPdfLoading] = useState(false);
// Load session on mount
useEffect(() => {
loadSession();
}, [sessionId, token]);
// Load PDF for viewing
useEffect(() => {
const loadPdf = async () => {
if (session && !pdfFile) {
try {
setPdfLoading(true);
const response = await apiClient.get(`/api/v1/security/cert-sign/sessions/${sessionId}/pdf`, {
params: { token },
responseType: 'blob'
});
const pdfBlob = new Blob([response.data], { type: 'application/pdf' });
const pdfFile = new File([pdfBlob], session.documentName || 'document.pdf', { type: 'application/pdf' });
setPdfFile(pdfFile);
} catch (error) {
console.error('Failed to load PDF:', error);
} finally {
setPdfLoading(false);
}
}
};
loadPdf();
}, [session, sessionId, pdfFile]);
const loadSession = async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.get<SigningSession>(`/api/v1/security/cert-sign/sessions/${sessionId}`);
const sessionData = response.data;
// Find participant by token
const foundParticipant = sessionData.participants.find(p => p.shareToken === token);
if (!foundParticipant) {
setError(t('certSign.collab.participant.invalidToken', 'Invalid or expired session link'));
return;
}
// Check if already signed
if (foundParticipant.status === 'SIGNED') {
setSubmitted(true);
}
setSession(sessionData);
setParticipant(foundParticipant);
// Pre-fill name if available
if (foundParticipant.name) {
setName(foundParticipant.name);
}
} catch (err: any) {
console.error('Failed to load session:', err);
setError(err.response?.data?.message || t('certSign.collab.participant.invalidToken', 'Invalid or expired session link'));
} finally {
setLoading(false);
}
};
const handleSubmit = async () => {
if (!session || !participant) return;
// Validate required fields
if (certType === 'PEM' && (!privateKeyFile || !certFile)) {
setError(t('certSign.collab.participant.submitError', 'Please upload both private key and certificate files'));
return;
}
if (certType === 'PKCS12' && !p12File) {
setError(t('certSign.collab.participant.submitError', 'Please upload PKCS12 file'));
return;
}
if (certType === 'PFX' && !p12File) {
setError(t('certSign.collab.participant.submitError', 'Please upload PFX file'));
return;
}
if (certType === 'JKS' && !jksFile) {
setError(t('certSign.collab.participant.submitError', 'Please upload JKS file'));
return;
}
try {
setSubmitting(true);
setError(null);
const formData = new FormData();
formData.append('certType', certType);
if (password) {
formData.append('password', password);
}
// Add certificate files based on type
if (certType === 'PEM') {
formData.append('privateKeyFile', privateKeyFile!);
formData.append('certFile', certFile!);
} else if (certType === 'PKCS12' || certType === 'PFX') {
formData.append('p12File', p12File!);
} else if (certType === 'JKS') {
formData.append('jksFile', jksFile!);
}
// Add signature appearance
formData.append('showSignature', showSignature.toString());
if (showSignature) {
formData.append('pageNumber', pageNumber.toString());
if (reason) formData.append('reason', reason);
if (location) formData.append('location', location);
if (name) formData.append('name', name);
formData.append('showLogo', showLogo.toString());
}
await apiClient.post(
`/api/v1/security/cert-sign/sessions/${sessionId}/participants/${participant.email}/certificate`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
setSubmitted(true);
} catch (err: any) {
console.error('Failed to submit certificate:', err);
setError(err.response?.data?.message || t('certSign.collab.participant.submitError', 'Failed to submit certificate'));
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<Paper p="xl" withBorder>
<Text>{t('certSign.collab.participant.loading', 'Loading session details...')}</Text>
</Paper>
);
}
if (error && !session) {
return (
<Paper p="xl" withBorder>
<Alert icon={<ErrorIcon />} color="red">
{error}
</Alert>
</Paper>
);
}
if (submitted || participant?.status === 'SIGNED') {
return (
<Paper p="xl" withBorder>
<Alert icon={<CheckCircleIcon />} color="green">
{t('certSign.collab.participant.alreadySigned',
'You have already submitted your certificate for this session. The session owner will finalize all signatures.')}
</Alert>
</Paper>
);
}
return (
<Box style={{ height: '100vh', display: 'flex', flexDirection: 'row' }}>
{/* PDF Viewer - Left Side */}
<Box style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<Paper p="md" withBorder style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, height: '100%' }}>
<Group gap="xs" mb="md">
<PictureAsPdfIcon />
<Title order={3}>{session?.documentName || 'Document'}</Title>
</Group>
{pdfLoading ? (
<Alert icon={<InfoIcon />} color="blue">
Loading PDF...
</Alert>
) : pdfFile ? (
<Box style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<ViewerProvider>
<LocalEmbedPDF
file={pdfFile}
enableAnnotations={false}
/>
</ViewerProvider>
</Box>
) : (
<Alert icon={<InfoIcon />} color="blue">
Preparing document...
</Alert>
)}
</Paper>
</Box>
{/* Form - Right Side */}
<Box style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<ScrollArea style={{ height: '100vh' }}>
<Paper p="xl" withBorder style={{ minHeight: '100vh' }}>
<Stack gap="lg">
<div>
<Title order={2}>{t('certSign.collab.participant.title', 'Submit your certificate')}</Title>
<Text c="dimmed">{t('certSign.collab.participant.subtitle', 'Upload certificate to sign document')}</Text>
</div>
<Alert icon={<InfoIcon />} color="blue" variant="light">
<Stack gap="xs">
<Text size="sm"><strong>{t('certSign.collab.participant.yourEmail', 'Your email')}:</strong> {participant?.email}</Text>
{participant?.name && (
<Text size="sm"><strong>{t('certSign.collab.participant.nameLabel', 'Name')}:</strong> {participant.name}</Text>
)}
</Stack>
</Alert>
<Text size="sm">
{t('certSign.collab.participant.instructions', 'Please upload your certificate files and provide signing details.')}
</Text>
{/* Certificate Type Selection */}
<div>
<Text size="sm" fw={500} mb="xs">
{t('certSign.collab.participant.certTypeLabel', 'Certificate type')}
</Text>
<Group gap="xs">
{(['PEM', 'PKCS12', 'PFX', 'JKS', 'SERVER'] as CertType[]).map((type) => (
<Button
key={type}
variant={certType === type ? 'filled' : 'outline'}
color={certType === type ? 'blue' : 'var(--text-muted)'}
onClick={() => setCertType(type)}
disabled={submitting}
size="sm"
>
{type}
</Button>
))}
</Group>
</div>
{/* Certificate Files */}
<Stack gap="sm">
<Text size="sm" fw={500}>
{t('certSign.collab.participant.filesLabel', 'Certificate files')}
</Text>
{certType === 'PEM' && (
<>
<FileUploadButton
file={privateKeyFile}
onChange={(file) => setPrivateKeyFile(file || undefined)}
accept=".pem,.der,.key"
disabled={submitting}
placeholder={t('certSign.choosePrivateKey', 'Choose Private Key File')}
/>
{privateKeyFile && (
<FileUploadButton
file={certFile}
onChange={(file) => setCertFile(file || undefined)}
accept=".pem,.der,.crt,.cer"
disabled={submitting}
placeholder={t('certSign.chooseCertificate', 'Choose Certificate File')}
/>
)}
</>
)}
{certType === 'PKCS12' && (
<FileUploadButton
file={p12File}
onChange={(file) => setP12File(file || undefined)}
accept=".p12"
disabled={submitting}
placeholder={t('certSign.chooseP12File', 'Choose PKCS12 File')}
/>
)}
{certType === 'PFX' && (
<FileUploadButton
file={p12File}
onChange={(file) => setP12File(file || undefined)}
accept=".pfx"
disabled={submitting}
placeholder={t('certSign.choosePfxFile', 'Choose PFX File')}
/>
)}
{certType === 'JKS' && (
<FileUploadButton
file={jksFile}
onChange={(file) => setJksFile(file || undefined)}
accept=".jks,.keystore"
disabled={submitting}
placeholder={t('certSign.chooseJksFile', 'Choose JKS File')}
/>
)}
{certType === 'SERVER' && (
<Text c="dimmed" size="sm">
{t('certSign.serverCertMessage', 'Using server certificate - no files required')}
</Text>
)}
</Stack>
{/* Password */}
{certType !== 'SERVER' && (
<TextInput
label={t('certSign.collab.participant.passwordLabel', 'Certificate password')}
placeholder={t('certSign.collab.participant.passwordPlaceholder', 'Leave empty if no password')}
type="password"
value={password}
onChange={(event) => setPassword(event.currentTarget.value)}
disabled={submitting}
/>
)}
{/* Signature Appearance */}
<Stack gap="sm">
<Text size="sm" fw={500}>
{t('certSign.collab.participant.appearanceLabel', 'Signature appearance')}
</Text>
<Group gap="xs">
<Button
variant={!showSignature ? 'filled' : 'outline'}
color={!showSignature ? 'blue' : 'var(--text-muted)'}
onClick={() => setShowSignature(false)}
disabled={submitting}
size="sm"
>
{t('certSign.appearance.invisible', 'Invisible')}
</Button>
<Button
variant={showSignature ? 'filled' : 'outline'}
color={showSignature ? 'blue' : 'var(--text-muted)'}
onClick={() => setShowSignature(true)}
disabled={submitting}
size="sm"
>
{t('certSign.appearance.visible', 'Visible')}
</Button>
</Group>
{showSignature && (
<Stack gap="sm">
<NumberInput
label={t('certSign.collab.participant.pageNumberLabel', 'Page number')}
value={pageNumber}
onChange={(value) => setPageNumber(value as number || 1)}
min={1}
disabled={submitting}
/>
<TextInput
label={t('certSign.collab.participant.reasonLabel', 'Reason')}
value={reason}
onChange={(event) => setReason(event.currentTarget.value)}
disabled={submitting}
/>
<TextInput
label={t('certSign.collab.participant.locationLabel', 'Location')}
value={location}
onChange={(event) => setLocation(event.currentTarget.value)}
disabled={submitting}
/>
<TextInput
label={t('certSign.collab.participant.nameLabel', 'Signer name')}
value={name}
onChange={(event) => setName(event.currentTarget.value)}
disabled={submitting}
/>
<Switch
label={t('certSign.showLogo', 'Show Logo')}
checked={showLogo}
onChange={(event) => setShowLogo(event.currentTarget.checked)}
disabled={submitting}
/>
</Stack>
)}
</Stack>
{error && (
<Alert icon={<ErrorIcon />} color="red">
{error}
</Alert>
)}
<Button
onClick={handleSubmit}
loading={submitting}
leftSection={<CheckCircleIcon />}
fullWidth
size="lg"
disabled={submitted}
>
{t('certSign.collab.participant.submit', 'Submit certificate')}
</Button>
</Stack>
</Paper>
</ScrollArea>
</Box>
</Box>
);
}
export default ParticipantCertificateSubmission;
+2 -2
View File
@@ -254,10 +254,10 @@ export const mantineTheme = createTheme({
},
option: {
color: 'var(--text-primary)',
'&[data-hovered]': {
'&[dataHovered]': {
backgroundColor: 'var(--hover-bg)',
},
'&[data-selected]': {
'&[dataSelected]': {
backgroundColor: 'var(--color-primary-100)',
color: 'var(--color-primary-900)',
},
+409
View File
@@ -0,0 +1,409 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Tabs, Text as MantineText } from '@mantine/core';
import { createToolFlow } from '@app/components/tools/shared/createToolFlow';
import { useBaseTool } from '@app/hooks/tools/shared/useBaseTool';
import { BaseToolProps, ToolComponent } from '@app/types/tool';
import SigningCollaborationSettings from '@app/components/tools/certSign/SigningCollaborationSettings';
import SessionListView from '@app/components/tools/certSign/SessionListView';
import SessionDetailView from '@app/components/tools/certSign/SessionDetailView';
import SignRequestListView from '@app/components/tools/certSign/SignRequestListView';
import SignRequestWorkbenchView from '@app/components/tools/certSign/SignRequestWorkbenchView';
import { useSigningWorkflowParameters } from '@app/hooks/tools/certSign/useSigningWorkflowParameters';
import { useSigningWorkflowOperation } from '@app/hooks/tools/certSign/useSigningWorkflowOperation';
import { useSigningSessionManagement } from '@app/hooks/tools/certSign/useSigningSessionManagement';
import { useSignRequestManagement } from '@app/hooks/tools/certSign/useSignRequestManagement';
import { useFileManagement, useFileSelection } from '@app/contexts/file/fileHooks';
import { alert } from '@app/components/toast';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import { useAuth } from '@app/auth/UseSession';
import { useNavigationActions } from '@app/contexts/NavigationContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { FileId } from '@app/types/file';
const SigningWorkflow = (props: BaseToolProps) => {
const { t } = useTranslation();
const { user } = useAuth();
const base = useBaseTool(
'signingWorkflow',
useSigningWorkflowParameters,
useSigningWorkflowOperation,
props,
);
const sessionMgmt = useSigningSessionManagement();
const signRequestMgmt = useSignRequestManagement();
const { addFiles, removeFiles } = useFileManagement();
const { setSelectedFiles } = useFileSelection();
const { openFilesModal } = useFilesModalContext();
const { actions: navActions } = useNavigationActions();
const {
registerCustomWorkbenchView,
unregisterCustomWorkbenchView,
setCustomWorkbenchViewData,
clearCustomWorkbenchViewData,
} = useToolWorkflow();
// Track loaded session PDFs to prevent duplicate fetches
const loadedSessionsRef = useRef<Set<string>>(new Set());
// Track loaded file IDs by sessionId for cleanup
const sessionFileIdsRef = useRef<Map<string, string>>(new Map());
// Custom workbench ID (must use custom: prefix for WorkbenchType)
const SIGN_REQUEST_WORKBENCH_ID = 'signRequestWorkbench';
const SIGN_REQUEST_WORKBENCH_TYPE = 'custom:signRequestWorkbench';
// Tab states: 'sessions' | 'signRequests'
const [activeTab, setActiveTab] = useState<'sessions' | 'signRequests'>('sessions');
// View states: 'list' | 'create' | 'detail'
const [view, setView] = useState<'list' | 'create' | 'detail'>('list');
// Register custom workbench on mount
useEffect(() => {
registerCustomWorkbenchView({
id: SIGN_REQUEST_WORKBENCH_ID,
workbenchId: SIGN_REQUEST_WORKBENCH_TYPE,
// Use a static label at registration time to avoid re-registering on i18n changes
label: 'Sign Request',
component: SignRequestWorkbenchView,
});
return () => {
unregisterCustomWorkbenchView(SIGN_REQUEST_WORKBENCH_ID);
};
// Register once; avoid re-registering on translation/prop changes which clears data mid-flight
}, []);
// On mount: fetch sessions and sign requests
useEffect(() => {
sessionMgmt.fetchSessions();
signRequestMgmt.fetchSignRequests();
}, []);
// Fetch data when switching tabs
useEffect(() => {
if (activeTab === 'sessions') {
sessionMgmt.fetchSessions();
} else {
signRequestMgmt.fetchSignRequests();
}
}, [activeTab]);
// Load PDF and set data when viewing sign request detail
useEffect(() => {
if (view === 'detail' && activeTab === 'signRequests' && signRequestMgmt.activeRequest) {
// Fetch PDF directly without adding to FileContext
signRequestMgmt.fetchSessionPdf(
signRequestMgmt.activeRequest.sessionId,
signRequestMgmt.activeRequest.documentName
).then((pdfFile) => {
console.log('[SigningWorkflow] PDF fetched for custom workbench:', pdfFile.name);
// Set custom workbench data with the PDF file directly
console.log('[SigningWorkflow] Setting custom workbench data for:', SIGN_REQUEST_WORKBENCH_ID);
setCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID, {
signRequest: signRequestMgmt.activeRequest,
pdfFile,
onSign: async (certData: FormData) => {
if (!user?.id) {
alert({
alertType: 'error',
title: t('error'),
body: t('certSign.collab.signRequest.noUser', 'User not authenticated'),
});
return;
}
const sessionId = signRequestMgmt.activeRequest!.sessionId;
await signRequestMgmt.signRequest(
sessionId,
parseInt(user.id, 10),
certData
);
// Clear custom workbench data (no FileContext cleanup needed since we didn't add file there)
clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID);
navActions.setWorkbench('viewer');
setView('list');
},
onDecline: async () => {
const sessionId = signRequestMgmt.activeRequest!.sessionId;
await signRequestMgmt.declineRequest(sessionId);
// Clear custom workbench data (no FileContext cleanup needed since we didn't add file there)
clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID);
navActions.setWorkbench('viewer');
setView('list');
},
onBack: () => {
signRequestMgmt.setActiveRequest(null);
// Clear custom workbench data (no FileContext cleanup needed since we didn't add file there)
clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID);
navActions.setWorkbench('viewer');
setView('list');
},
canSign:
signRequestMgmt.activeRequest?.myStatus === 'PENDING' ||
signRequestMgmt.activeRequest?.myStatus === 'NOTIFIED' ||
signRequestMgmt.activeRequest?.myStatus === 'VIEWED',
});
// Navigate after React re-renders with updated customWorkbenchViews
// Use requestAnimationFrame to defer until after render cycle completes
requestAnimationFrame(() => {
console.log('[SigningWorkflow] Navigating to custom workbench:', SIGN_REQUEST_WORKBENCH_TYPE);
navActions.setWorkbench(SIGN_REQUEST_WORKBENCH_TYPE);
});
}).catch((error) => {
console.error('[SigningWorkflow] Failed to fetch PDF for sign request:', error);
});
}
}, [view, activeTab, signRequestMgmt.activeRequest]);
// Custom execute handler that navigates back after success
const handleCreateSession = async () => {
try {
// Clear any previous errors first
base.operation.clearError();
// Call operation directly
await base.operation.executeOperation(base.params.parameters, base.selectedFiles);
// If we get here without throwing, it succeeded
await sessionMgmt.fetchSessions();
// Clear files and set view to list
await base.handleUndo();
setView('list');
alert({
alertType: 'success',
title: t('success'),
body: t('certSign.collab.sessionCreated', 'Signing session created successfully'),
});
} catch (error) {
// Operation hook already displays error, just log
console.error('Session creation error:', error);
}
};
// Removed auto-switch to create - user must explicitly click "Create New"
const handleBackToList = () => {
sessionMgmt.setActiveSession(null);
sessionMgmt.fetchSessions();
base.handleUndo();
setView('list');
};
const handleCreateNew = () => {
setView('create');
// Only open file picker if no files selected
if (!base.hasFiles) {
openFilesModal();
}
};
const _handleLoadPdf = useCallback(async (sessionId: string, documentName: string) => {
// Check if we've already loaded this session's PDF
if (loadedSessionsRef.current.has(sessionId)) {
console.log('[SigningWorkflow] PDF already loaded for session:', sessionId);
// Still select the file if it's already loaded
const fileId = sessionFileIdsRef.current.get(sessionId);
if (fileId) {
setSelectedFiles([fileId as FileId]);
console.log('[SigningWorkflow] Re-selected already loaded file:', fileId);
}
return null as any;
}
// Mark this session as loading immediately to prevent race condition
loadedSessionsRef.current.add(sessionId);
console.log('[SigningWorkflow] Loading PDF for session:', sessionId);
const pdfFile = await signRequestMgmt.fetchSessionPdf(sessionId, documentName);
console.log('[SigningWorkflow] PDF fetched:', pdfFile.name, pdfFile.size);
const stirlingFiles = await addFiles([pdfFile]);
console.log('[SigningWorkflow] Added to FileContext:', stirlingFiles.length, 'files');
// Track the file ID for cleanup and select the file
if (stirlingFiles.length > 0) {
const fileId = stirlingFiles[0].fileId;
sessionFileIdsRef.current.set(sessionId, fileId);
console.log('[SigningWorkflow] Tracked fileId for cleanup:', fileId, 'session:', sessionId);
console.log('[SigningWorkflow] Current tracked sessions:', Array.from(sessionFileIdsRef.current.keys()));
// Select the file to display it in the viewer
setSelectedFiles([fileId]);
console.log('[SigningWorkflow] Selected file:', fileId);
}
return pdfFile;
}, [signRequestMgmt.fetchSessionPdf, addFiles, setSelectedFiles]);
const _cleanupSessionPdf = useCallback((sessionId: string) => {
console.log('[SigningWorkflow] cleanupSessionPdf called for session:', sessionId);
console.log('[SigningWorkflow] Tracked sessions before cleanup:', Array.from(sessionFileIdsRef.current.keys()));
const fileId = sessionFileIdsRef.current.get(sessionId);
console.log('[SigningWorkflow] FileId to remove:', fileId);
if (fileId) {
console.log('[SigningWorkflow] Calling removeFiles with fileId:', fileId);
removeFiles([fileId as FileId]);
sessionFileIdsRef.current.delete(sessionId);
loadedSessionsRef.current.delete(sessionId);
console.log('[SigningWorkflow] Cleanup complete. Remaining tracked sessions:', Array.from(sessionFileIdsRef.current.keys()));
} else {
console.warn('[SigningWorkflow] No fileId found for session:', sessionId);
}
}, [removeFiles]);
// Always create toolFlowContent to maintain consistent hook order
const toolFlowContent = createToolFlow({
forceStepNumbers: true,
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [
{
title: t('certSign.collab.stepTitle', 'Share for signing'),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
content: (
<SigningCollaborationSettings
parameters={base.params.parameters}
onParameterChange={base.params.updateParameter}
disabled={base.endpointLoading}
/>
),
},
],
executeButton: {
text: t('certSign.collab.submit', 'Create shared session'),
isVisible: !base.hasResults,
loadingText: t('loading'),
onClick: handleCreateSession,
disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled,
},
review: {
isVisible: false, // We go straight to detail view instead of showing results
operation: base.operation,
title: t('certSign.collab.results', 'Session Created'),
},
});
return (
<Stack gap="md">
{view === 'create' && base.hasFiles ? (
// Creating a new session
toolFlowContent
) : view === 'detail' && activeTab === 'sessions' && sessionMgmt.activeSession ? (
// Viewing session detail (owner view)
<SessionDetailView
session={sessionMgmt.activeSession}
onFinalize={async () => {
try {
const signedFile = await sessionMgmt.finalizeSession(
sessionMgmt.activeSession!.sessionId,
sessionMgmt.activeSession!.documentName
);
// Add the finalized PDF to active files
await addFiles([signedFile]);
alert({
alertType: 'success',
title: t('success'),
body: t('certSign.collab.finalize.success', 'Signed PDF added to active files'),
});
await sessionMgmt.fetchSessions();
setView('list');
} catch (_error) {
// Error already handled by sessionMgmt
}
}}
onDelete={async () => {
await sessionMgmt.deleteSession(sessionMgmt.activeSession!.sessionId);
await sessionMgmt.fetchSessions();
setView('list');
}}
onAddParticipants={(participants) =>
sessionMgmt.addParticipants(sessionMgmt.activeSession!.sessionId, participants)
}
onRemoveParticipant={(userId) =>
sessionMgmt.removeParticipant(sessionMgmt.activeSession!.sessionId, userId)
}
onLoadSignedPdf={async () => {
try {
const signedFile = await sessionMgmt.loadSignedPdf(
sessionMgmt.activeSession!.sessionId,
sessionMgmt.activeSession!.documentName
);
// Add the signed PDF to active files
await addFiles([signedFile]);
alert({
alertType: 'success',
title: t('success'),
body: t('certSign.collab.finalize.success', 'Signed PDF added to active files'),
});
} catch (_error) {
// Error already handled by sessionMgmt
}
}}
onBack={handleBackToList}
onRefresh={() => sessionMgmt.fetchSessionDetail(sessionMgmt.activeSession!.sessionId)}
/>
) : view === 'detail' && activeTab === 'signRequests' ? (
// Sign request detail is now handled by custom workbench (SignRequestWorkbenchView)
// Navigation happens automatically via useEffect above
<Stack gap="sm" align="center" justify="center" style={{ minHeight: '200px' }}>
<MantineText size="sm" c="dimmed">
{t('certSign.collab.signRequest.loading', 'Loading sign request...')}
</MantineText>
</Stack>
) : (
// List views with tabs
<Tabs value={activeTab} onChange={(val) => setActiveTab(val as 'sessions' | 'signRequests')}>
<Tabs.List>
<Tabs.Tab value="sessions">
{t('certSign.collab.tabs.mySessions', 'My Sessions')}
</Tabs.Tab>
<Tabs.Tab value="signRequests">
{t('certSign.collab.tabs.signRequests', 'Sign Requests')}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="sessions" pt="md">
<SessionListView
sessions={sessionMgmt.sessions}
onSessionSelect={(id) => {
sessionMgmt.fetchSessionDetail(id);
setView('detail');
}}
onCreateNew={handleCreateNew}
loading={sessionMgmt.loading}
/>
</Tabs.Panel>
<Tabs.Panel value="signRequests" pt="md">
<SignRequestListView
signRequests={signRequestMgmt.signRequests}
onRequestSelect={(id) => {
signRequestMgmt.fetchSignRequestDetail(id);
setView('detail');
}}
loading={signRequestMgmt.loading}
/>
</Tabs.Panel>
</Tabs>
)}
</Stack>
);
};
SigningWorkflow.tool = () => useSigningWorkflowOperation;
export default SigningWorkflow as ToolComponent;
+67
View File
@@ -0,0 +1,67 @@
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 {
userId: number;
username: string;
displayName: string;
status: 'PENDING' | 'NOTIFIED' | 'VIEWED' | 'SIGNED' | 'DECLINED';
lastUpdated: string;
// Signature appearance settings (owner-controlled)
showSignature?: boolean;
pageNumber?: number;
reason?: string;
location?: string;
showLogo?: boolean;
}
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;
}
+1
View File
@@ -8,6 +8,7 @@ export type ToolKind = 'regular' | 'super' | 'link';
export const CORE_REGULAR_TOOL_IDS = [
'certSign',
'signingWorkflow',
'sign',
'addText',
'addPassword',
+18 -1
View File
@@ -1,5 +1,5 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { Routes, Route, useSearchParams } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
@@ -9,6 +9,7 @@ import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
import ParticipantCertificateSubmission from "@app/pages/ParticipantCertificateSubmission";
// Import global styles
import "@app/styles/tailwind.css";
@@ -19,6 +20,19 @@ import "@app/styles/auth-theme.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
// Wrapper component to extract query params for participant signing
function SigningSessionRoute() {
const [searchParams] = useSearchParams();
const sessionId = searchParams.get('sessionId');
const token = searchParams.get('token');
if (!sessionId || !token) {
return <div>Invalid signing session link. Missing sessionId or token.</div>;
}
return <ParticipantCertificateSubmission sessionId={sessionId} token={token} />;
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
@@ -31,6 +45,9 @@ export default function App() {
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Participant certificate submission route */}
<Route path="/signing-session" element={<SigningSessionRoute />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
@@ -0,0 +1,86 @@
import apiClient from '@app/services/apiClient';
export interface CertificateInfo {
exists: boolean;
type: string | null;
subject: string | null;
issuer: string | null;
validFrom: string | null;
validTo: string | null;
createdAt: string | null;
updatedAt: string | null;
}
/**
* Service for managing user personal certificates
*/
class UserCertificateService {
/**
* Get information about the current user's certificate
*/
async getCertificateInfo(): Promise<CertificateInfo> {
const response = await apiClient.get<CertificateInfo>('/api/v1/user/certificate/info');
return response.data;
}
/**
* Generate a new self-signed certificate for the current user
*/
async generateCertificate(): Promise<void> {
await apiClient.post('/api/v1/user/certificate/generate');
}
/**
* Upload a custom PKCS12 certificate for the current user
*/
async uploadCertificate(file: File, password: string): Promise<void> {
const formData = new FormData();
formData.append('file', file);
formData.append('password', password);
await apiClient.post('/api/v1/user/certificate/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
/**
* Delete the current user's certificate
*/
async deleteCertificate(): Promise<void> {
await apiClient.delete('/api/v1/user/certificate');
}
/**
* Download the user's public certificate
*/
async downloadCertificate(): Promise<Blob> {
const response = await apiClient.get('/api/v1/user/certificate/download', {
responseType: 'blob',
});
return response.data;
}
/**
* Check if certificate exists and is valid
*/
async hasCertificate(): Promise<boolean> {
const info = await this.getCertificateInfo();
return info.exists;
}
/**
* Check if certificate is expired
*/
async isCertificateExpired(): Promise<boolean> {
const info = await this.getCertificateInfo();
if (!info.exists || !info.validTo) {
return false;
}
const validTo = new Date(info.validTo);
return validTo < new Date();
}
}
export const userCertificateService = new UserCertificateService();