mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f603ba12ce | ||
|
|
da80344c08 | ||
|
|
ffd404a24b | ||
|
|
f2eee030c3 | ||
|
|
ce10dacf15 | ||
|
|
60e2a67200 | ||
|
|
3e87faf07e | ||
|
|
3b64da76ff | ||
|
|
ffbecc592b | ||
|
|
2e3e297e07 | ||
|
|
b9d68ccfdd | ||
|
|
a9bc7a562d | ||
|
|
efbb45717b |
@@ -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'
|
||||
}
|
||||
|
||||
+14
@@ -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;
|
||||
}
|
||||
+48
@@ -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;
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
+50
@@ -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;
|
||||
}
|
||||
+21
@@ -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;
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package stirling.software.common.model.api.security;
|
||||
|
||||
public enum ParticipantStatus {
|
||||
PENDING,
|
||||
NOTIFIED,
|
||||
VIEWED,
|
||||
SIGNED,
|
||||
DECLINED
|
||||
}
|
||||
+81
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+26
@@ -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();
|
||||
}
|
||||
}
|
||||
+26
@@ -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();
|
||||
}
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
+96
@@ -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;
|
||||
}
|
||||
}
|
||||
+155
@@ -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");
|
||||
|
||||
+177
-4
@@ -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)
|
||||
|
||||
+678
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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";
|
||||
}
|
||||
|
||||
+68
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+80
@@ -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;
|
||||
}
|
||||
+124
@@ -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();
|
||||
}
|
||||
}
|
||||
+104
@@ -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();
|
||||
}
|
||||
}
|
||||
+73
@@ -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;
|
||||
}
|
||||
+11
-1
@@ -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();
|
||||
|
||||
+222
@@ -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) {}
|
||||
}
|
||||
+31
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
+6
@@ -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);
|
||||
|
||||
+26
@@ -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);
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
+23
@@ -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);
|
||||
}
|
||||
+845
@@ -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;
|
||||
}
|
||||
}
|
||||
+251
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
Generated
+41
-16
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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)',
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type ToolKind = 'regular' | 'super' | 'link';
|
||||
|
||||
export const CORE_REGULAR_TOOL_IDS = [
|
||||
'certSign',
|
||||
'signingWorkflow',
|
||||
'sign',
|
||||
'addText',
|
||||
'addPassword',
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user