From 5f553ece11704c91af77e2b304453b4d41d3e574 Mon Sep 17 00:00:00 2001 From: Connor Yoh Date: Fri, 27 Mar 2026 17:33:51 +0000 Subject: [PATCH] feat(signing): add external/guest user signing support Allow document owners to invite external users (no Stirling-PDF account) to sign documents via a one-time share link. Backend: - GuestCertificateService: generates ephemeral PKCS12 keystores keyed by email, with rfc822Name SAN for industry-traceable digital signatures; uses emailProtection EKU, cryptographically random serial numbers, and HMAC-SHA256 derived passwords - SigningFinalizationService: handles GUEST_CERT case in buildKeystore() and getKeystorePassword() - WorkflowParticipantController: defaults certType to GUEST_CERT for unregistered participants, captures hashed-IP audit trail on submission, skips password encryption for GUEST_CERT paths - WorkflowSessionService: sends signing invitation email to guest participants on session creation; redacts email PII in logs - EmailService: adds sendSigningInvitationEmail() with HTML-escaped user-supplied values and javascript: URL guard to prevent XSS Frontend: - GuestSignPage: new token-gated route /sign/:token outside AppProviders; full page-state machine (loading/ready/expired/signed/declined/error) - GuestCertificateChooser: auto-generated cert vs upload P12 chooser - SelectParticipantsStep: refactored to support registered + external (email) participants via tabbed UI with validation - AddParticipantsFlow: same external email tab added for mid-session adds - CreateSessionFlow/SignPopout: thread new Participant[] shape through to split userIds and emails on submit - App.tsx: /sign/:token route with minimal GuestSigningProviders (no auth) - i18n: [guestSigning] keys added to en-GB/translation.toml Tests: - GuestCertificateServiceTest: keystore generation, SAN, deterministic passwords - WorkflowParticipantControllerTest: GUEST_CERT defaulting, audit trail, token guards, encrypt() not called for guest cert - EmailServiceTest: invitation email, HTML injection safety, JS URL guard - GuestSignPage.test.tsx: 9 vitest unit tests (all page states + submit) - SelectParticipantsStep.test.tsx: 15 vitest unit tests - GuestSigningE2E.spec.ts: 19 Playwright E2E tests (route-mocked, no backend) - playwright.config.ts: fix testDir to src/core/tests, use port 5174 --- .../security/service/EmailService.java | 106 +++++ .../WorkflowParticipantController.java | 72 +++- .../service/GuestCertificateService.java | 187 +++++++++ .../service/SigningFinalizationService.java | 31 ++ .../service/WorkflowSessionService.java | 61 +++ .../security/service/EmailServiceTest.java | 106 +++++ .../WorkflowParticipantControllerTest.java | 354 ++++++++++++++++ .../service/GuestCertificateServiceTest.java | 138 +++++++ docs/guest-signing-test-plan.md | 270 ++++++++++++ frontend/playwright.config.ts | 6 +- .../public/locales/en-GB/translation.toml | 41 +- .../shared/signing/CreateSessionFlow.tsx | 19 +- .../shared/signing/CreateSessionPanel.tsx | 139 ++++++- .../signing/GuestCertificateChooser.tsx | 82 ++++ .../components/shared/signing/SignPopout.tsx | 44 +- .../steps/SelectParticipantsStep.test.tsx | 317 ++++++++++++++ .../signing/steps/SelectParticipantsStep.tsx | 234 +++++++++-- .../certSign/SessionDetailWorkbenchView.tsx | 6 +- .../certSign/modals/AddParticipantsFlow.tsx | 141 ++++++- .../src/core/routes/GuestSignPage.test.tsx | 321 +++++++++++++++ frontend/src/core/routes/GuestSignPage.tsx | 385 ++++++++++++++++++ .../guestSigning/GuestSigningE2E.spec.ts | 342 ++++++++++++++++ frontend/src/proprietary/App.tsx | 22 + 23 files changed, 3326 insertions(+), 98 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/GuestCertificateService.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantControllerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/GuestCertificateServiceTest.java create mode 100644 docs/guest-signing-test-plan.md create mode 100644 frontend/src/core/components/shared/signing/GuestCertificateChooser.tsx create mode 100644 frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.test.tsx create mode 100644 frontend/src/core/routes/GuestSignPage.test.tsx create mode 100644 frontend/src/core/routes/GuestSignPage.tsx create mode 100644 frontend/src/core/tests/guestSigning/GuestSigningE2E.spec.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java index 5c1cf75450..321acfde77 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java @@ -6,6 +6,7 @@ import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.util.HtmlUtils; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; @@ -260,6 +261,111 @@ public class EmailService { sendPlainEmail(to, subject, body, true); } + /** + * Sends a document signing invitation to an external/guest user. + * + * @param to the recipient email address + * @param ownerName the display name of the session owner + * @param documentName the name of the document to be signed + * @param signingUrl the full URL of the guest signing page + * @param expiresAt human-readable expiry date/time string + * @param message optional personal message from the owner (may be null) + * @throws MessagingException if sending fails + */ + @Async + public void sendSigningInvitationEmail( + String to, + String ownerName, + String documentName, + String signingUrl, + String expiresAt, + String message) + throws MessagingException { + // Escape all user-supplied values before embedding in HTML + String safeOwner = HtmlUtils.htmlEscape(ownerName != null ? ownerName : ""); + String safeDoc = HtmlUtils.htmlEscape(documentName != null ? documentName : ""); + // Only allow http/https URLs in the signing link to prevent javascript: injection + String safeUrl = + (signingUrl != null + && (signingUrl.startsWith("https://") + || signingUrl.startsWith("http://"))) + ? signingUrl + : "#"; + String safeUrlText = HtmlUtils.htmlEscape(safeUrl); + + String subject = "Please sign: " + (documentName != null ? documentName : ""); + + String messageSection = + (message != null && !message.isBlank()) + ? """ +
+

"%s"

+
+ """ + .formatted(HtmlUtils.htmlEscape(message)) + : ""; + + String expirySection = + (expiresAt != null && !expiresAt.isBlank()) + ? """ +
+

⚠ Important: This signing link will expire on %s.

+
+ """ + .formatted(HtmlUtils.htmlEscape(expiresAt)) + : ""; + + String body = + """ + +
+
+ +
+ Stirling PDF +
+ +
+

You have been asked to sign a document

+

Hi there,

+

%s has requested your signature on:

+ +
+

📄 %s

+
+ %s + +
+ Review and Sign Document +
+

Or copy and paste this link in your browser:

+
+ %s +
+ %s +

No account is required. Click the button above to review the document and apply your signature.

+

If you did not expect this request, you can safely ignore this email.

+

— The Stirling PDF Team

+
+ +
+ © 2025 Stirling PDF. All rights reserved. +
+
+
+ + """ + .formatted( + safeOwner, + safeDoc, + messageSection, + safeUrl, + safeUrlText, + expirySection); + + sendPlainEmail(to, subject, body, true); + } + @Async public void sendPasswordChangedNotification( String to, String username, String newPassword, String loginUrl) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java index f29598af06..1c5d6502c6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java @@ -2,8 +2,11 @@ package stirling.software.proprietary.workflow.controller; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; +import java.util.Base64; import java.util.HashMap; import java.util.Map; @@ -24,6 +27,7 @@ import org.springframework.web.server.ResponseStatusException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -134,7 +138,7 @@ public class WorkflowParticipantController { consumes = MediaType.MULTIPART_FORM_DATA_VALUE, produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity submitSignature( - @ModelAttribute SignatureSubmissionRequest request) { + @ModelAttribute SignatureSubmissionRequest request, HttpServletRequest httpRequest) { workflowSessionService.ensureSigningEnabled(); @@ -169,7 +173,8 @@ public class WorkflowParticipantController { try { // Build metadata map with certificate and wet signature data - Map metadata = buildSubmissionMetadata(request); + Map metadata = + buildSubmissionMetadata(request, participant, httpRequest); participant.setParticipantMetadata(metadata); // Update status to SIGNED @@ -369,15 +374,27 @@ public class WorkflowParticipantController { } /** - * Builds metadata map from signature submission request. Includes certificate submission and - * wet signature data. + * Builds metadata map from signature submission request. Includes certificate submission, wet + * signature data, and an audit trail entry for compliance traceability. */ - private Map buildSubmissionMetadata(SignatureSubmissionRequest request) + private Map buildSubmissionMetadata( + SignatureSubmissionRequest request, + WorkflowParticipant participant, + HttpServletRequest httpRequest) throws IOException { Map metadata = new HashMap<>(); - // Validate certificate before storing — throws 400 if invalid, expired, or wrong password - if (request.getCertType() != null && !"SERVER".equalsIgnoreCase(request.getCertType())) { + // Default to GUEST_CERT for external (non-registered) participants that did not + // supply their own certificate. + String effectiveCertType = request.getCertType(); + if (effectiveCertType == null && participant.getUser() == null) { + effectiveCertType = "GUEST_CERT"; + } + + // Validate uploaded certificate before storing — skip for GUEST_CERT (no upload) + if (effectiveCertType != null + && !"SERVER".equalsIgnoreCase(effectiveCertType) + && !"GUEST_CERT".equalsIgnoreCase(effectiveCertType)) { byte[] keystoreBytes = null; if (request.getP12File() != null && !request.getP12File().isEmpty()) { keystoreBytes = request.getP12File().getBytes(); @@ -386,16 +403,20 @@ public class WorkflowParticipantController { } if (keystoreBytes != null) { certificateSubmissionValidator.validateAndExtractInfo( - keystoreBytes, request.getCertType(), request.getPassword()); + keystoreBytes, effectiveCertType, request.getPassword()); } } - // Add certificate submission if provided - if (request.getCertType() != null) { + // Add certificate submission if provided (or defaulted) + if (effectiveCertType != null) { Map certSubmission = new HashMap<>(); - certSubmission.put("certType", request.getCertType()); - certSubmission.put( - "password", metadataEncryptionService.encrypt(request.getPassword())); + certSubmission.put("certType", effectiveCertType); + // GUEST_CERT passwords are derived at finalization time; no password to store + String encryptedPassword = + "GUEST_CERT".equalsIgnoreCase(effectiveCertType) + ? null + : metadataEncryptionService.encrypt(request.getPassword()); + certSubmission.put("password", encryptedPassword); certSubmission.put("showSignature", request.getShowSignature()); certSubmission.put("pageNumber", request.getPageNumber()); certSubmission.put("location", request.getLocation()); @@ -437,6 +458,31 @@ public class WorkflowParticipantController { metadata.put("wetSignatures", wetSigs); } + // Audit trail for compliance traceability (IP is hashed, not stored raw) + Map auditTrail = new HashMap<>(); + auditTrail.put("ipHash", hashIp(httpRequest.getRemoteAddr())); + String userAgent = httpRequest.getHeader("User-Agent"); + auditTrail.put( + "userAgent", + userAgent != null && userAgent.length() > 500 + ? userAgent.substring(0, 500) + : userAgent); + auditTrail.put("submittedAt", Instant.now().toString()); + auditTrail.put("email", participant.getEmail()); + metadata.put("auditTrail", auditTrail); + return metadata; } + + /** Returns a SHA-256 Base64-encoded digest of an IP address for privacy-safe audit logging. */ + private String hashIp(String ip) { + if (ip == null) return null; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(ip.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hash); + } catch (Exception e) { + return null; + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/GuestCertificateService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/GuestCertificateService.java new file mode 100644 index 0000000000..20c4cd98b0 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/GuestCertificateService.java @@ -0,0 +1,187 @@ +package stirling.software.proprietary.workflow.service; + +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.Date; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +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.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; + +/** + * Generates ephemeral (in-memory) PKCS12 keystores for guest/external signers. The generated + * certificate includes the signer's email address as a Subject Alternative Name (rfc822Name), + * providing industry-standard traceability. Guest keystores are never persisted to the database. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class GuestCertificateService { + + private static final String KEYSTORE_ALIAS = "stirling-pdf-guest-cert"; + private static final int VALIDITY_DAYS = 365; + + private final ApplicationProperties applicationProperties; + + static { + if (Security.getProvider("BC") == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + /** + * Generates a new in-memory PKCS12 keystore for a guest signer identified by email. The X.509 + * certificate includes: - CN = sanitized email (no special chars) - O = Stirling-PDF Guest - + * SAN rfc822Name = raw email (industry-standard signer identity field) + * + * @param email the guest signer's email address + * @return an in-memory PKCS12 KeyStore ready for use in PDF signing + */ + public KeyStore generateGuestKeyStore(String email) throws Exception { + log.debug( + "Generating guest certificate for external signer (domain: {})", + email.contains("@") ? email.substring(email.indexOf('@')) : ""); + + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC"); + keyPairGenerator.initialize(2048, new SecureRandom()); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + // Sanitize email for use in DN (remove chars that break X.500 parsing) + String safeCn = email.replaceAll("[,=+<>#;\"\\\\]", "_"); + X500Name subject = new X500Name("CN=" + safeCn + ", O=Stirling-PDF Guest, C=US"); + + BigInteger serialNumber = new BigInteger(64, new SecureRandom()); + Date notBefore = new Date(); + Date notAfter = + new Date(notBefore.getTime() + ((long) VALIDITY_DAYS * 24 * 60 * 60 * 1000)); + + JcaX509v3CertificateBuilder certBuilder = + new JcaX509v3CertificateBuilder( + subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic()); + + 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: emailProtection is the correct OID for email-identified signers + certBuilder.addExtension( + Extension.extendedKeyUsage, + false, + new ExtendedKeyUsage(KeyPurposeId.id_kp_emailProtection)); + + // Subject Key Identifier + certBuilder.addExtension( + Extension.subjectKeyIdentifier, + false, + extUtils.createSubjectKeyIdentifier(keyPair.getPublic())); + + // Authority Key Identifier (self-signed) + certBuilder.addExtension( + Extension.authorityKeyIdentifier, + false, + extUtils.createAuthorityKeyIdentifier(keyPair.getPublic())); + + // Subject Alternative Name: rfc822Name = email (industry-standard signer identity) + GeneralName emailSan = new GeneralName(GeneralName.rfc822Name, email); + GeneralNames subjectAltName = new GeneralNames(emailSan); + certBuilder.addExtension(Extension.subjectAlternativeName, false, subjectAltName); + + // Sign the certificate + ContentSigner signer = + new JcaContentSignerBuilder("SHA256WithRSA") + .setProvider("BC") + .build(keyPair.getPrivate()); + + X509CertificateHolder certHolder = certBuilder.build(signer); + X509Certificate cert = + new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder); + + // Build in-memory PKCS12 keystore + String password = generateGuestPassword(email); + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setKeyEntry( + KEYSTORE_ALIAS, + keyPair.getPrivate(), + password.toCharArray(), + new Certificate[] {cert}); + + // Round-trip through serialization so the keystore is fully initialised + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + keyStore.store(baos, password.toCharArray()); + KeyStore loaded = KeyStore.getInstance("PKCS12"); + loaded.load(new java.io.ByteArrayInputStream(baos.toByteArray()), password.toCharArray()); + + log.debug("Guest certificate generated (CN={}, SAN=rfc822Name:[email])", safeCn); + return loaded; + } + + /** + * Derives a deterministic password for a guest keystore. The password is a Base64-encoded + * HMAC-SHA256 of the email using the application's secret key, truncated to 32 characters. Two + * calls with the same email always return the same password. + * + * @param email the guest signer's email address + * @return a deterministic password string + */ + public String generateGuestPassword(String email) { + try { + String rawKey = applicationProperties.getAutomaticallyGenerated().getKey(); + if (rawKey == null || rawKey.isBlank()) { + // Fallback: SHA-256 of email if no application key available + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(email.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(hash).substring(0, 32); + } + + Mac hmac = Mac.getInstance("HmacSHA256"); + SecretKeySpec keySpec = + new SecretKeySpec(rawKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + hmac.init(keySpec); + byte[] macBytes = hmac.doFinal(email.getBytes(StandardCharsets.UTF_8)); + String encoded = Base64.getEncoder().encodeToString(macBytes); + // Truncate to 32 chars; use URL-safe replacement to avoid keystore password issues + return encoded.replace("/", "_").replace("+", "-").substring(0, 32); + } catch (Exception e) { + throw new IllegalStateException("Failed to derive guest keystore password", e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java index aac9c180fc..741f6ad10d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java @@ -72,6 +72,9 @@ public class SigningFinalizationService { @Autowired(required = false) private final UserServerCertificateService userServerCertificateService; + @Autowired(required = false) + private final GuestCertificateService guestCertificateService; + // ===== PUBLIC API ===== /** @@ -891,6 +894,28 @@ public class SigningFinalizationService { "Failed to generate or retrieve user certificate: " + e.getMessage()); } + case "GUEST_CERT": + if (guestCertificateService == null) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Guest certificate service is not available"); + } + if (participant.getEmail() == null || participant.getEmail().isBlank()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Guest certificate requires participant email address"); + } + try { + return guestCertificateService.generateGuestKeyStore(participant.getEmail()); + } catch (Exception e) { + log.error( + "Failed to generate guest certificate for {}: {}", + participant.getEmail(), + e.getMessage()); + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Failed to generate guest certificate: " + e.getMessage()); + } + default: throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid certificate type: " + certType); @@ -943,6 +968,12 @@ public class SigningFinalizationService { } } + if ("GUEST_CERT".equalsIgnoreCase(certType) + && guestCertificateService != null + && participant.getEmail() != null) { + return guestCertificateService.generateGuestPassword(participant.getEmail()); + } + return submission.getPassword(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java index e8887e2f55..6d3eb771b1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -8,11 +8,13 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -63,6 +65,9 @@ public class WorkflowSessionService { private final MetadataEncryptionService metadataEncryptionService; private final CertificateSubmissionValidator certificateSubmissionValidator; + @Autowired(required = false) + private stirling.software.proprietary.security.service.EmailService emailService; + public void ensureSigningEnabled() { if (!applicationProperties.getStorage().isEnabled() || !applicationProperties.getStorage().getSigning().isEnabled()) { @@ -230,6 +235,62 @@ public class WorkflowSessionService { session.addParticipant(participant); participant = workflowParticipantRepository.save(participant); + + // Send signing invitation email to external/guest participants + if (request.getEmail() != null + && request.getUserId() == null + && emailService != null + && (request.isSendNotification())) { + try { + String signingUrl = buildSigningUrl(participant.getShareToken()); + String ownerName = + session.getOwnerEmail() != null + ? session.getOwnerEmail() + : "A Stirling PDF user"; + String expiresAt = + participant.getExpiresAt() != null + ? participant.getExpiresAt().toString() + : null; + emailService.sendSigningInvitationEmail( + participant.getEmail(), + ownerName, + session.getDocumentName(), + signingUrl, + expiresAt, + session.getMessage()); + participant.setStatus(ParticipantStatus.NOTIFIED); + workflowParticipantRepository.save(participant); + log.info( + "Sent signing invitation to external participant (domain: {})", + emailDomain(participant.getEmail())); + } catch (Exception e) { + log.warn( + "Failed to send signing invitation email (domain: {}): {}", + emailDomain(participant.getEmail()), + e.getMessage()); + } + } + } + } + + /** Returns only the domain portion of an email for privacy-safe logging. */ + private static String emailDomain(String email) { + if (email == null) return ""; + int at = email.indexOf('@'); + return at >= 0 ? email.substring(at) : ""; + } + + /** Builds the guest signing URL for a share token using the current request context. */ + private String buildSigningUrl(String shareToken) { + try { + return ServletUriComponentsBuilder.fromCurrentContextPath() + .path("/sign/") + .path(shareToken) + .toUriString(); + } catch (Exception e) { + // Fallback if called outside of a request context (e.g. in tests) + log.debug("Could not determine base URL from request context, using relative path"); + return "/sign/" + shareToken; } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/EmailServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/EmailServiceTest.java index 66719e3726..3e642dff23 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/EmailServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/EmailServiceTest.java @@ -164,4 +164,110 @@ public class EmailServiceTest { assertEquals("Invalid Addresses", e.getMessage()); } } + + @Test + void sendSigningInvitationEmail_sendsEmailWithDocumentNameInSubject() + throws MessagingException { + when(applicationProperties.getMail()).thenReturn(mailProperties); + when(mailProperties.getFrom()).thenReturn("no-reply@stirling-software.com"); + MimeMessage mimeMessage = mock(MimeMessage.class); + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + + emailService.sendSigningInvitationEmail( + "guest@example.com", + "Alice Owner", + "Contract 2025.pdf", + "https://example.com/sign/abc-token", + "2025-12-31", + "Please review and sign"); + + verify(mailSender).send(mimeMessage); + } + + @Test + void sendSigningInvitationEmail_throwsForBlankRecipient() { + assertThrows( + MessagingException.class, + () -> + emailService.sendSigningInvitationEmail( + "", + "Owner", + "Doc.pdf", + "https://example.com/sign/tok", + null, + null)); + } + + // ── HTML injection / URL guard tests ────────────────────────────────── + + @Test + void sendSigningInvitationEmail_doesNotThrowForHtmlInDocumentName() throws MessagingException { + when(applicationProperties.getMail()).thenReturn(mailProperties); + when(mailProperties.getFrom()).thenReturn("no-reply@stirling.com"); + MimeMessage mimeMessage = mock(MimeMessage.class); + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + + // Document name with injected HTML — must not cause an exception + emailService.sendSigningInvitationEmail( + "guest@example.com", + "Alice", + "Contract.pdf", + "https://example.com/sign/tok", + null, + null); + + verify(mailSender).send(mimeMessage); + } + + @Test + void sendSigningInvitationEmail_doesNotThrowForHtmlInMessage() throws MessagingException { + when(applicationProperties.getMail()).thenReturn(mailProperties); + when(mailProperties.getFrom()).thenReturn("no-reply@stirling.com"); + MimeMessage mimeMessage = mock(MimeMessage.class); + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + + // Personal message with HTML injection + emailService.sendSigningInvitationEmail( + "guest@example.com", + "Bob Owner", + "Contract.pdf", + "https://example.com/sign/tok", + "2026-12-31", + " Please sign"); + + verify(mailSender).send(mimeMessage); + } + + @Test + void sendSigningInvitationEmail_doesNotThrowForJavascriptUrl() throws MessagingException { + when(applicationProperties.getMail()).thenReturn(mailProperties); + when(mailProperties.getFrom()).thenReturn("no-reply@stirling.com"); + MimeMessage mimeMessage = mock(MimeMessage.class); + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + + // javascript: URL — must be replaced with '#', not thrown into email + emailService.sendSigningInvitationEmail( + "guest@example.com", + "Owner", + "Contract.pdf", + "javascript:alert(document.cookie)", + null, + null); + + verify(mailSender).send(mimeMessage); + } + + @Test + void sendSigningInvitationEmail_acceptsNullOptionalParams() throws MessagingException { + when(applicationProperties.getMail()).thenReturn(mailProperties); + when(mailProperties.getFrom()).thenReturn("no-reply@stirling.com"); + MimeMessage mimeMessage = mock(MimeMessage.class); + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + + // expiresAt and message are optional — null values must not cause NPE + emailService.sendSigningInvitationEmail( + "guest@example.com", null, null, "https://example.com/sign/tok", null, null); + + verify(mailSender).send(mimeMessage); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantControllerTest.java new file mode 100644 index 0000000000..b7ec4f4daa --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantControllerTest.java @@ -0,0 +1,354 @@ +package stirling.software.proprietary.workflow.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +import stirling.software.proprietary.workflow.dto.ParticipantResponse; +import stirling.software.proprietary.workflow.dto.SignatureSubmissionRequest; +import stirling.software.proprietary.workflow.model.ParticipantStatus; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; +import stirling.software.proprietary.workflow.model.WorkflowSession; +import stirling.software.proprietary.workflow.model.WorkflowStatus; +import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository; +import stirling.software.proprietary.workflow.service.MetadataEncryptionService; +import stirling.software.proprietary.workflow.service.WorkflowSessionService; + +import tools.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +class WorkflowParticipantControllerTest { + + @Mock private WorkflowSessionService workflowSessionService; + @Mock private WorkflowParticipantRepository participantRepository; + @Mock private MetadataEncryptionService metadataEncryptionService; + + private ObjectMapper objectMapper; + private WorkflowParticipantController controller; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + controller = + new WorkflowParticipantController( + workflowSessionService, + participantRepository, + objectMapper, + metadataEncryptionService); + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private WorkflowParticipant guestParticipant(String token, String email) { + WorkflowSession session = new WorkflowSession(); + session.setStatus(WorkflowStatus.IN_PROGRESS); + session.setFinalized(false); + + WorkflowParticipant participant = new WorkflowParticipant(); + participant.setId(1L); + participant.setShareToken(token); + participant.setEmail(email); + participant.setUser(null); // guest — no registered user + participant.setStatus(ParticipantStatus.VIEWED); + participant.setWorkflowSession(session); + return participant; + } + + // ── GUEST_CERT defaulting ───────────────────────────────────────────── + + @Test + void submitSignature_guestNoCertType_defaultsToGuestCert() throws Exception { + String token = "test-token-123"; + WorkflowParticipant participant = guestParticipant(token, "guest@example.com"); + + when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant)); + when(participantRepository.save(any())).thenReturn(participant); + // No encrypt() stub — GUEST_CERT skips encryption entirely (fix verified in separate test) + + SignatureSubmissionRequest request = new SignatureSubmissionRequest(); + request.setParticipantToken(token); + // certType intentionally not set — should default to GUEST_CERT + + MockHttpServletRequest httpRequest = new MockHttpServletRequest(); + httpRequest.setRemoteAddr("192.168.1.1"); + httpRequest.addHeader("User-Agent", "TestBrowser/1.0"); + + ResponseEntity response = + controller.submitSignature(request, httpRequest); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Capture the saved participant and verify certType in metadata + ArgumentCaptor captor = + ArgumentCaptor.forClass(WorkflowParticipant.class); + verify(participantRepository).save(captor.capture()); + + WorkflowParticipant saved = captor.getValue(); + assertThat(saved.getStatus()).isEqualTo(ParticipantStatus.SIGNED); + + Map metadata = saved.getParticipantMetadata(); + assertThat(metadata).containsKey("certificateSubmission"); + @SuppressWarnings("unchecked") + Map certSub = (Map) metadata.get("certificateSubmission"); + assertThat(certSub.get("certType")).isEqualTo("GUEST_CERT"); + } + + @Test + void submitSignature_registeredUserNoCertType_doesNotDefaultToGuestCert() throws Exception { + String token = "registered-token"; + WorkflowParticipant participant = guestParticipant(token, "user@example.com"); + + // Give participant a registered user + stirling.software.proprietary.security.model.User user = + new stirling.software.proprietary.security.model.User(); + user.setUsername("registered"); + participant.setUser(user); + + when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant)); + when(participantRepository.save(any())).thenReturn(participant); + + SignatureSubmissionRequest request = new SignatureSubmissionRequest(); + request.setParticipantToken(token); + // certType intentionally not set + + MockHttpServletRequest httpRequest = new MockHttpServletRequest(); + + ResponseEntity response = + controller.submitSignature(request, httpRequest); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WorkflowParticipant.class); + verify(participantRepository).save(captor.capture()); + + WorkflowParticipant saved = captor.getValue(); + Map metadata = saved.getParticipantMetadata(); + // No certType defaulted — certificateSubmission should be absent + assertThat(metadata).doesNotContainKey("certificateSubmission"); + } + + // ── Audit trail ─────────────────────────────────────────────────────── + + @Test + void submitSignature_capturesAuditTrail() throws Exception { + String token = "audit-token"; + WorkflowParticipant participant = guestParticipant(token, "audited@example.com"); + + when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant)); + when(participantRepository.save(any())).thenReturn(participant); + + SignatureSubmissionRequest request = new SignatureSubmissionRequest(); + request.setParticipantToken(token); + + MockHttpServletRequest httpRequest = new MockHttpServletRequest(); + httpRequest.setRemoteAddr("10.0.0.1"); + httpRequest.addHeader("User-Agent", "AuditBrowser/2.0"); + + controller.submitSignature(request, httpRequest); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WorkflowParticipant.class); + verify(participantRepository).save(captor.capture()); + + Map metadata = captor.getValue().getParticipantMetadata(); + assertThat(metadata).containsKey("auditTrail"); + + @SuppressWarnings("unchecked") + Map audit = (Map) metadata.get("auditTrail"); + assertThat(audit).containsKey("ipHash"); + assertThat(audit).containsKey("userAgent"); + assertThat(audit).containsKey("submittedAt"); + assertThat(audit.get("email")).isEqualTo("audited@example.com"); + // IP should be hashed, not stored raw + assertThat(audit.get("ipHash")).isNotEqualTo("10.0.0.1"); + assertThat(audit.get("userAgent")).isEqualTo("AuditBrowser/2.0"); + } + + // ── GUEST_CERT password not stored ─────────────────────────────────── + + @Test + void submitSignature_guestCert_doesNotStoreEncryptedPassword() throws Exception { + String token = "no-pwd-token"; + WorkflowParticipant participant = guestParticipant(token, "guest@example.com"); + + when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant)); + when(participantRepository.save(any())).thenReturn(participant); + + SignatureSubmissionRequest request = new SignatureSubmissionRequest(); + request.setParticipantToken(token); + // certType not set → defaults to GUEST_CERT; no password submitted + + MockHttpServletRequest httpRequest = new MockHttpServletRequest(); + + controller.submitSignature(request, httpRequest); + + // metadataEncryptionService.encrypt() must NOT be called for GUEST_CERT + verify(metadataEncryptionService, never()).encrypt(any()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WorkflowParticipant.class); + verify(participantRepository).save(captor.capture()); + + @SuppressWarnings("unchecked") + Map certSub = + (Map) + captor.getValue().getParticipantMetadata().get("certificateSubmission"); + assertThat(certSub.get("password")).isNull(); + } + + // ── Audit trail edge cases ──────────────────────────────────────────── + + @Test + void submitSignature_userAgentTruncatedAt500Chars() throws Exception { + String token = "ua-token"; + WorkflowParticipant participant = guestParticipant(token, "ua@example.com"); + + when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant)); + when(participantRepository.save(any())).thenReturn(participant); + + SignatureSubmissionRequest request = new SignatureSubmissionRequest(); + request.setParticipantToken(token); + + MockHttpServletRequest httpRequest = new MockHttpServletRequest(); + httpRequest.addHeader("User-Agent", "A".repeat(600)); + + controller.submitSignature(request, httpRequest); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WorkflowParticipant.class); + verify(participantRepository).save(captor.capture()); + + @SuppressWarnings("unchecked") + Map audit = + (Map) captor.getValue().getParticipantMetadata().get("auditTrail"); + String storedUa = (String) audit.get("userAgent"); + assertThat(storedUa).hasSize(500); + } + + @Test + void submitSignature_nullIp_storesNullHash() throws Exception { + String token = "null-ip-token"; + WorkflowParticipant participant = guestParticipant(token, "noip@example.com"); + + when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant)); + when(participantRepository.save(any())).thenReturn(participant); + + SignatureSubmissionRequest request = new SignatureSubmissionRequest(); + request.setParticipantToken(token); + + MockHttpServletRequest httpRequest = new MockHttpServletRequest(); + httpRequest.setRemoteAddr(null); + + controller.submitSignature(request, httpRequest); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WorkflowParticipant.class); + verify(participantRepository).save(captor.capture()); + + @SuppressWarnings("unchecked") + Map audit = + (Map) captor.getValue().getParticipantMetadata().get("auditTrail"); + assertThat(audit.get("ipHash")).isNull(); + } + + // ── Token guards ────────────────────────────────────────────────────── + + @Test + void submitSignature_expiredToken_returnsForbidden() { + String token = "expired-token"; + WorkflowParticipant participant = guestParticipant(token, "expired@example.com"); + participant.setExpiresAt(java.time.LocalDateTime.now().minusDays(1)); // expired + + when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant)); + + SignatureSubmissionRequest request = new SignatureSubmissionRequest(); + request.setParticipantToken(token); + + org.springframework.web.server.ResponseStatusException ex = + org.junit.jupiter.api.Assertions.assertThrows( + org.springframework.web.server.ResponseStatusException.class, + () -> controller.submitSignature(request, new MockHttpServletRequest())); + + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void submitSignature_alreadySigned_returnsBadRequest() { + String token = "signed-token"; + WorkflowParticipant participant = guestParticipant(token, "done@example.com"); + participant.setStatus(ParticipantStatus.SIGNED); + + when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant)); + + SignatureSubmissionRequest request = new SignatureSubmissionRequest(); + request.setParticipantToken(token); + + org.springframework.web.server.ResponseStatusException ex = + org.junit.jupiter.api.Assertions.assertThrows( + org.springframework.web.server.ResponseStatusException.class, + () -> controller.submitSignature(request, new MockHttpServletRequest())); + + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void submitSignature_alreadyDeclined_returnsBadRequest() { + String token = "declined-token"; + WorkflowParticipant participant = guestParticipant(token, "declined@example.com"); + participant.setStatus(ParticipantStatus.DECLINED); + + when(participantRepository.findByShareToken(token)).thenReturn(Optional.of(participant)); + + SignatureSubmissionRequest request = new SignatureSubmissionRequest(); + request.setParticipantToken(token); + + org.springframework.web.server.ResponseStatusException ex = + org.junit.jupiter.api.Assertions.assertThrows( + org.springframework.web.server.ResponseStatusException.class, + () -> controller.submitSignature(request, new MockHttpServletRequest())); + + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @Test + void submitSignature_unknownToken_returnsForbidden() { + when(participantRepository.findByShareToken("no-such-token")).thenReturn(Optional.empty()); + + SignatureSubmissionRequest request = new SignatureSubmissionRequest(); + request.setParticipantToken("no-such-token"); + + org.springframework.web.server.ResponseStatusException ex = + org.junit.jupiter.api.Assertions.assertThrows( + org.springframework.web.server.ResponseStatusException.class, + () -> controller.submitSignature(request, new MockHttpServletRequest())); + + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void submitSignature_blankToken_returnsBadRequest() { + SignatureSubmissionRequest request = new SignatureSubmissionRequest(); + request.setParticipantToken(" "); + + org.springframework.web.server.ResponseStatusException ex = + org.junit.jupiter.api.Assertions.assertThrows( + org.springframework.web.server.ResponseStatusException.class, + () -> controller.submitSignature(request, new MockHttpServletRequest())); + + assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/GuestCertificateServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/GuestCertificateServiceTest.java new file mode 100644 index 0000000000..bb0a607d71 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/workflow/service/GuestCertificateServiceTest.java @@ -0,0 +1,138 @@ +package stirling.software.proprietary.workflow.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.security.KeyStore; +import java.security.cert.X509Certificate; + +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.AutomaticallyGenerated; + +@ExtendWith(MockitoExtension.class) +class GuestCertificateServiceTest { + + @Mock private ApplicationProperties applicationProperties; + + @Mock private AutomaticallyGenerated automaticallyGenerated; + + private GuestCertificateService service; + + @BeforeEach + void setUp() { + when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated); + when(automaticallyGenerated.getKey()).thenReturn("test-secret-key-for-unit-tests"); + service = new GuestCertificateService(applicationProperties); + } + + @Test + void generateGuestKeyStore_createsValidPKCS12() throws Exception { + String email = "signer@example.com"; + + KeyStore keyStore = service.generateGuestKeyStore(email); + + assertThat(keyStore).isNotNull(); + assertThat(keyStore.getType()).isEqualToIgnoringCase("PKCS12"); + assertThat(keyStore.aliases().hasMoreElements()).isTrue(); + String alias = keyStore.aliases().nextElement(); + assertThat(keyStore.isKeyEntry(alias)).isTrue(); + assertThat(keyStore.getCertificate(alias)).isNotNull(); + } + + @Test + void generateGuestKeyStore_subjectContainsSanitizedEmail() throws Exception { + String email = "signer@example.com"; + + KeyStore keyStore = service.generateGuestKeyStore(email); + String alias = keyStore.aliases().nextElement(); + X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias); + + String subjectDn = cert.getSubjectX500Principal().getName(); + assertThat(subjectDn).contains("signer@example.com"); + assertThat(subjectDn).contains("Stirling-PDF Guest"); + } + + @Test + void generateGuestKeyStore_sanContainsEmailRfc822Name() throws Exception { + String email = "guest@test.org"; + + KeyStore keyStore = service.generateGuestKeyStore(email); + String alias = keyStore.aliases().nextElement(); + X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias); + + // Parse SAN extension via BouncyCastle + JcaX509CertificateHolder holder = new JcaX509CertificateHolder(cert); + org.bouncycastle.asn1.x509.GeneralNames sans = + org.bouncycastle.asn1.x509.GeneralNames.getInstance( + holder.getExtension(Extension.subjectAlternativeName).getParsedValue()); + + boolean foundEmail = false; + for (org.bouncycastle.asn1.x509.GeneralName gn : sans.getNames()) { + if (gn.getTagNo() == org.bouncycastle.asn1.x509.GeneralName.rfc822Name) { + String value = gn.getName().toString(); + if (value.equals(email)) { + foundEmail = true; + } + } + } + assertThat(foundEmail) + .as("SAN should contain rfc822Name matching the signer's email") + .isTrue(); + } + + @Test + void generateGuestPassword_isDeterministic() { + String email = "signer@example.com"; + + String pw1 = service.generateGuestPassword(email); + String pw2 = service.generateGuestPassword(email); + + assertThat(pw1).isEqualTo(pw2); + assertThat(pw1).hasSize(32); + } + + @Test + void generateGuestPassword_differentEmails_differentPasswords() { + String pw1 = service.generateGuestPassword("alice@example.com"); + String pw2 = service.generateGuestPassword("bob@example.com"); + + assertThat(pw1).isNotEqualTo(pw2); + } + + @Test + void generateGuestPassword_fallbackWhenNoKey() { + // If no app key is configured, should fall back to SHA-256 of email + when(automaticallyGenerated.getKey()).thenReturn(null); + + String pw = service.generateGuestPassword("fallback@example.com"); + + assertThat(pw).isNotNull(); + assertThat(pw).hasSize(32); + } + + @Test + void generateGuestKeyStore_certIsValidForSigning() throws Exception { + String email = "signer@example.com"; + + KeyStore keyStore = service.generateGuestKeyStore(email); + String alias = keyStore.aliases().nextElement(); + X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias); + + // Certificate must be currently valid + cert.checkValidity(); + + // Key usage must include digitalSignature (bit 0) and nonRepudiation (bit 1) + boolean[] keyUsage = cert.getKeyUsage(); + assertThat(keyUsage).isNotNull(); + assertThat(keyUsage[0]).as("digitalSignature key usage").isTrue(); + assertThat(keyUsage[1]).as("nonRepudiation key usage").isTrue(); + } +} diff --git a/docs/guest-signing-test-plan.md b/docs/guest-signing-test-plan.md new file mode 100644 index 0000000000..8b9762a67c --- /dev/null +++ b/docs/guest-signing-test-plan.md @@ -0,0 +1,270 @@ +# Guest Signing — Test Plan + +## Automated coverage summary + +The following scenarios are already covered by automated tests and **do not need manual testing**: + +| Area | Coverage | Test file | +|------|----------|-----------| +| Guest certificate generation (PKCS12, SAN, key usage) | Unit | `GuestCertificateServiceTest.java` | +| EKU is `emailProtection` (not `codeSigning`) | Unit | `GuestCertificateServiceTest.java` | +| Password determinism / uniqueness | Unit | `GuestCertificateServiceTest.java` | +| GUEST_CERT defaulted for external participants | Unit | `WorkflowParticipantControllerTest.java` | +| Registered users not defaulted to GUEST_CERT | Unit | `WorkflowParticipantControllerTest.java` | +| Audit trail: IP hashed, UA stored, timestamp present | Unit | `WorkflowParticipantControllerTest.java` | +| Audit trail: User-agent truncated at 500 chars | Unit | `WorkflowParticipantControllerTest.java` | +| Audit trail: null IP stored as null (not exception) | Unit | `WorkflowParticipantControllerTest.java` | +| GUEST_CERT password not stored in metadata | Unit | `WorkflowParticipantControllerTest.java` | +| Expired token → 403 | Unit | `WorkflowParticipantControllerTest.java` | +| Already-SIGNED → 400 | Unit | `WorkflowParticipantControllerTest.java` | +| Already-DECLINED → 400 | Unit | `WorkflowParticipantControllerTest.java` | +| Unknown token → 403 | Unit | `WorkflowParticipantControllerTest.java` | +| Blank token → 400 | Unit | `WorkflowParticipantControllerTest.java` | +| HTML injection in email: docName, ownerName, message | Unit | `EmailServiceTest.java` | +| `javascript:` URL replaced with `#` in email | Unit | `EmailServiceTest.java` | +| Email sends for null optional params | Unit | `EmailServiceTest.java` | +| `/sign/:token` — loading spinner shown | E2E | `GuestSigningE2E.spec.ts` | +| `/sign/:token` — expired (403) page | E2E | `GuestSigningE2E.spec.ts` | +| `/sign/:token` — already-SIGNED state on load | E2E | `GuestSigningE2E.spec.ts` | +| `/sign/:token` — already-DECLINED state on load | E2E | `GuestSigningE2E.spec.ts` | +| `/sign/:token` — 500 error page | E2E | `GuestSigningE2E.spec.ts` | +| Signing form renders with doc name, owner, message | E2E | `GuestSigningE2E.spec.ts` | +| PDF iframe present with correct src | E2E | `GuestSigningE2E.spec.ts` | +| Auto-cert selected by default + info alert | E2E | `GuestSigningE2E.spec.ts` | +| Switching to P12 shows file + password inputs | E2E | `GuestSigningE2E.spec.ts` | +| Switching back to auto-cert hides P12 inputs | E2E | `GuestSigningE2E.spec.ts` | +| Submit → success page; FormData contains GUEST_CERT + token | E2E | `GuestSigningE2E.spec.ts` | +| Submit failure shows error with server message | E2E | `GuestSigningE2E.spec.ts` | +| Submit button disabled while in-flight | E2E | `GuestSigningE2E.spec.ts` | +| Decline → opens modal with correct title + body | E2E | `GuestSigningE2E.spec.ts` | +| Decline → Cancel closes modal, form still visible | E2E | `GuestSigningE2E.spec.ts` | +| Decline → Confirm transitions to declined state | E2E | `GuestSigningE2E.spec.ts` | +| SelectParticipantsStep — registered/external tabs | Unit | `SelectParticipantsStep.test.tsx` | +| SelectParticipantsStep — email validation + duplicates | Unit | `SelectParticipantsStep.test.tsx` | +| SelectParticipantsStep — add by button / Enter key | Unit | `SelectParticipantsStep.test.tsx` | +| SelectParticipantsStep — remove participant | Unit | `SelectParticipantsStep.test.tsx` | +| SelectParticipantsStep — Continue disabled when empty | Unit | `SelectParticipantsStep.test.tsx` | +| GuestSignPage — all page states (loading/expired/signed/etc.) | Unit | `GuestSignPage.test.tsx` | +| GuestSignPage — submit calls correct endpoint | Unit | `GuestSignPage.test.tsx` | + +--- + +## Running the automated tests + +```bash +# Backend unit tests (from repo root) +./gradlew :app:proprietary:test \ + --tests "stirling.software.proprietary.workflow.service.GuestCertificateServiceTest" \ + --tests "stirling.software.proprietary.workflow.controller.WorkflowParticipantControllerTest" \ + --tests "stirling.software.proprietary.security.service.EmailServiceTest" + +# Frontend unit tests +cd frontend && npm test -- --run \ + src/core/routes/GuestSignPage.test.tsx \ + src/core/components/shared/signing/steps/SelectParticipantsStep.test.tsx + +# Frontend E2E (requires dev server running) +cd frontend && npx playwright test src/core/tests/guestSigning/GuestSigningE2E.spec.ts +``` + +--- + +## Residual manual test plan + +These scenarios require a running system with real SMTP, storage, and a PDF. + +### Prerequisites + +- Stirling PDF running with the config below applied +- A real SMTP server (or MailHog locally) receiving email +- A test PDF document +- A valid .p12 certificate for the P12 upload test + +--- + +### MT-1 — Owner invites an external guest (email delivery) + +**Steps:** +1. Log in as a registered user +2. Open a PDF and start a signing session +3. In the participants step, switch to the **External (by email)** tab +4. Enter a real email address and click Add +5. Continue through the session creation flow +6. Check the inbox of the guest email address + +**Expected:** +- Email received with subject `Please sign: ` +- Email shows owner's email, document name, optional message +- "Review and Sign Document" button links to `https:///sign/` +- No raw HTML visible in the email body (escaping check) +- No raw IP address visible anywhere in the email + +--- + +### MT-2 — Guest signs with auto-generated certificate (happy path) + +**Steps:** +1. Open the signing link from MT-1 +2. Verify the loading spinner appears briefly +3. Verify document name and owner info are displayed +4. Verify PDF is visible in the embedded preview +5. Verify **"Use auto-generated certificate (recommended)"** is selected by default +6. Draw a signature in the canvas +7. Click **Submit Signature** + +**Expected:** +- Success page: "Your signature has been submitted successfully." +- PDF in session now has a digital signature +- Signature certificate CN includes the guest's email (sanitized) +- Certificate SAN contains `rfc822Name=` — verify in Adobe Acrobat / PDF viewer signature panel +- Participant status in admin panel changed to SIGNED +- Audit trail in database: `ipHash` is a Base64 SHA-256 (not raw IP), `userAgent` matches browser, `submittedAt` timestamp is recent + +--- + +### MT-3 — Guest signs with their own P12 certificate + +**Steps:** +1. Open a fresh signing link (new session) +2. Select **"Upload my own certificate"** +3. Upload a valid `.p12` file +4. Enter the certificate password +5. Draw a signature and submit + +**Expected:** +- Success page shown +- PDF signed with the uploaded certificate (not a Stirling-generated one) +- Signature visible in PDF viewer with details from the P12 + +--- + +### MT-4 — Guest uses an expired signing link + +**Steps:** +1. Set a participant's `expires_at` to the past in the database (or wait for expiry) +2. Navigate to the signing link + +**Expected:** +- "This signing link has expired." page +- Contact message displayed +- No way to proceed to the signing form + +--- + +### MT-5 — Guest declines and owner is notified + +**Steps:** +1. Open a valid signing link +2. Click **Decline** +3. Confirm in the modal + +**Expected:** +- "You have declined this signing request." page +- Participant status in session changes to DECLINED +- Session owner sees participant marked as declined in the session view + +--- + +### MT-6 — Duplicate submission prevented + +**Steps:** +1. Complete signing (MT-2) +2. Navigate back to the same `/sign/:token` URL + +**Expected:** +- Page immediately shows the already-signed state ("Your signature has been submitted successfully.") +- No signing form is shown + +--- + +### MT-7 — Email content security + +**Steps:** +1. Create a session with a document named: `.pdf` +2. Send invitation to a guest + +**Expected:** +- Email subject: `Please sign: .pdf` (angle brackets visible as text, not rendered) +- Email body: document name appears as text `<img src=x onerror=alert(1)>.pdf` +- No JavaScript executes in the email client + +--- + +### MT-8 — Mobile / responsive layout + +**Steps:** +1. Open the signing link on a mobile device or via browser DevTools responsive mode (375px width) + +**Expected:** +- Page is usable at mobile width +- PDF preview, certificate chooser, and signature canvas are all visible and usable +- Buttons are appropriately sized for touch + +--- + +### MT-9 — No-email configuration (mail disabled) + +**Steps:** +1. Disable `mail.enabled` in config +2. Create a session with an external guest participant + +**Expected:** +- Session creation succeeds +- No email is sent (no error in logs) +- Participant status remains `PENDING` (not `NOTIFIED`) +- Operator can still manually share the signing URL from the session detail view + +--- + +## Config required + +Add to your `settings.yml` (or environment variables): + +```yaml +storage: + enabled: true # required for group signing + signing: + enabled: true # enables the signing session feature + +mail: + enabled: true + host: smtp.example.com # your SMTP server + port: 587 + username: stirling@example.com + password: your-smtp-password + from: noreply@stirling-pdf.example.com + startTlsEnable: true # recommended; use sslEnable: true + port 465 for implicit TLS +``` + +### Local development with MailHog + +MailHog provides a local SMTP server with a web UI at `http://localhost:8025`. + +```bash +# Start MailHog (Docker) +docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog + +# settings.yml for local dev +mail: + enabled: true + host: localhost + port: 1025 + username: "" + password: "" + from: test@stirling-pdf.local + startTlsEnable: false +``` + +### Environment variable equivalents + +``` +STIRLING_STORAGE_ENABLED=true +STIRLING_STORAGE_SIGNING_ENABLED=true +MAIL_ENABLED=true +MAIL_HOST=smtp.example.com +MAIL_PORT=587 +MAIL_USERNAME=stirling@example.com +MAIL_PASSWORD=secret +MAIL_FROM=noreply@stirling-pdf.example.com +MAIL_STARTTLS_ENABLE=true +``` diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 14eb25f85c..e886fff44a 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: 'http://localhost:5173', + baseURL: 'http://localhost:5174', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', @@ -68,8 +68,8 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'npm run dev', - url: 'http://localhost:5173', + command: 'npm run dev -- --port 5174', + url: 'http://localhost:5174', reuseExistingServer: !process.env.CI, }, }); \ No newline at end of file diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 4550ce99f3..47735456bd 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -3750,10 +3750,20 @@ selectedFile = "Selected document" title = "Select Document" [groupSigning.steps.selectParticipants] +addEmail = "Add" +badgeExternal = "Guest" +badgeRegistered = "User" continue = "Continue to Signature Settings" -count = "{{count}} participant(s) selected" +count = "{{count}} participant(s)" +duplicateEmail = "This email has already been added" +emailInviteNote = "External participants will receive an email invitation with a signing link." +emailPlaceholder = "signer@example.com" +externalNote = "An invitation email will be sent with a signing link. No account required." +invalidEmail = "Please enter a valid email address" label = "Select participants" placeholder = "Choose participants to sign..." +tabExternal = "External (by email)" +tabRegistered = "Registered Users" title = "Choose Participants" [getPdfInfo] @@ -3868,6 +3878,35 @@ message = "Create a free account to save your work, access more features, and su signUp = "Sign Up Free" title = "You're using Stirling PDF as a guest!" +[guestSigning] +certAutoNote = "A certificate will be generated using your email address for traceability." +certChoiceAuto = "Use auto-generated certificate (recommended)" +certChoiceUpload = "Upload my own certificate" +certFileLabel = "Certificate file (.p12 / .pfx)" +certFilePlaceholder = "Select .p12 or .pfx file" +certFileRequired = "Please select a certificate file." +certPasswordLabel = "Certificate password" +certPasswordPlaceholder = "Enter certificate password" +certSectionTitle = "Signing Certificate" +declineButton = "Decline" +declineConfirmBody = "Are you sure you want to decline? This action cannot be undone." +declineConfirmTitle = "Decline signing?" +declinedNote = "The document owner has been notified. You may close this window." +declineSuccess = "You have declined this signing request." +documentPreview = "Document" +dueDate = "Due {{date}}" +errorTitle = "Something went wrong" +expiredNote = "Please contact the document owner for a new link." +expiredToken = "This signing link has expired." +loadingSession = "Loading signing session..." +pageTitle = "Sign Document" +requestedBy = "Requested by {{owner}}" +signatureTitle = "Your Signature" +signedNote = "The document owner has been notified. You may close this window." +submitButton = "Submit Signature" +submitError = "Failed to submit signature." +submitSuccess = "Your signature has been submitted successfully." + [home] alphabetical = "Alphabetical" desc = "Your locally hosted one-stop-shop for all your PDF needs." diff --git a/frontend/src/core/components/shared/signing/CreateSessionFlow.tsx b/frontend/src/core/components/shared/signing/CreateSessionFlow.tsx index 6643e1d10c..b1f78c8ead 100644 --- a/frontend/src/core/components/shared/signing/CreateSessionFlow.tsx +++ b/frontend/src/core/components/shared/signing/CreateSessionFlow.tsx @@ -3,7 +3,10 @@ import { Stack, Text, Group, Badge } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import CheckIcon from '@mui/icons-material/Check'; import { SelectDocumentStep } from '@app/components/shared/signing/steps/SelectDocumentStep'; -import { SelectParticipantsStep } from '@app/components/shared/signing/steps/SelectParticipantsStep'; +import { + SelectParticipantsStep, + type Participant, +} from '@app/components/shared/signing/steps/SelectParticipantsStep'; import { ConfigureSignatureDefaultsStep } from '@app/components/shared/signing/steps/ConfigureSignatureDefaultsStep'; import { ReviewSessionStep } from '@app/components/shared/signing/steps/ReviewSessionStep'; import { useGroupSigningTips } from '@app/components/tooltips/useGroupSigningTips'; @@ -13,8 +16,8 @@ import type { FileState } from '@app/types/file'; interface CreateSessionFlowProps { selectedFiles: FileState[]; - selectedUserIds: number[]; - onSelectedUserIdsChange: (userIds: number[]) => void; + participants: Participant[]; + onParticipantsChange: (participants: Participant[]) => void; dueDate: string; onDueDateChange: (date: string) => void; creating: boolean; @@ -102,8 +105,8 @@ const StepWrapper: React.FC = ({ export const CreateSessionFlow: React.FC = ({ selectedFiles, - selectedUserIds, - onSelectedUserIdsChange, + participants, + onParticipantsChange, dueDate, onDueDateChange, creating, @@ -186,8 +189,8 @@ export const CreateSessionFlow: React.FC = ({ isCompleted={currentStep > 2} > setCurrentStep(1)} onNext={() => setCurrentStep(3)} disabled={creating} @@ -220,7 +223,7 @@ export const CreateSessionFlow: React.FC = ({ {selectedFile && ( void; + participants: Participant[]; + onParticipantsChange: (participants: Participant[]) => void; dueDate: string; onDueDateChange: (date: string) => void; creating: boolean; @@ -14,10 +19,12 @@ interface CreateSessionPanelProps { onIncludeSummaryPageChange: (value: boolean) => void; } +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + const CreateSessionPanel = ({ selectedFiles, - selectedUserIds, - onSelectedUserIdsChange, + participants, + onParticipantsChange, dueDate, onDueDateChange, creating, @@ -25,9 +32,40 @@ const CreateSessionPanel = ({ onIncludeSummaryPageChange, }: CreateSessionPanelProps) => { const { t } = useTranslation(); + const [emailInput, setEmailInput] = useState(''); const hasValidFile = selectedFiles.length === 1; + const registeredUserIds = participants + .filter((p) => p.type === 'registered' && p.userId != null) + .map((p) => p.userId as number); + + function handleRegisteredChange(userIds: number[]) { + const external = participants.filter((p) => p.type === 'external'); + onParticipantsChange([ + ...userIds.map((id): Participant => ({ type: 'registered', userId: id })), + ...external, + ]); + } + + function handleAddEmail() { + const trimmed = emailInput.trim(); + if (!EMAIL_RE.test(trimmed)) return; + if (participants.some((p) => p.email === trimmed)) return; + onParticipantsChange([...participants, { type: 'external', email: trimmed }]); + setEmailInput(''); + } + + function handleRemove(participant: Participant) { + onParticipantsChange( + participants.filter((p) => + participant.type === 'registered' + ? !(p.type === 'registered' && p.userId === participant.userId) + : !(p.type === 'external' && p.email === participant.email) + ) + ); + } + return (
{!hasValidFile ? ( @@ -46,14 +84,89 @@ const CreateSessionPanel = ({
-
{t('quickAccess.selectUsers', 'Select users to sign')}
- +
{t('quickAccess.selectUsers', 'Select participants to sign')}
+ + + + } style={{ fontSize: 11, padding: '4px 8px' }}> + {t('groupSigning.steps.selectParticipants.tabRegistered', 'Registered')} + + } style={{ fontSize: 11, padding: '4px 8px' }}> + {t('groupSigning.steps.selectParticipants.tabExternal', 'External')} + + + + + + + + + + setEmailInput(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddEmail(); + } + }} + disabled={creating} + /> + + + + + + {/* Combined participant list */} + {participants.length > 0 && ( +
+ {participants.map((p, i) => ( + + + + {p.type === 'external' + ? t('groupSigning.steps.selectParticipants.badgeExternal', 'Guest') + : t('groupSigning.steps.selectParticipants.badgeRegistered', 'User')} + + + {p.type === 'external' ? p.email : `#${p.userId}`} + + + handleRemove(p)} + disabled={creating} + > + + + + ))} +
+ )}
diff --git a/frontend/src/core/components/shared/signing/GuestCertificateChooser.tsx b/frontend/src/core/components/shared/signing/GuestCertificateChooser.tsx new file mode 100644 index 0000000000..814ba7492c --- /dev/null +++ b/frontend/src/core/components/shared/signing/GuestCertificateChooser.tsx @@ -0,0 +1,82 @@ +import { Alert, FileInput, PasswordInput, Radio, Stack, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +export type GuestCertType = 'GUEST_CERT' | 'P12'; + +interface GuestCertificateChooserProps { + value: GuestCertType; + onChange: (certType: GuestCertType) => void; + onFileChange: (file: File | null) => void; + onPasswordChange: (password: string) => void; + p12File: File | null; + password: string; +} + +export const GuestCertificateChooser: React.FC = ({ + value, + onChange, + onFileChange, + onPasswordChange, + p12File, + password, +}) => { + const { t } = useTranslation(); + + return ( + + + {t('guestSigning.certSectionTitle', 'Signing Certificate')} + + + { + onChange(val as GuestCertType); + // Clear uploaded file when switching back to auto + if (val === 'GUEST_CERT') { + onFileChange(null); + onPasswordChange(''); + } + }} + > + + + + + + + {value === 'GUEST_CERT' && ( + + {t( + 'guestSigning.certAutoNote', + 'A certificate will be generated using your email address for traceability.' + )} + + )} + + {value === 'P12' && ( + + + onPasswordChange(e.currentTarget.value)} + /> + + )} + + ); +}; diff --git a/frontend/src/core/components/shared/signing/SignPopout.tsx b/frontend/src/core/components/shared/signing/SignPopout.tsx index 183f731831..9d3b14253a 100644 --- a/frontend/src/core/components/shared/signing/SignPopout.tsx +++ b/frontend/src/core/components/shared/signing/SignPopout.tsx @@ -82,7 +82,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: const [loading, setLoading] = useState(false); // Create form state - const [selectedUserIds, setSelectedUserIds] = useState([]); + const [participants, setParticipants] = useState([]); const [dueDate, setDueDate] = useState(''); const [creating, setCreating] = useState(false); const [includeSummaryPage, setIncludeSummaryPage] = useState(false); @@ -309,7 +309,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: // Create session handler const handleCreateSession = useCallback(async () => { - if (selectedUserIds.length === 0 || selectedFiles.length !== 1) return; + if (participants.length === 0 || selectedFiles.length !== 1) return; setCreating(true); try { @@ -321,8 +321,13 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: formData.append('file', stirlingFile, selectedFile.name); formData.append('workflowType', 'SIGNING'); formData.append('documentName', selectedFile.name); - selectedUserIds.forEach((userId, index) => { - formData.append(`participantUserIds[${index}]`, userId.toString()); + const registeredIds = participants.filter((p) => p.type === 'registered' && p.userId != null); + const externalEmails = participants.filter((p) => p.type === 'external' && p.email != null); + registeredIds.forEach((p, index) => { + formData.append(`participantUserIds[${index}]`, (p.userId as number).toString()); + }); + externalEmails.forEach((p, index) => { + formData.append(`participantEmails[${index}]`, p.email as string); }); if (dueDate) formData.append('dueDate', dueDate); formData.append('notifyOnCreate', 'true'); @@ -364,7 +369,7 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: } finally { setCreating(false); } - }, [selectedUserIds, dueDate, selectedFiles, fetchData, t, includeSummaryPage]); + }, [participants, dueDate, selectedFiles, fetchData, t, includeSummaryPage]); // Handle clicking a sign request const handleSignRequestClick = useCallback(async (request: SignRequestSummary) => { @@ -465,8 +470,8 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: pdfFile, onFinalize: () => handleFinalize(session.sessionId, session.documentName), onLoadSignedPdf: () => handleLoadSignedPdf(session.sessionId, session.documentName), - onAddParticipants: (userIds: number[], defaultReason?: string) => - handleAddParticipants(session.sessionId, userIds, defaultReason), + onAddParticipants: (userIds: number[], emails: string[], defaultReason?: string) => + handleAddParticipants(session.sessionId, userIds, emails, defaultReason), onRemoveParticipant: (participantId: number) => handleRemoveParticipant(session.sessionId, participantId), onDelete: () => handleDeleteSession(session.sessionId), onBack: () => { @@ -566,12 +571,19 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: navigationActions.setWorkbench('viewer'); }; - const handleAddParticipants = async (sessionId: string, userIds: number[], defaultReason?: string) => { - const requests = userIds.map(userId => ({ - userId, - defaultReason: defaultReason || undefined, - sendNotification: true, - })); + const handleAddParticipants = async (sessionId: string, userIds: number[], emails: string[], defaultReason?: string) => { + const requests = [ + ...userIds.map(userId => ({ + userId, + defaultReason: defaultReason || undefined, + sendNotification: true, + })), + ...emails.map(email => ({ + email, + defaultReason: defaultReason || undefined, + sendNotification: true, + })), + ]; await apiClient.post(`/api/v1/security/cert-sign/sessions/${sessionId}/participants`, requests); await handleRefreshSession(sessionId); }; @@ -750,8 +762,8 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: {showCreatePanel ? ( {creating diff --git a/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.test.tsx b/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.test.tsx new file mode 100644 index 0000000000..588ffceabf --- /dev/null +++ b/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.test.tsx @@ -0,0 +1,317 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MantineProvider } from '@mantine/core'; + +// ── i18n ──────────────────────────────────────────────────────────────────── + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string | Record) => { + if (typeof fallback === 'string') return fallback; + if (typeof fallback === 'object' && fallback !== null) { + // Handle interpolation like {{count}} + const defaultValue = (fallback as Record).defaultValue; + if (typeof defaultValue === 'string') return defaultValue; + } + return key; + }, + }), + initReactI18next: { type: '3rdParty', init: vi.fn() }, +})); + +// ── UserSelector stub — renders a simple multi-select ─────────────────────── + +vi.mock('@app/components/shared/UserSelector', () => ({ + default: ({ + value, + onChange, + }: { + value: number[]; + onChange: (ids: number[]) => void; + }) => ( +
+ + + {value.length} +
+ ), +})); + +// ── MUI icons stub (to avoid SVG transform issues in jsdom) ───────────────── + +vi.mock('@mui/icons-material/ArrowBack', () => ({ default: () => null })); +vi.mock('@mui/icons-material/Close', () => ({ default: () => null })); +vi.mock('@mui/icons-material/Person', () => ({ default: () => null })); +vi.mock('@mui/icons-material/Email', () => ({ default: () => null })); + +// ── Component under test ──────────────────────────────────────────────────── + +import { SelectParticipantsStep, Participant } from './SelectParticipantsStep'; + +// ── Test wrapper ──────────────────────────────────────────────────────────── + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// ── Default props ─────────────────────────────────────────────────────────── + +function makeProps(overrides?: Partial<{ + participants: Participant[]; + onParticipantsChange: (p: Participant[]) => void; + onBack: () => void; + onNext: () => void; +}>) { + return { + participants: [] as Participant[], + onParticipantsChange: vi.fn(), + onBack: vi.fn(), + onNext: vi.fn(), + ...overrides, + }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('SelectParticipantsStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the registered users tab by default', () => { + render( + + + + ); + + expect(screen.getByText('Registered Users')).toBeInTheDocument(); + expect(screen.getByTestId('user-selector')).toBeInTheDocument(); + }); + + it('renders the external tab', () => { + render( + + + + ); + + expect(screen.getByText('External (by email)')).toBeInTheDocument(); + }); + + it('Continue button is disabled when participants list is empty', () => { + render( + + + + ); + + const continueBtn = screen.getByText('Continue to Signature Settings'); + expect(continueBtn.closest('button')).toBeDisabled(); + }); + + it('Continue button is enabled when participants list has entries', () => { + render( + + + + ); + + const continueBtn = screen.getByText('Continue to Signature Settings'); + expect(continueBtn.closest('button')).not.toBeDisabled(); + }); + + it('adds external email to participant list on Add button click', async () => { + const user = userEvent.setup(); + const onParticipantsChange = vi.fn(); + + render( + + + + ); + + // Switch to external tab + await user.click(screen.getByText('External (by email)')); + + const input = screen.getByPlaceholderText('signer@example.com'); + await user.type(input, 'new@example.com'); + await user.click(screen.getByText('Add')); + + expect(onParticipantsChange).toHaveBeenCalledWith([ + { type: 'external', email: 'new@example.com' }, + ]); + }); + + it('adds external email on Enter keypress', async () => { + const user = userEvent.setup(); + const onParticipantsChange = vi.fn(); + + render( + + + + ); + + await user.click(screen.getByText('External (by email)')); + + const input = screen.getByPlaceholderText('signer@example.com'); + await user.type(input, 'enter@example.com{Enter}'); + + expect(onParticipantsChange).toHaveBeenCalledWith([ + { type: 'external', email: 'enter@example.com' }, + ]); + }); + + it('shows validation error for invalid email format', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByText('External (by email)')); + + const input = screen.getByPlaceholderText('signer@example.com'); + await user.type(input, 'not-an-email'); + await user.click(screen.getByText('Add')); + + expect(screen.getByText('Please enter a valid email address')).toBeInTheDocument(); + }); + + it('shows duplicate email error when same email is added twice', async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await user.click(screen.getByText('External (by email)')); + + const input = screen.getByPlaceholderText('signer@example.com'); + await user.type(input, 'existing@example.com'); + await user.click(screen.getByText('Add')); + + expect(screen.getByText('This email has already been added')).toBeInTheDocument(); + }); + + it('displays external participants as Guest badges', () => { + render( + + + + ); + + expect(screen.getByText('guest@example.com')).toBeInTheDocument(); + expect(screen.getByText('Guest')).toBeInTheDocument(); + }); + + it('removes participant when close button is clicked', () => { + const onParticipantsChange = vi.fn(); + + render( + + + + ); + + // The remove action icon + const removeButtons = screen.getAllByRole('button'); + const removeBtn = removeButtons.find( + (btn) => btn.getAttribute('data-variant') === 'subtle' + ); + expect(removeBtn).toBeDefined(); + fireEvent.click(removeBtn!); + + expect(onParticipantsChange).toHaveBeenCalledWith([]); + }); + + it('shows email invite alert when external participants are present', () => { + render( + + + + ); + + expect( + screen.getByText(/External participants will receive an email invitation/i) + ).toBeInTheDocument(); + }); + + it('does not show email invite alert when only registered users are present', () => { + render( + + + + ); + + expect( + screen.queryByText(/External participants will receive an email invitation/i) + ).not.toBeInTheDocument(); + }); + + it('calls onBack when Back button is clicked', async () => { + const user = userEvent.setup(); + const onBack = vi.fn(); + + render( + + + + ); + + await user.click(screen.getByText('Back')); + expect(onBack).toHaveBeenCalledOnce(); + }); + + it('calls onNext when Continue button is clicked with participants', async () => { + const user = userEvent.setup(); + const onNext = vi.fn(); + + render( + + + + ); + + await user.click(screen.getByText('Continue to Signature Settings')); + expect(onNext).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx b/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx index 04af1ffe7c..372e91c737 100644 --- a/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx +++ b/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx @@ -1,58 +1,234 @@ -import { Button, Stack, Text, Group } from '@mantine/core'; +import { useState } from 'react'; +import { + ActionIcon, + Alert, + Badge, + Button, + Group, + Stack, + Tabs, + Text, + TextInput, +} from '@mantine/core'; import { useTranslation } from 'react-i18next'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import CloseIcon from '@mui/icons-material/Close'; +import PersonIcon from '@mui/icons-material/Person'; +import EmailIcon from '@mui/icons-material/Email'; import UserSelector from '@app/components/shared/UserSelector'; +// ─── Shared participant model ───────────────────────────────────────────────── + +export interface Participant { + type: 'registered' | 'external'; + /** Present for registered users */ + userId?: number; + /** Present for external/guest users */ + email?: string; + name?: string; +} + +// ─── Props ─────────────────────────────────────────────────────────────────── + interface SelectParticipantsStepProps { - selectedUserIds: number[]; - onSelectedUserIdsChange: (userIds: number[]) => void; + participants: Participant[]; + onParticipantsChange: (participants: Participant[]) => void; onBack: () => void; onNext: () => void; disabled?: boolean; } +// ─── Email validation ───────────────────────────────────────────────────────── + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +// ─── Component ─────────────────────────────────────────────────────────────── + export const SelectParticipantsStep: React.FC = ({ - selectedUserIds, - onSelectedUserIdsChange, + participants, + onParticipantsChange, onBack, onNext, disabled = false, }) => { const { t } = useTranslation(); - const hasParticipants = selectedUserIds.length > 0; + // External email input state + const [emailInput, setEmailInput] = useState(''); + const [emailError, setEmailError] = useState(''); + + const registeredUserIds = participants + .filter((p) => p.type === 'registered' && p.userId != null) + .map((p) => p.userId as number); + + function handleRegisteredChange(userIds: number[]) { + const external = participants.filter((p) => p.type === 'external'); + const registered: Participant[] = userIds.map((id) => ({ type: 'registered', userId: id })); + onParticipantsChange([...registered, ...external]); + } + + function handleAddEmail() { + const trimmed = emailInput.trim(); + if (!EMAIL_RE.test(trimmed)) { + setEmailError( + t( + 'groupSigning.steps.selectParticipants.invalidEmail', + 'Please enter a valid email address' + ) + ); + return; + } + if (participants.some((p) => p.email === trimmed)) { + setEmailError( + t( + 'groupSigning.steps.selectParticipants.duplicateEmail', + 'This email has already been added' + ) + ); + return; + } + onParticipantsChange([...participants, { type: 'external', email: trimmed }]); + setEmailInput(''); + setEmailError(''); + } + + function handleRemove(participant: Participant) { + onParticipantsChange( + participants.filter((p) => + participant.type === 'registered' + ? !(p.type === 'registered' && p.userId === participant.userId) + : !(p.type === 'external' && p.email === participant.email) + ) + ); + } return ( -
- - {t('groupSigning.steps.selectParticipants.label', 'Select participants')} - - -
+ + + }> + {t('groupSigning.steps.selectParticipants.tabRegistered', 'Registered Users')} + + }> + {t('groupSigning.steps.selectParticipants.tabExternal', 'External (by email)')} + + - {selectedUserIds.length > 0 && ( - - {t('groupSigning.steps.selectParticipants.count', { - count: selectedUserIds.length, - defaultValue: '{{count}} participant(s) selected', - })} - + + + + {t('groupSigning.steps.selectParticipants.label', 'Select participants')} + + + + + + + + + {t( + 'groupSigning.steps.selectParticipants.externalNote', + 'An invitation email will be sent with a signing link. No account required.' + )} + + + { + setEmailInput(e.currentTarget.value); + if (emailError) setEmailError(''); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddEmail(); + } + }} + error={emailError} + disabled={disabled} + /> + + + + + + + {/* Combined participant list */} + {participants.length > 0 && ( + + + {t('groupSigning.steps.selectParticipants.count', { + count: participants.length, + defaultValue: '{{count}} participant(s)', + })} + + {participants.map((p, i) => ( + + + + {p.type === 'external' + ? t('groupSigning.steps.selectParticipants.badgeExternal', 'Guest') + : t('groupSigning.steps.selectParticipants.badgeRegistered', 'User')} + + + {p.type === 'external' ? p.email : `User #${p.userId}`} + + + handleRemove(p)} + disabled={disabled} + > + + + + ))} + + )} + + {participants.some((p) => p.type === 'external') && ( + }> + {t( + 'groupSigning.steps.selectParticipants.emailInviteNote', + 'External participants will receive an email invitation with a signing link.' + )} + )} - - diff --git a/frontend/src/core/components/tools/certSign/SessionDetailWorkbenchView.tsx b/frontend/src/core/components/tools/certSign/SessionDetailWorkbenchView.tsx index afb4c28a77..37f3a42ce8 100644 --- a/frontend/src/core/components/tools/certSign/SessionDetailWorkbenchView.tsx +++ b/frontend/src/core/components/tools/certSign/SessionDetailWorkbenchView.tsx @@ -21,7 +21,7 @@ export interface SessionDetailWorkbenchData { pdfFile: File | null; onFinalize: () => Promise; onLoadSignedPdf: () => Promise; - onAddParticipants: (userIds: number[], defaultReason?: string) => Promise; + onAddParticipants: (userIds: number[], emails: string[], defaultReason?: string) => Promise; onRemoveParticipant: (participantId: number) => Promise; onDelete: () => Promise; onBack: () => void; @@ -67,9 +67,9 @@ const SessionDetailWorkbenchView = ({ data }: SessionDetailWorkbenchViewProps) = } }, [session.finalized, onRefresh]); - const handleAddParticipants = async (userIds: number[], defaultReason?: string) => { + const handleAddParticipants = async (userIds: number[], emails: string[], defaultReason?: string) => { try { - await onAddParticipants(userIds, defaultReason); + await onAddParticipants(userIds, emails, defaultReason); alert({ alertType: 'success', title: t('success'), diff --git a/frontend/src/core/components/tools/certSign/modals/AddParticipantsFlow.tsx b/frontend/src/core/components/tools/certSign/modals/AddParticipantsFlow.tsx index 81b8d8560f..8ca22a1f5f 100644 --- a/frontend/src/core/components/tools/certSign/modals/AddParticipantsFlow.tsx +++ b/frontend/src/core/components/tools/certSign/modals/AddParticipantsFlow.tsx @@ -1,13 +1,28 @@ import { useState } from 'react'; -import { Modal, Stack, TextInput, Button, Group } from '@mantine/core'; +import { + ActionIcon, + Badge, + Button, + Group, + Modal, + Stack, + Tabs, + Text, + TextInput, +} from '@mantine/core'; import { useTranslation } from 'react-i18next'; import AddIcon from '@mui/icons-material/Add'; +import CloseIcon from '@mui/icons-material/Close'; +import PersonIcon from '@mui/icons-material/Person'; +import EmailIcon from '@mui/icons-material/Email'; import UserSelector from '@app/components/shared/UserSelector'; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + interface AddParticipantsFlowProps { opened: boolean; onClose: () => void; - onSubmit: (userIds: number[], defaultReason?: string) => Promise; + onSubmit: (userIds: number[], emails: string[], defaultReason?: string) => Promise; } export const AddParticipantsFlow: React.FC = ({ @@ -17,19 +32,52 @@ export const AddParticipantsFlow: React.FC = ({ }) => { const { t } = useTranslation(); const [selectedUserIds, setSelectedUserIds] = useState([]); + const [externalEmails, setExternalEmails] = useState([]); + const [emailInput, setEmailInput] = useState(''); + const [emailError, setEmailError] = useState(''); const [defaultReason, setDefaultReason] = useState(''); const [submitting, setSubmitting] = useState(false); + const totalCount = selectedUserIds.length + externalEmails.length; + const handleClose = () => { setSelectedUserIds([]); + setExternalEmails([]); + setEmailInput(''); + setEmailError(''); setDefaultReason(''); onClose(); }; + function handleAddEmail() { + const trimmed = emailInput.trim(); + if (!EMAIL_RE.test(trimmed)) { + setEmailError( + t( + 'groupSigning.steps.selectParticipants.invalidEmail', + 'Please enter a valid email address' + ) + ); + return; + } + if (externalEmails.includes(trimmed)) { + setEmailError( + t( + 'groupSigning.steps.selectParticipants.duplicateEmail', + 'This email has already been added' + ) + ); + return; + } + setExternalEmails((prev) => [...prev, trimmed]); + setEmailInput(''); + setEmailError(''); + } + const handleSubmit = async () => { setSubmitting(true); try { - await onSubmit(selectedUserIds, defaultReason.trim() || undefined); + await onSubmit(selectedUserIds, externalEmails, defaultReason.trim() || undefined); handleClose(); } catch (error) { console.error('Failed to add participants:', error); @@ -46,18 +94,87 @@ export const AddParticipantsFlow: React.FC = ({ size="lg" > - + + + }> + {t('groupSigning.steps.selectParticipants.tabRegistered', 'Registered Users')} + + }> + {t('groupSigning.steps.selectParticipants.tabExternal', 'External (by email)')} + + + + + + + + + + + {t( + 'groupSigning.steps.selectParticipants.externalNote', + 'An invitation email will be sent with a signing link. No account required.' + )} + + + { + setEmailInput(e.currentTarget.value); + if (emailError) setEmailError(''); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddEmail(); + } + }} + error={emailError} + /> + + + {externalEmails.map((email) => ( + + + + {t('groupSigning.steps.selectParticipants.badgeExternal', 'Guest')} + + {email} + + setExternalEmails((prev) => prev.filter((e) => e !== email))} + > + + + + ))} + + + setDefaultReason(e.currentTarget.value)} - placeholder={t('certSign.collab.addParticipants.reasonPlaceholder', 'e.g. Approval, Review...')} + placeholder={t( + 'certSign.collab.addParticipants.reasonPlaceholder', + 'e.g. Approval, Review...' + )} size="sm" /> @@ -68,12 +185,12 @@ export const AddParticipantsFlow: React.FC = ({ diff --git a/frontend/src/core/routes/GuestSignPage.test.tsx b/frontend/src/core/routes/GuestSignPage.test.tsx new file mode 100644 index 0000000000..d3944ae45e --- /dev/null +++ b/frontend/src/core/routes/GuestSignPage.test.tsx @@ -0,0 +1,321 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MantineProvider } from '@mantine/core'; + +// ── i18n ──────────────────────────────────────────────────────────────────── + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string | Record) => { + if (typeof fallback === 'string') return fallback; + return key; + }, + }), + initReactI18next: { type: '3rdParty', init: vi.fn() }, +})); + +// ── react-router-dom: useParams returns our injected token ────────────────── + +const mockToken = 'test-token-abc'; + +vi.mock('react-router-dom', () => ({ + useParams: () => ({ token: mockToken }), +})); + +// ── Heavy sub-components — stub them out so we don't need full canvas / MUI ─ + +vi.mock('@app/components/shared/wetSignature/DrawSignatureCanvas', () => ({ + DrawSignatureCanvas: ({ onChange }: { onChange: (v: string | null) => void }) => ( + + ), +})); + +vi.mock('@app/components/shared/wetSignature/SignatureTypeSelector', () => ({ + SignatureTypeSelector: () =>
, + SignatureType: {}, +})); + +vi.mock('@app/components/shared/wetSignature/TypeSignatureText', () => ({ + TypeSignatureText: () =>
, +})); + +vi.mock('@app/components/shared/wetSignature/UploadSignatureImage', () => ({ + UploadSignatureImage: () =>
, +})); + +vi.mock('@app/components/shared/signing/GuestCertificateChooser', () => ({ + GuestCertificateChooser: ({ + value, + onChange, + }: { + value: string; + onChange: (v: string) => void; + }) => ( +
+ + +
+ ), + GuestCertType: {}, +})); + +vi.mock('@app/types/signingSession', () => ({})); + +// ── Component under test ──────────────────────────────────────────────────── + +import GuestSignPage from './GuestSignPage'; + +// ── Test wrapper ──────────────────────────────────────────────────────────── + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// ── Fetch helpers ─────────────────────────────────────────────────────────── + +function mockFetchSuccess( + sessionData = { sessionId: '1', documentName: 'Contract.pdf', ownerEmail: 'owner@example.com' }, + participantData = { id: 1, email: 'guest@example.com', name: 'Guest', status: 'VIEWED' } +) { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((url: string) => { + if (url.includes('/session')) { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(sessionData) }); + } + if (url.includes('/details')) { + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(participantData), + }); + } + if (url.includes('/document')) { + return Promise.resolve({ ok: true, status: 200 }); + } + // submit-signature or decline + return Promise.resolve({ ok: true, status: 200 }); + }) + ); +} + +function mockFetchForbidden() { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 403 }) + ); +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('GuestSignPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders loading state on mount before fetch resolves', () => { + // Never resolve the fetch — stays in loading state + vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))); + + render( + + + + ); + + expect(screen.getByText(/loading signing session/i)).toBeInTheDocument(); + }); + + it('shows expired message when fetch returns 403', async () => { + mockFetchForbidden(); + + render( + + + + ); + + await waitFor(() => { + expect( + screen.getByText(/This signing link has expired\./i) + ).toBeInTheDocument(); + }); + }); + + it('shows signed success state when participant status is SIGNED', async () => { + mockFetchSuccess(undefined, { + id: 1, + email: 'guest@example.com', + name: 'Guest', + status: 'SIGNED', + }); + + render( + + + + ); + + await waitFor(() => { + expect( + screen.getByText(/Your signature has been submitted successfully\./i) + ).toBeInTheDocument(); + }); + }); + + it('shows declined state when participant status is DECLINED', async () => { + mockFetchSuccess(undefined, { + id: 1, + email: 'guest@example.com', + name: 'Guest', + status: 'DECLINED', + }); + + render( + + + + ); + + await waitFor(() => { + expect( + screen.getByText(/You have declined this signing request\./i) + ).toBeInTheDocument(); + }); + }); + + it('renders the signing form in ready state', async () => { + mockFetchSuccess(); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Sign Document')).toBeInTheDocument(); + expect(screen.getByText('Contract.pdf')).toBeInTheDocument(); + }); + }); + + it('shows auto-cert chooser selected by default', async () => { + mockFetchSuccess(); + + render( + + + + ); + + await waitFor(() => { + const chooser = screen.getByTestId('cert-chooser'); + expect(chooser).toHaveAttribute('data-value', 'GUEST_CERT'); + }); + }); + + it('submits with GUEST_CERT when auto cert is selected', async () => { + const user = userEvent.setup(); + mockFetchSuccess(); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Sign Document')).toBeInTheDocument(); + }); + + const submitButton = screen.getByText('Submit Signature'); + await user.click(submitButton); + + await waitFor(() => { + const fetchMock = vi.mocked(fetch); + const submitCall = fetchMock.mock.calls.find(([url]) => + String(url).includes('submit-signature') + ); + expect(submitCall).toBeDefined(); + const body = submitCall![1]!.body as FormData; + expect(body.get('certType')).toBe('GUEST_CERT'); + expect(body.get('participantToken')).toBe(mockToken); + }); + }); + + it('shows success state after successful submission', async () => { + const user = userEvent.setup(); + mockFetchSuccess(); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Sign Document')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Submit Signature')); + + await waitFor(() => { + expect( + screen.getByText(/Your signature has been submitted successfully\./i) + ).toBeInTheDocument(); + }); + }); + + it('opens decline confirmation modal when Decline is clicked', async () => { + const user = userEvent.setup(); + mockFetchSuccess(); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Sign Document')).toBeInTheDocument(); + }); + + // Click the outer Decline button (variant="subtle") + const outerDeclineBtn = screen.getAllByRole('button').find( + (btn) => btn.getAttribute('data-variant') === 'subtle' + ); + expect(outerDeclineBtn).toBeDefined(); + await user.click(outerDeclineBtn!); + + // Modal confirmation dialog should appear + await waitFor(() => { + expect(screen.getByText('Decline signing?')).toBeInTheDocument(); + expect(screen.getByText(/Are you sure you want to decline/i)).toBeInTheDocument(); + }); + + // Cancel button should close the modal + await user.click(screen.getByRole('button', { name: /cancel/i })); + + await waitFor(() => { + expect(screen.queryByText('Decline signing?')).not.toBeInTheDocument(); + }); + }); + + it('shows error state when fetch fails with non-403 error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 500 }) + ); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/core/routes/GuestSignPage.tsx b/frontend/src/core/routes/GuestSignPage.tsx new file mode 100644 index 0000000000..e4f8e6c09c --- /dev/null +++ b/frontend/src/core/routes/GuestSignPage.tsx @@ -0,0 +1,385 @@ +import { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { + Alert, + Button, + Center, + Divider, + Group, + Loader, + Modal, + Paper, + Stack, + Text, + Title, +} from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { useTranslation } from 'react-i18next'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; + +import { DrawSignatureCanvas } from '@app/components/shared/wetSignature/DrawSignatureCanvas'; +import { SignatureTypeSelector, SignatureType } from '@app/components/shared/wetSignature/SignatureTypeSelector'; +import { TypeSignatureText } from '@app/components/shared/wetSignature/TypeSignatureText'; +import { UploadSignatureImage } from '@app/components/shared/wetSignature/UploadSignatureImage'; +import { GuestCertificateChooser, GuestCertType } from '@app/components/shared/signing/GuestCertificateChooser'; +import type { WetSignatureMetadata } from '@app/types/signingSession'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +interface SessionInfo { + sessionId: string; + documentName: string; + ownerEmail: string; + message?: string; + dueDate?: string; +} + +interface ParticipantDetails { + id: number; + email: string; + name: string; + status: string; + expiresAt?: string; +} + +type PageState = 'loading' | 'ready' | 'expired' | 'signed' | 'declined' | 'error'; + +// ─── Component ────────────────────────────────────────────────────────────── + +export default function GuestSignPage() { + const { token } = useParams<{ token: string }>(); + const { t } = useTranslation(); + + // Page state + const [pageState, setPageState] = useState('loading'); + const [errorMessage, setErrorMessage] = useState(''); + const [session, setSession] = useState(null); + const [participant, setParticipant] = useState(null); + + // Signature state + const [sigType, setSigType] = useState('draw'); + const [sigData, setSigData] = useState(null); + + // Certificate state + const [certType, setCertType] = useState('GUEST_CERT'); + const [p12File, setP12File] = useState(null); + const [certPassword, setCertPassword] = useState(''); + + // Submission state + const [submitting, setSubmitting] = useState(false); + const [declineOpen, { open: openDecline, close: closeDecline }] = useDisclosure(false); + const [declining, setDeclining] = useState(false); + + // ── Load session on mount ────────────────────────────────────────────── + + useEffect(() => { + if (!token) { + setPageState('error'); + setErrorMessage('Missing signing token.'); + return; + } + + async function load() { + try { + const [sessionRes, detailsRes] = await Promise.all([ + fetch(`/api/v1/workflow/participant/session?token=${encodeURIComponent(token!)}`), + fetch(`/api/v1/workflow/participant/details?token=${encodeURIComponent(token!)}`), + ]); + + if (sessionRes.status === 403 || detailsRes.status === 403) { + setPageState('expired'); + return; + } + if (!sessionRes.ok || !detailsRes.ok) { + setPageState('error'); + setErrorMessage('Unable to load signing session.'); + return; + } + + const sessionData: SessionInfo = await sessionRes.json(); + const participantData: ParticipantDetails = await detailsRes.json(); + + setSession(sessionData); + setParticipant(participantData); + + if (participantData.status === 'SIGNED') { + setPageState('signed'); + } else if (participantData.status === 'DECLINED') { + setPageState('declined'); + } else { + setPageState('ready'); + } + } catch { + setPageState('error'); + setErrorMessage('An unexpected error occurred.'); + } + } + + load(); + }, [token]); + + // ── Submit signature ─────────────────────────────────────────────────── + + async function handleSubmit() { + if (!token) return; + setSubmitting(true); + try { + const formData = new FormData(); + formData.append('participantToken', token); + formData.append('certType', certType); + + if (certType === 'P12') { + if (!p12File) { + alert(t('guestSigning.certFileRequired', 'Please select a certificate file.')); + setSubmitting(false); + return; + } + formData.append('p12File', p12File); + formData.append('password', certPassword); + } + + if (sigData) { + const wetSig: WetSignatureMetadata = { + type: sigType === 'type' ? 'text' : sigType === 'draw' ? 'canvas' : 'image', + data: sigData, + page: 0, + x: 50, + y: 50, + width: 200, + height: 60, + }; + formData.append('wetSignaturesData', JSON.stringify([wetSig])); + } + + const res = await fetch('/api/v1/workflow/participant/submit-signature', { + method: 'POST', + body: formData, + }); + + if (!res.ok) { + const body = await res.text(); + setErrorMessage(body || t('guestSigning.submitError', 'Failed to submit signature.')); + setPageState('error'); + return; + } + + setPageState('signed'); + } finally { + setSubmitting(false); + } + } + + // ── Decline ──────────────────────────────────────────────────────────── + + async function handleDecline() { + if (!token) return; + setDeclining(true); + try { + const res = await fetch( + `/api/v1/workflow/participant/decline?token=${encodeURIComponent(token)}`, + { method: 'POST' } + ); + if (res.ok) { + setPageState('declined'); + } + } finally { + setDeclining(false); + closeDecline(); + } + } + + // ── Render helpers ───────────────────────────────────────────────────── + + if (pageState === 'loading') { + return ( +
+ + + {t('guestSigning.loadingSession', 'Loading signing session...')} + +
+ ); + } + + if (pageState === 'expired') { + return ( +
+ + + + {t('guestSigning.expiredToken', 'This signing link has expired.')} + + {t('guestSigning.expiredNote', 'Please contact the document owner for a new link.')} + + + +
+ ); + } + + if (pageState === 'signed') { + return ( +
+ + + + {t('guestSigning.submitSuccess', 'Your signature has been submitted successfully.')} + + {t('guestSigning.signedNote', 'The document owner has been notified. You may close this window.')} + + + +
+ ); + } + + if (pageState === 'declined') { + return ( +
+ + + + {t('guestSigning.declineSuccess', 'You have declined this signing request.')} + + {t('guestSigning.declinedNote', 'The document owner has been notified. You may close this window.')} + + + +
+ ); + } + + if (pageState === 'error') { + return ( +
+ + + + {t('guestSigning.errorTitle', 'Something went wrong')} + {errorMessage} + + +
+ ); + } + + // ── Main signing form ────────────────────────────────────────────────── + + return ( +
+ + + {/* Header */} + + {t('guestSigning.pageTitle', 'Sign Document')} + {session && ( + <> + {session.documentName} + + {t('guestSigning.requestedBy', 'Requested by {{owner}}', { + owner: session.ownerEmail, + })} + + {session.dueDate && ( + + {t('guestSigning.dueDate', 'Due {{date}}', { date: session.dueDate })} + + )} + {session.message && ( + + {session.message} + + )} + + )} + + + + + {/* PDF preview */} + {token && ( + + + {t('guestSigning.documentPreview', 'Document')} + +